Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.2.1 - Unreleased

- Added a standalone, live-updating, cookie-authenticated thread embed with the shared thread composer and rendering, public route IDs, focused auth recovery, and an opt-in per-origin `/embed/*` frame policy.
- Signed and notarized macOS desktop release bundles with the OpenClaw Foundation identity, hardened runtime, and strict sealed-artifact verification.

## 0.2.0 - 2026-07-17
Expand Down Expand Up @@ -46,6 +47,30 @@
- Proxied WebSocket upgrades in the Vite development server so local realtime connects without a reconnect loop.
- Refreshed frontend, Electron, Cloudflare Worker, router, SQLite, Node, pnpm, GoReleaser, GitHub Actions, and container bases to their latest stable releases.
- Aligned the pull-request template with the repository workflow. Thanks @hannesrudolph.
||||||| parent of 26c95ce (feat: add embeddable thread view)
- Added validated split frontend/API origins, credentialed exact-origin browser access, and versioned server-issued bot setup endpoints while preserving same-origin setup commands.
- Refreshed frontend, Cloudflare Worker, router, and SQLite dependencies to their current compatible releases.
- Tightened integration administration with manager-only registration changes, signing-secret rotation, atomic installation revoke cascades, paginated delivery history, and a discoverable durable event-type vocabulary.
- Added owner-scoped message nonce lookup so durable clients can distinguish completed sends from interrupted requests and recover attachments safely. Thanks @shakkernerd.
- Added durable upload idempotency and nonce lookup so retried bot deliveries reuse the original upload without consuming more storage or quota. Thanks @shakkernerd.
- Allowed disabled Pushover notification settings to clear the stored user key through the public API contract.
- Unified the desktop shell chrome like Slack: title bar, workspace rail, and sidebar share one continuous plate with the conversation floating on it as a rounded card, the sidebar workspace header and the in-card channel header are gone on desktop — the workspace name (click for workspace settings) and the current channel or DM title live in the title bar — and the always-on "Connected" labels are gone everywhere (app settings stay on the native menu and Cmd/Ctrl+,; a pulsing "Connecting…" note appears only while the realtime link is down). The browser app keeps its sidebar workspace header and channel header unchanged.
- Fixed live agent-activity bursts so same-turn preambles grow in place without dropping realtime rows or pulling a bottom-pinned timeline away from the live edge.
- Added typed agent-progress SDK payloads while preserving workspace-wide presence events without channel or DM targets. Thanks @arcabotai for surfacing the integration needs.
- Softened message bubbles: hairline accent-tint borders replace the heavier outlines, gentler keycap under-edges, and roomier padding.
- Added Appearance settings with a light/dark/system color mode and four full-app board themes (Signal, the heritage Ember, Moss, Iris) — each tuned for both modes via a light-dark() token refactor, applied instantly with live swatch previews, persisted per device, and flash-free on load.
- Redesigned the product site around the "Switchboard" keycap identity: cool porcelain/night-board themes over a dot-grid plate, electric cyan accent with indigo bot/thread tones, keycap buttons and step markers with press states, Bricolage Grotesque display type, and mono kickers/labels.
- Redesigned the web app around a "Switchboard" keycap identity: cool porcelain/night-board themes replace the warm paper look, an electric cyan signal accent with indigo delivery and amber working-log tones, tactile keycap controls with hard press states, Bricolage Grotesque display type, and mono machine metadata (timestamps, section labels, day dividers, bot chips).
- Added personal channel ordering with desktop drag-and-drop, keyboard and touch move-menu controls, per-user/workspace browser persistence, same-profile tab synchronization, and preserved active/unread priority rows while sections are collapsed. Thanks @jjjhenriksen.
- Added independently collapsible Channels, Direct messages, and People sidebar sections that retain active and unread rows while collapsed, with accessible controls and per-workspace browser persistence. Thanks @jjjhenriksen.
- Unified the web app on one warm "paper & ember" design language: a new espresso dark theme replaces the cool slate look, identity marks share a theme-tied brand gradient, off-palette purple/indigo/blue hardcodes (including workspace-settings buttons and quote blocks) now follow the coral accent, settings surfaces inherit the app font, and scrollbars are visible on the light theme.
- Added manager workspace profile and icon controls plus owner-only ownership transfer and permanent deletion with durable upload cleanup. Thanks @Solvely-Colin.
- Proxied WebSocket upgrades in the Vite dev server so the local app connects to realtime instead of looping on "Reconnecting…".
- Added a bounded read-only artifact pane for code, text, Markdown, PDF, and sandboxed HTML attachments, while keeping DOCX download-only and preserving thread routes when the pane closes. Thanks @jjjhenriksen.
- Added an opt-in realtime tail cursor so fresh clients can skip retained history without racing new events, and applied read-receipt visibility before event pagination. Thanks @shakkernerd.
- Added SDK helpers for paginated realtime recovery and bounded latest thread-history windows. Thanks @arcabotai for surfacing the integration needs.
- Preserved validated request correlation IDs as optional metadata on durable message and thread-reply events across replay and realtime delivery, and added canary run/case evidence IDs without changing message storage or gateway traffic.
- Added an isolated FakeCo small-VM deployment path with idempotent synthetic chat seed data, OpenClaw and ClawRouter SecretRef configuration, correlated health/readiness and metadata-only telemetry, a quoted-reply end-to-end canary, tests, and teardown guidance.

