* fix(core): add exponential backoff with jitter to WSClient reconnect
The WebSocket client used a flat 3-second reconnect delay with no
backoff, jitter, or attempt limit. When the server restarts, every
connected client (web + desktop) reconnects at exactly T+3s, creating
a thundering-herd connection spike.
Replace the fixed delay with exponential backoff:
- Base delay 1 s, doubling each attempt (1 → 2 → 4 → 8 → …)
- Cap at 30 s to keep recovery time reasonable
- ±20 % jitter to decorrelate clients that disconnect simultaneously
- Give up after 20 consecutive failures (log error, allow manual retry)
- Reset the counter on successful authentication
Add 7 unit tests covering the backoff curve, cap, jitter range,
counter reset, max-attempt cutoff, and disconnect cancellation.
Closes#5035
* fix(core): clamp jittered delay to max and make jitter test deterministic
Address Copilot review feedback:
1. Clamp the final delay to RECONNECT_MAX_DELAY_MS after jitter is
applied. Previously, when base was already at the 30s cap, +20%
jitter could push the delay to 36s, violating the configured max.
2. Replace the nondeterministic jitter test (which relied on real
Math.random() producing ≥2 distinct values in 20 samples) with a
deterministic stub that alternates between 0 and 1, asserting
exact min/max delays (800ms and 1200ms).
* fix(core): remove reconnect attempt limit, retry indefinitely with capped backoff
Address maintainer feedback (NevilleQingNY):
The web/desktop UI does not currently expose a visible disconnected
state or manual retry action, so the 20-attempt give-up limit would
leave an open tab silently stale after a long outage. Remove the
limit and let the client retry indefinitely with the 30s capped
jittered delay.
- Drop RECONNECT_MAX_ATTEMPTS constant and the give-up early-return
- Update JSDoc to document the indefinite-retry contract
- Replace "stops after max attempts" test with "keeps retrying
indefinitely with capped delay" that verifies 25+ attempts still
schedule reconnects at 30s
* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The post-#2946 onmessage guard logs the raw event.data alongside the
warning. A malformed or rogue server can stream arbitrarily large
garbage and bloat the renderer / desktop main-process log buffers, so
cap the logged payload to the first 200 chars and append a
"(truncated, N chars total)" suffix when truncation occurs.
MUL-2490
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): include actor_type in WebSocket broadcast messages
The WS broadcast message format was {type, payload, actor_id} but missing
actor_type. This meant the web UI could not distinguish agent from human
operations in real-time events at the top level.
While payload data for comments (author_type) and activities (entry.actor_type)
already included the type, the top-level message did not — causing the web UI
to display agent CLI operations as human operations when relying on the
broadcast actor identity.
Changes:
- server/cmd/server/listeners.go: add actor_type to all broadcast messages
- packages/core/types/events.ts: add actor_type to WSMessage interface
- packages/core/api/ws-client.ts: pass actor_type to event handlers
- packages/core/realtime/hooks.ts: update EventHandler type signature
- packages/core/realtime/provider.tsx: update EventHandler type signature
Fixes MUL-2260
Co-authored-by: multica-agent <github@multica.ai>
* test: add frame-shape unit test asserting actor_type in WS frames
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat: identify clients via X-Client-Platform/Version/OS
Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.
- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}
Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).
Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.
CORS allowlist extended for the new headers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: address client-identity PR nits
- Memoize the CoreProvider identity object on Web and Desktop, and key
WSProvider's effect on identity primitives instead of the object
reference, so unrelated parent re-renders no longer tear down and
reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
and asserts the "websocket connected" log entry surfaces them as
structured attributes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit 9b94914bc8.
* compat: legacy URL redirect + localStorage double-write for safe rollback
The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.
Two compat layers added on top of the restored refactor:
1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
/agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
/skills/*, /settings/*). Logged-in users with a last_workspace_slug
cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
without the cookie bounce through / where the landing page picks a
workspace client-side. Unauthenticated users go to /login.
2. Both layouts now double-write the workspace id to the legacy
localStorage key on every workspace entry. New code ignores this key
— it exists solely so that if this PR ever gets reverted again, the
legacy build reading the key would still find the correct workspace
and avoid the empty-dashboard symptom users saw during the rollback.
Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(platform): defer rehydrateAllWorkspaceStores to a microtask
Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.
Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: workspace URL refactor + slug-first API identity
Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).
## Key architectural changes
**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)
**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback
**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers
**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID
## Issues resolved
MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths
C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).
C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.
C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).
C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): wrap shell in WorkspaceSlugProvider from module var
AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).
Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): migrate old tab paths + fix shell slug deadlock
Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.
Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(security): use first-message auth for WebSocket instead of URL query param
Token was exposed in URL query parameters (HIGH-4 from security audit),
visible in server/proxy logs, browser history, and referrer headers.
Now non-cookie clients (desktop, CLI) send the token as the first
WebSocket message after the connection opens. Cookie-based auth (web)
continues to work unchanged. Server-side auth priority flipped to
cookie-first.
Closes MUL-580
* fix(security): add auth_ack and fix test JSON construction
Server sends auth_ack after successful first-message auth so the client
knows auth completed before firing reconnect callbacks. Test now uses
json.Marshal instead of string concatenation for the auth message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(test): update WebSocket integration test for first-message auth
The integration test still passed the token as a URL query param,
causing a timeout since the server now expects first-message auth
for non-cookie clients.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist
Security improvements from the MUL-566 audit report:
1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
preventing XSS-based token theft. Cookie-based auth includes CSRF
protection via double-submit cookie pattern. The Authorization header
path is preserved for Electron desktop app and CLI/PAT clients.
2. WebSocket upgrader now validates the Origin header against a
configurable allowlist (ALLOWED_ORIGINS env var), rejecting
connections from unauthorized origins.
Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync
1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>