## 0.1.0 - 2026-07-06

Expand Down
14 changes: 9 additions & 5 deletions apps/api/cmd/clickclack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func serve(args []string) error {
configPath := flags.String("config", "", "config file")
flags.Bool("dev-bootstrap", false, "create a local owner/workspace/channel if no user exists")
flags.Bool("metrics-enabled", false, "expose metadata-only Prometheus metrics at /metrics")
flags.String("embed-frame-ancestors", "", "comma-separated origins allowed to embed /embed/* pages")
if err := flags.Parse(args); err != nil {
return err
}
Expand Down Expand Up @@ -133,11 +134,12 @@ func serve(args []string) error {
}
log.Printf("ClickClack listening on %s", displayURL(cfg.Addr))
server := httpapi.New(st, realtime.NewHub(), httpapi.Options{
UploadStorage: uploads,
DisableDevAuth: !cfg.DevBootstrap,
CookieNames: cookieNames,
FrontendURL: cfg.PublicURL,
PublicAPIURL: cfg.PublicAPIURL,
UploadStorage: uploads,
DisableDevAuth: !cfg.DevBootstrap,
CookieNames: cookieNames,
FrontendURL: cfg.PublicURL,
PublicAPIURL: cfg.PublicAPIURL,
EmbedFrameAncestors: cfg.EmbedFrameAncestors,
GitHubOAuth: httpapi.GitHubOAuthConfig{
ClientID: cfg.GitHubClientID,
ClientSecret: cfg.GitHubClientSecret,
Expand Down Expand Up @@ -582,6 +584,8 @@ func applyFlagOverrides(flags *flag.FlagSet, cfg *config.Config) {
cfg.DevBootstrap = f.Value.String() == "true"
case "metrics-enabled":
cfg.MetricsEnabled = f.Value.String() == "true"
case "embed-frame-ancestors":
cfg.EmbedFrameAncestors = config.ParseEmbedFrameAncestors(f.Value.String())
}
})
}
Expand Down
14 changes: 14 additions & 0 deletions apps/api/cmd/clickclack/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -58,6 +59,19 @@ func TestCommandDBDefaultsUseEnvironment(t *testing.T) {
}
}

func TestApplyFlagOverridesParsesEmbedFrameAncestors(t *testing.T) {
flags := flag.NewFlagSet("test", flag.ContinueOnError)
flags.String("embed-frame-ancestors", "", "")
if err := flags.Parse([]string{"--embed-frame-ancestors", "https://control.example.com,https://dock.example.com"}); err != nil {
t.Fatal(err)
}
cfg := config.Config{}
applyFlagOverrides(flags, &cfg)
if len(cfg.EmbedFrameAncestors) != 2 || cfg.EmbedFrameAncestors[1] != "https://dock.example.com" {
t.Fatalf("unexpected embed frame ancestors: %#v", cfg.EmbedFrameAncestors)
}
}

func TestFakeCoSeedRequiresExplicitEnvironment(t *testing.T) {
t.Setenv("CLICKCLACK_ENVIRONMENT", "")
err := admin([]string{"fakeco", "seed", "--data", t.TempDir()})
Expand Down
80 changes: 61 additions & 19 deletions apps/api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,26 @@ import (
)

type Config struct {
Addr string `json:"addr"`
Data string `json:"data"`
DB string `json:"db"`
Uploads string `json:"uploads"`
Environment string `json:"environment"`
MetricsEnabled bool `json:"metrics_enabled"`
PublicURL string `json:"public_url"`
PublicAPIURL string `json:"public_api_url"`
CookieNamespace string `json:"cookie_namespace"`
DevBootstrap bool `json:"dev_bootstrap"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
GitHubAllowedOrg string `json:"github_allowed_org"`
GitHubModeratorOrg string `json:"github_moderator_org"`
PushoverAPIToken string `json:"pushover_api_token"`
R2AccountID string `json:"r2_account_id"`
R2AccessKeyID string `json:"r2_access_key_id"`
R2SecretAccessKey string `json:"r2_secret_access_key"`
R2Endpoint string `json:"r2_endpoint"`
Addr string `json:"addr"`
Data string `json:"data"`
DB string `json:"db"`
Uploads string `json:"uploads"`
Environment string `json:"environment"`
MetricsEnabled bool `json:"metrics_enabled"`
PublicURL string `json:"public_url"`
PublicAPIURL string `json:"public_api_url"`
EmbedFrameAncestors []string `json:"embed_frame_ancestors"`
CookieNamespace string `json:"cookie_namespace"`
DevBootstrap bool `json:"dev_bootstrap"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
GitHubAllowedOrg string `json:"github_allowed_org"`
GitHubModeratorOrg string `json:"github_moderator_org"`
PushoverAPIToken string `json:"pushover_api_token"`
R2AccountID string `json:"r2_account_id"`
R2AccessKeyID string `json:"r2_access_key_id"`
R2SecretAccessKey string `json:"r2_secret_access_key"`
R2Endpoint string `json:"r2_endpoint"`
}

func Defaults() Config {
Expand Down Expand Up @@ -83,6 +84,9 @@ func Load(path string) (Config, error) {
if env := os.Getenv("CLICKCLACK_PUBLIC_API_URL"); env != "" {
cfg.PublicAPIURL = env
}
if env := os.Getenv("CLICKCLACK_EMBED_FRAME_ANCESTORS"); env != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward embed origins into Cloudflare containers

When ClickClack is deployed through the checked Cloudflare pipeline, this environment setting never reaches the API process: infra/cloudflare/worker.ts explicitly constructs ClickClackContainer.envVars but does not forward env.CLICKCLACK_EMBED_FRAME_ANCESTORS. Consequently, configuring the documented Worker variable leaves cfg.EmbedFrameAncestors empty and every embedded page remains restricted to 'self', making cross-origin embedding unusable on that deployment.

Useful? React with 👍 / 👎.

cfg.EmbedFrameAncestors = ParseEmbedFrameAncestors(env)
}
if env := os.Getenv("CLICKCLACK_COOKIE_NAMESPACE"); env != "" {
cfg.CookieNamespace = env
}
Expand Down Expand Up @@ -145,6 +149,10 @@ func (c *Config) ValidateServe() error {
if publicAPIURL == "" {
publicAPIURL = publicURL
}
embedFrameAncestors, err := normalizeEmbedFrameAncestors(c.EmbedFrameAncestors)
if err != nil {
return fmt.Errorf("CLICKCLACK_EMBED_FRAME_ANCESTORS: %w", err)
}
if err := validatePublicURLPair(publicURL, publicAPIURL); err != nil {
return err
}
Expand All @@ -167,6 +175,7 @@ func (c *Config) ValidateServe() error {
return fmt.Errorf("cookie policy: %w", err)
}
c.PublicAPIURL = publicAPIURL
c.EmbedFrameAncestors = embedFrameAncestors
c.CookieNamespace = namespace
c.PublicURL = publicURL
c.GitHubClientID = clientID
Expand All @@ -176,6 +185,39 @@ func (c *Config) ValidateServe() error {
return nil
}

// ParseEmbedFrameAncestors parses the comma- or whitespace-separated format
// accepted by CLICKCLACK_EMBED_FRAME_ANCESTORS and --embed-frame-ancestors.
func ParseEmbedFrameAncestors(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
}

func normalizeEmbedFrameAncestors(values []string) ([]string, error) {
seen := make(map[string]struct{}, len(values))
normalized := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
parsed, err := url.Parse(value)
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Hostname() == "" ||
strings.Contains(parsed.Hostname(), "*") ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
(parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
Comment on lines +204 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject origins with invalid ports

Reject malformed port forms while validating configured origins. Go's url.Parse accepts values such as https://control.example.com: and https://control.example.com:99999, and the current predicates accept and emit them into frame-ancestors; browsers cannot match these as valid origins, so an operator typo silently leaves the intended host unable to frame the embed instead of failing startup validation.

Useful? React with 👍 / 👎.

return nil, fmt.Errorf("%q must be an HTTP(S) origin without a path, query, or fragment", value)
}
origin := (&url.URL{Scheme: strings.ToLower(parsed.Scheme), Host: strings.ToLower(parsed.Host)}).String()
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
normalized = append(normalized, origin)
}
return normalized, nil
}

func validatePublicURLPair(publicURL, publicAPIURL string) error {
if publicURL == "" || publicAPIURL == "" {
return nil
Expand Down
13 changes: 12 additions & 1 deletion apps/api/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_METRICS_ENABLED", "true")
t.Setenv("CLICKCLACK_PUBLIC_URL", "https://clickclack.test")
t.Setenv("CLICKCLACK_PUBLIC_API_URL", "https://api.clickclack.test/services/clickclack/")
t.Setenv("CLICKCLACK_EMBED_FRAME_ANCESTORS", "https://control.example.com, https://dock.example.com")
t.Setenv("CLICKCLACK_COOKIE_NAMESPACE", "prod-2")
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "false")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "client")
Expand All @@ -30,7 +31,7 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.Uploads != "r2://clickclack-uploads/prod" || cfg.Environment != "fakeco" || !cfg.MetricsEnabled || cfg.PublicURL != "https://clickclack.test" || cfg.PublicAPIURL != "https://api.clickclack.test/services/clickclack/" || cfg.CookieNamespace != "prod-2" || cfg.DevBootstrap || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" || cfg.GitHubAllowedOrg != "openclaw" || cfg.GitHubModeratorOrg != "openclaw" || cfg.PushoverAPIToken != "app-token" || cfg.R2AccountID != "account" || cfg.R2AccessKeyID != "access" || cfg.R2SecretAccessKey != "secret-access" || cfg.R2Endpoint != "https://r2.example.com" {
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.Uploads != "r2://clickclack-uploads/prod" || cfg.Environment != "fakeco" || !cfg.MetricsEnabled || cfg.PublicURL != "https://clickclack.test" || cfg.PublicAPIURL != "https://api.clickclack.test/services/clickclack/" || len(cfg.EmbedFrameAncestors) != 2 || cfg.EmbedFrameAncestors[0] != "https://control.example.com" || cfg.CookieNamespace != "prod-2" || cfg.DevBootstrap || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" || cfg.GitHubAllowedOrg != "openclaw" || cfg.GitHubModeratorOrg != "openclaw" || cfg.PushoverAPIToken != "app-token" || cfg.R2AccountID != "account" || cfg.R2AccessKeyID != "access" || cfg.R2SecretAccessKey != "secret-access" || cfg.R2Endpoint != "https://r2.example.com" {
t.Fatalf("unexpected env config: %#v", cfg)
}

Expand All @@ -54,6 +55,7 @@ func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_METRICS_ENABLED", "")
t.Setenv("CLICKCLACK_PUBLIC_URL", "")
t.Setenv("CLICKCLACK_PUBLIC_API_URL", "")
t.Setenv("CLICKCLACK_EMBED_FRAME_ANCESTORS", "")
t.Setenv("CLICKCLACK_COOKIE_NAMESPACE", "")
t.Setenv("CLICKCLACK_DEV_BOOTSTRAP", "")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "")
Expand Down Expand Up @@ -140,6 +142,13 @@ func TestValidateServe(t *testing.T) {
if err := splitOrigin.ValidateServe(); err != nil || splitOrigin.PublicAPIURL != "https://api.example.com/services/clickclack" {
t.Fatalf("expected canonical split API URL: %#v %v", splitOrigin, err)
}
embedOrigins := Config{EmbedFrameAncestors: []string{"HTTPS://Control.Example.com/", "https://control.example.com", "http://localhost:3000"}}
if err := embedOrigins.ValidateServe(); err != nil {
t.Fatal(err)
}
if len(embedOrigins.EmbedFrameAncestors) != 2 || embedOrigins.EmbedFrameAncestors[0] != "https://control.example.com" || embedOrigins.EmbedFrameAncestors[1] != "http://localhost:3000" {
t.Fatalf("unexpected normalized embed origins: %#v", embedOrigins.EmbedFrameAncestors)
}

for _, tc := range []struct {
name string
Expand All @@ -155,6 +164,8 @@ func TestValidateServe(t *testing.T) {
{"missing client secret", Config{PublicURL: "https://chat.example.com", GitHubClientID: "client"}},
{"oauth without public url", Config{GitHubClientID: "client", GitHubClientSecret: "secret"}},
{"org without oauth", Config{GitHubAllowedOrg: "openclaw"}},
{"invalid embed ancestor", Config{EmbedFrameAncestors: []string{"https://control.example.com/path"}}},
{"wildcard embed ancestor", Config{EmbedFrameAncestors: []string{"https://*.example.com"}}},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.cfg.ValidateServe(); err == nil {
Expand Down
34 changes: 22 additions & 12 deletions apps/api/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Server struct {
githubOAuth GitHubOAuthConfig
frontendURL string
publicAPIURL string
embedFrameAncestors []string
cookies authpolicy.CookieNames
cookieSameSite http.SameSite
disableDevAuth bool
Expand Down Expand Up @@ -67,18 +68,19 @@ type actor struct {
}

type Options struct {
UploadDir string
UploadStorage uploadstore.Store
GitHubOAuth GitHubOAuthConfig
FrontendURL string
PublicAPIURL string
CookieNames authpolicy.CookieNames
DisableDevAuth bool
PushNotifier PushNotifier
MetricsEnabled bool
Environment string
Version string
Commit string
UploadDir string
UploadStorage uploadstore.Store
GitHubOAuth GitHubOAuthConfig
FrontendURL string
PublicAPIURL string
EmbedFrameAncestors []string
CookieNames authpolicy.CookieNames
DisableDevAuth bool
PushNotifier PushNotifier
MetricsEnabled bool
Environment string
Version string
Commit string
}

func New(st store.Store, hub *realtime.Hub, options Options) *Server {
Expand All @@ -102,6 +104,7 @@ func New(st store.Store, hub *realtime.Hub, options Options) *Server {
githubOAuth: options.GitHubOAuth.withDefaults(),
frontendURL: strings.TrimSpace(options.FrontendURL),
publicAPIURL: strings.TrimRight(strings.TrimSpace(options.PublicAPIURL), "/"),
embedFrameAncestors: append([]string(nil), options.EmbedFrameAncestors...),
cookies: cookieNames,
cookieSameSite: configuredCookieSameSite(options.FrontendURL, options.PublicAPIURL),
disableDevAuth: options.DisableDevAuth,
Expand Down Expand Up @@ -1456,6 +1459,13 @@ func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if strings.HasPrefix(r.URL.Path, "/embed/") {
// frame-ancestors is deliberately independent of cookie SameSite policy:
// allowing a cross-site ancestor never loosens cookies, so such embeds can
// render signed-out. Documented in docs/features/embedding.md.
ancestors := append([]string{"'self'"}, s.embedFrameAncestors...)
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(ancestors, " "))
}
index = s.injectRuntimeConfig(index)
_, _ = w.Write(index)
}
Expand Down
Loading