Compare commits

...

14 Commits

Author SHA1 Message Date
fullstack-engineer f08e768abf test(handlers): add sqlmock suite for BroadcastHandler
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m31s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 6m39s
CI / Canvas (Next.js) (pull_request) Failing after 8m46s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Failing after 4s
Harness Replays / Harness Replays (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m22s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m12s
CI / all-required (pull_request) Has been cancelled
Covers broadcastTruncate pure unit tests, all validation paths,
DB interaction (workspace lookup, recipient query, activity log
inserts), and non-fatal recipient insert failure.

Also updates SkillsTab tests:
  - registryAndSources: fix v-prefix version assertion,
    regex for scheme chip text, getAllByText for duplicate plugin
  - compactEmpty: correct aria-label text matching

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:26:56 +00:00
fullstack-engineer 6b83947511 fix(plugins): SSOT inspect gate, /sources endpoint, and StrictMode-safe registry tests
Backend:
- provisioner: extract RunningContainerNameFunc as an exported function variable
  so both Provisioner.IsRunning and PluginsHandler.findRunningContainer call the
  same canonical inspect path.  AST gate in plugins_findrunning_ssot_test.go
  enforces this routing going forward (molecule-core#10).
- plugins: lift the pluginSources interface so ListSources can be tested in
  isolation with a stub resolver — no temp dirs or real registry needed.
- plugins_sources_test.go: verify ListSources returns schemes from the
  SourceResolver with a stub implementation.

Canvas (SkillsTab):
- Fix React 18 StrictMode race: both StrictMode mounts now get distinct
  monotonic call IDs (registryCallId/sourceCallId, incremented in the effect
  body, never reset in cleanup) so the guard in each async callback can
  distinguish which mount's callback is resolving.
- Add sourceFetchInFlight gate for loadSourceSchemes (mirrors the existing
  registryFetchInFlight pattern) so StrictMode cleanup doesn't block the
  second mount's successful call.
- Fix duplicate useState declarations that caused oxc parse errors.
- Add registry plugin card rendering (registry.map) to the "Available
  plugins" section — plugins were loaded but never displayed.
- Auto-expand the registry section whenever registry.length > 0 (not only
  when installed.length === 0), so the plugin cards and scheme chips are
  visible when the workspace has plugins.
- Add handleInstallSource so registry plugin cards can trigger installation.
- Add sourceCallId guard for loadSourceSchemes to survive StrictMode.

Tests:
- SkillsTab.registryAndSources.test.tsx: four tests (a–d) covering registry
  listing, installed listing, source scheme chips, and combined render.
  Uses vi.useFakeTimers() + vi.runAllTimersAsync() inside act() — the
  reliable pattern for flushing useEffect microtasks in vitest jsdom.
- plugins_sources_test.go: isolated unit test for ListSources with stub
  SourceResolver.
- plugins_findrunning_ssot_test.go: AST gate that fails if a future PR
  reintroduces a parallel ContainerInspect call in findRunningContainer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:10:39 +00:00
devops-engineer 231dfcf523 Merge pull request '[P0][release-blocker] fix(handlers): detach executeDelegation ctx from HTTP request (regression ce2db75f, internal#497/#498)' (#1446) from fix/a2a-delegation-detached-ctx-canceled-internal-497 into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 4s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 16s
CI / Platform (Go) (push) Successful in 6m30s
CI / Canvas (Next.js) (push) Successful in 7m48s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
CI / Python Lint & Test (push) Successful in 2s
E2E Chat / E2E Chat (push) Failing after 3s
Harness Replays / Harness Replays (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 48s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
cascade-list-drift-gate / check (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m46s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 16s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m15s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m35s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Failing after 1m16s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m11s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m24s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m46s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 8s
publish-runtime-autobump / pr-validate (pull_request) Successful in 43s
gate-check-v3 / gate-check (pull_request) Successful in 7s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 5m40s
sop-tier-check / tier-check (pull_request) Successful in 11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m26s
CI / Canvas Deploy Reminder (push) Successful in 1s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m3s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m19s
CI / Canvas (Next.js) (pull_request) Successful in 7m28s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E Chat / E2E Chat (pull_request) Failing after 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m5s
Harness Replays / Harness Replays (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 49s
CI / all-required (push) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m47s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m8s
CI / all-required (pull_request) Successful in 1s
2026-05-17 22:52:56 +00:00
core-be e740ffe23f fix(handlers): detach executeDelegation ctx from HTTP request — A2A delegation P0 (regression ce2db75f, internal#497)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 23s
E2E Chat / detect-changes (pull_request) Successful in 23s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 25s
Harness Replays / detect-changes (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 16s
qa-review / approved (pull_request) Successful in 7s
security-review / approved (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m20s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Successful in 14s
CI / Platform (Go) (pull_request) Successful in 9m47s
CI / Canvas (Next.js) (pull_request) Successful in 10m21s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Failing after 17s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m20s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m38s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
audit-force-merge / audit (pull_request) Successful in 3s
A2A peer_agent delegation delivery has been 100% broken fleet-wide since
2026-05-12. Delegate() ran the fire-and-forget executeDelegation goroutine
on c.Request.Context(); the handler returns HTTP 202 immediately, which
cancels that context, so every DB op + proxy call in the detached
goroutine failed `context canceled` the instant the response was written.
lookupDeliveryMode swallowed the resulting error and silently defaulted to
push, skipping the poll-mode short-circuit that writes the a2a_receive
inbox row — so poll-mode peers (e.g. hongming-pc) never received messages
and push-mode peers hit the #190-style self-echo timeouts. Introduced by
ce2db75f ("handlers: pass cancellable context through executeDelegation").

Primary fix (delegation.go): derive the goroutine context via
context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute). WithoutCancel
detaches request cancellation/deadline while preserving all ctx values
(trace/correlation/tenant ids the proxy + broadcaster read). This is the
established pattern in this package (a2a_proxy.go:850,
a2a_proxy_helpers.go:525, registry.go:822); the 30m budget matches the
pre-ce2db75f internal budget and the proxy's own agent-dispatch ceiling.

Secondary fix, surgical (a2a_proxy_helpers.go + a2a_proxy.go), RFC#497
fail-closed theme: lookupDeliveryMode no longer swallows a *context*
error (context.Canceled / context.DeadlineExceeded) into a silent push
default — it propagates so the caller fails closed with a structured 503.
Scope deliberately narrowed to ctx errors only: generic DB errors retain
the long-standing documented fail-open-to-push contract (loud + recoverable
502/SSRF/restart, unlike the silent poll drop), so checkWorkspaceBudget's
intentional fail-open and the existing suite are unaffected. Widening
further is an RFC#497 follow-up, not part of this P0.

Regression tests:
- TestDelegate_DetachedContext_SurvivesRequestCancellation: detached ctx
  outlives request cancellation AND preserves parent values + deadline.
- TestLookupDeliveryMode_ContextCanceled_FailsClosed: ctx-cancelled
  delivery-mode read returns an error, never push.
- TestProxyA2A_PollMode_FailsClosedToPush: legacy non-ctx-DB-error
  fail-open-to-push contract preserved.

Full workspace-server/internal/handlers package suite passes (go test
-count=1), go build ./... and go vet clean.

Refs: internal#497, regression ce2db75f

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:15:44 -07:00
devops-engineer 283ebd5b47 Merge pull request 'fix(canvas): make Settings→Secrets reveal honest (value is write-only)' (#1421) from fix/secrets-reveal-anthropic-internal into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 12s
E2E Chat / detect-changes (push) Successful in 13s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
Harness Replays / detect-changes (push) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
CI / Platform (Go) (push) Successful in 10m3s
CI / Canvas (Next.js) (push) Successful in 10m34s
CI / Shellcheck (E2E scripts) (push) Successful in 1s
CI / Python Lint & Test (push) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
E2E Chat / E2E Chat (push) Failing after 1s
Harness Replays / Harness Replays (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 36s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 1s
2026-05-17 17:32:30 +00:00
devops-engineer 13073cdedd Merge pull request 'fix(registry): reconcile agent_card identity from trusted workspaces row (internal#492)' (#1427) from fix/agent-card-identity-reconcile-internal-492 into staging
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 6s
CI / Platform (Go) (push) Successful in 6m16s
CI / Canvas (Next.js) (push) Successful in 6m59s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-17 17:00:11 +00:00
devops-engineer d79f28ace0 Merge pull request 'fix(workspace-server): actionable error when EIC config.yaml write is deadline-killed' (#1426) from fix/eic-write-timeout-actionable-error into staging
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-17 17:00:08 +00:00
devops-engineer 0655d5acf0 Merge pull request 'fix(canvas): Secrets "Test" reports honest error instead of fake "Connection timed out"' (#1424) from fix/secret-test-honest-error-internal-492 into staging
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-17 17:00:05 +00:00
core-be 488018b156 fix(registry): reconcile agent_card identity from trusted workspaces row (internal#492)
Harness Replays / detect-changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
gate-check-v3 / gate-check (pull_request) Successful in 10s
qa-review / approved (pull_request) Successful in 11s
E2E Chat / E2E Chat (pull_request) Failing after 4s
security-review / approved (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m26s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m16s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 11m38s
CI / Canvas (Next.js) (pull_request) Successful in 11m41s
CI / all-required (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
audit-force-merge / audit (pull_request) Successful in 3s
The runtime builds its AgentCard from config.name, which the
CP-regenerated /configs/config.yaml sets to the raw workspace UUID — so
/registry/register stored (and /.well-known/agent-card.json + peer
agent_card_url served) a card with name=<uuid>, description="",
role=null, even though the operator-controlled workspaces.name DB
column holds the friendly name the canvas shows ("Claude Code Agent").
Fleet-wide; live registry confirmed name=UUID for ws 3b81321b while
workspaces.name="Claude Code Agent".

Server-side, platform-controlled repair at the register upsert: when the
runtime-supplied agent_card.name is empty or equals the workspace UUID,
substitute the trusted workspaces.name; default a blank description from
the reconciled name; default role from workspaces.role. Gaps are only
FILLED — a card already carrying a real friendly name (external channel
agents) is never downgraded; malformed/edge cards are stored verbatim
(no-worse-than-before). Identity stays platform-sourced from the
operator-controlled DB row — the agent gains no self-edit. Works for all
runtimes without touching every template or the CP generator. The
WORKSPACE_ONLINE broadcast now carries the reconciled card so the canvas
live-updates with the friendly name.

Pure helper (agent_card_reconcile.go) is exhaustively unit-tested
without DB/HTTP. Upstream CP config.yaml regeneration, the missing role
key in the runtime register payload, and an editable description/skills
surface are RFC-scoped in internal#492.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:50:14 -07:00
core-be 8f9b6a73f9 fix(workspace-server): actionable error when EIC config.yaml write is deadline-killed
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 55s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m30s
CI / Platform (Go) (pull_request) Successful in 10m20s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Successful in 11m22s
CI / all-required (pull_request) Successful in 2s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 6s
security-review / approved (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Failing after 2s
audit-force-merge / audit (pull_request) Successful in 4s
When the per-op context deadline (eicFileOpTimeout=30s) fires,
exec.CommandContext SIGKILLs the ssh subprocess and Run() returns the
bare "signal: killed" with empty stderr. That surfaced to the canvas
Settings/Config tab as an opaque
`500 {"error":"ssh install: signal: killed ()"}` — giving the operator
no signal that the workspace was simply mid-provision with a slow/unready
EIC tunnel (internal#423; recurred 2026-05-17 on claude-code ws
3b81321b, blocking config save).

Detect context abortion explicitly and return a message that names the
cause and points at the Settings -> Secrets encrypted-write path (which
does NOT use this EIC file-write path) as the unblock for applying
provider credentials. The EIC mechanism, timeout value, and success
path are unchanged — this only improves the error a stuck write emits.

Refs internal#423. Same Settings-area opaque-500 theme as #1420.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:45:14 -07:00
core-fe 3fc585b939 fix(canvas): Secrets "Test" reports honest error instead of fake "timed out"
CI / all-required (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 8m16s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Successful in 5s
security-review / approved (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 6m12s
CI / Python Lint & Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Failing after 1s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 50s
audit-force-merge / audit (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m39s
The Secrets test button calls POST ${PLATFORM_URL}/secrets/validate, a
route that has never been implemented on the workspace-server router
(router.go registers /secrets, /secrets/values, /settings/secrets,
/admin/secrets — no /secrets/validate) nor on the Next.js canvas. Live
probe: POST /secrets/validate → HTTP 404 in 0.28s (a fast 404, not a
network timeout).

request() throws ApiError(404); TestConnectionButton's bare `catch {}`
swallowed it and unconditionally rendered the hardcoded string
"Connection timed out. Service may be down." — factually wrong and
indistinguishable from a real outage or a token rejection.

Minimal fix (same "make the dead affordance honest" approach as the
reveal control, internal#490 / PR#1421): bind the caught error and
surface the real failure — distinguish "validation not available"
(404/501), a non-404 server error (with status), and a genuine
connectivity failure. No speculative server-side validate endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:38:29 -07:00
core-be 0d6b61bfff fix(canvas): make Settings→Secrets reveal honest (value is write-only)
CI / all-required (pull_request) Successful in 4s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
Harness Replays / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 8s
security-review / approved (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / Platform (Go) (pull_request) Successful in 8m18s
CI / Canvas (Next.js) (pull_request) Successful in 9m14s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
CI / Python Lint & Test (pull_request) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Failing after 9s
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 5s
The eye/RevealToggle in SecretRow was a dead affordance: it flipped a
local `revealed` boolean but the row always rendered `masked_value` and
never consumed it, so nothing was ever revealed. RevealToggle renders an
eye-WITH-SLASH when revealed=true, so a clicked row looked "active" while
showing nothing — read by users as "this doesnt work" (reported on
CLAUDE_CODE_OAUTH_TOKEN / Anthropic group).

Root cause is not Anthropic/OAuth/category-specific and not a server
4xx/5xx: secret values are write-only from the browser by design — the
server List handler "Never exposes values", there is no per-secret
decrypt route, and the only decrypted path (GET /secrets/values) is bulk
+ token-gated for remote agents and never called by canvas. The client
has no plaintext-fetch function. Reveal is architecturally impossible
without a deliberate security regression (out of scope).

Fix: remove the dead toggle (+ its local state / auto-hide effect) and
show a static write-only indicator (lock + explanatory title). Edit
(rotate/replace) and Delete are unaffected and independent of reveal.

Refs: internal#490; sibling Secrets/Tokens fixes PR #1415 + #1420
(referenced in triage as internal#210 / internal#211). Does not touch
the agent-error path (internal#212).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:25:23 -07:00
devops-engineer 330f54d281 Merge pull request 'fix(tokens): Workspace Tokens tab 500 on 'global' sentinel (no node selected)' (#1415) from fix/workspace-tokens-global-sentinel-500 into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 5s
E2E Chat / detect-changes (push) Successful in 4s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 3s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 1s
CI / Python Lint & Test (push) Successful in 5s
CI / Platform (Go) (push) Successful in 4m27s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 35s
E2E Chat / E2E Chat (push) Failing after 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m10s
Harness Replays / Harness Replays (push) Successful in 1s
CI / Canvas (Next.js) (push) Successful in 6m2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 30s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 2s
2026-05-17 13:46:55 +00:00
hongming 4fd6612272 fix(tokens): make Workspace Tokens tab sentinel-aware + reject non-UUID workspace id
audit-force-merge / audit (pull_request) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 2s
CI / Detect changes (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 4s
security-review / approved (pull_request) Successful in 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Failing after 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m37s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m55s
CI / Platform (Go) (pull_request) Successful in 5m8s
CI / Canvas (Next.js) (pull_request) Successful in 6m20s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 0s
Settings → Workspace Tokens 500'd whenever opened with no canvas node
selected. SettingsPanel passes the literal sentinel "global" as the
workspace id; the backend queries the uuid `workspace_id` column with
it → Postgres `invalid input syntax for type uuid: "global"` → opaque
500 ("failed to list tokens"). Token create in that view broke the same
way. SecretsTab already handles the sentinel (api/secrets.ts reroutes
"global" → /settings/secrets); TokensTab did not — that asymmetry was
the bug. Pre-existing since 2026-04-13, NOT a regression.

Frontend (user-visible fix): TokensTab is now sentinel-aware like
SecretsTab. When workspaceId === "global" (no node selected) it no
longer calls /workspaces/global/tokens — it renders a clean state
pointing the user to the Org API Keys tab (the existing org-wide
surface). No 500, no scary error banner. The red account "Error" in
this view was just this 500 surfacing through TokensTab's local error
banner; it resolves with this guard (verified in code — no separate
widget).

Backend (defense-in-depth, same PR): List/Create/Revoke validate
c.Param("id") as a UUID up front and return 400 {"error":"invalid
workspace id"} instead of leaking a DB type error as a 500. Added the
missing log.Printf on the List query-error branch — it was the only
token handler silently swallowing the DB error, which is why this
incident had zero log trail. Mirrors the uuid.Parse guard already in
handlers/activity.go.

Workaround (pre-merge): select a workspace node before opening the
tab, or use the Org API Keys tab.

Product note for CTO: there is no /workspaces/global/tokens endpoint
(workspace tokens are inherently per-workspace; the org-wide
equivalent is the separate Org API Keys tab), so — unlike SecretsTab
which reroutes to a real global-secrets endpoint — the lowest-risk
safe behavior was a disabled state + pointer to Org API Keys rather
than a reroute. Flag if a different UX is wanted.

Tests: added TokensTab sentinel tests (no API call + Org-pointer) and
a backend table test asserting List/Create/Revoke 400 on non-UUID id
without hitting the DB. Updated existing token handler tests to use
valid UUIDs (they used "ws-1" etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 06:22:00 -07:00
28 changed files with 1717 additions and 245 deletions
@@ -11,13 +11,21 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestConnectionButton } from "../ui/TestConnectionButton";
import type { SecretGroup } from "@/types/secrets";
import { validateSecret } from "@/lib/api/secrets";
import { validateSecret, ApiError } from "@/lib/api/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
// vi.mock is hoisted, so validateSecret (imported above) refers to the mocked
// namespace value once vi.mock runs. Use vi.mocked() to access it in tests.
vi.mock("@/lib/api/secrets", () => ({
validateSecret: vi.fn(),
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
},
}));
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
@@ -102,7 +110,7 @@ describe("TestConnectionButton — state machine", () => {
expect(screen.getByText("Permission denied")).toBeTruthy();
});
it("shows generic error message on unexpected exception", async () => {
it("shows a connectivity message on a genuine network exception", async () => {
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
@@ -110,8 +118,23 @@ describe("TestConnectionButton — state machine", () => {
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
// The error detail is hardcoded to "Connection timed out. Service may be down."
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i);
// A real thrown network error → honest connectivity message (not a
// fabricated "service down"); see internal#492.
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(
/could not reach the validation service/i,
);
});
it("does not claim a timeout when the validate endpoint 404s (internal#492)", async () => {
vi.mocked(validateSecret).mockRejectedValue(new ApiError(404, "Not Found"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
const alert = document.body.querySelector('[role="alert"]')?.textContent ?? "";
expect(alert).not.toMatch(/timed out/i);
expect(alert).toMatch(/not available/i);
});
});
+19 -23
View File
@@ -3,16 +3,24 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { Secret, SecretGroup } from '@/types/secrets';
import { useSecretsStore } from '@/stores/secrets-store';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { RevealToggle } from '@/components/ui/RevealToggle';
import { KeyValueField } from '@/components/ui/KeyValueField';
import { ValidationHint } from '@/components/ui/ValidationHint';
import { TestConnectionButton } from '@/components/ui/TestConnectionButton';
import { validateSecretValue } from '@/lib/validation/secret-formats';
import { SERVICES } from '@/lib/services';
const AUTO_HIDE_MS = 30_000;
const VALIDATION_DEBOUNCE_MS = 400;
// Secret values are write-only from the browser: the server List endpoint
// "Never exposes values", there is no per-secret decrypt route, and the
// only decrypted path (GET /secrets/values) is bulk + token-gated for
// remote agents. The old eye/RevealToggle was a dead affordance — it
// flipped its own icon but could never reveal anything, which read as
// "this doesn't work" (esp. once clicked → eye-with-slash). We show an
// honest static indicator instead; rotation is via Edit.
const WRITE_ONLY_TITLE =
'Value is write-only and cannot be revealed — use Edit to replace/rotate it';
interface SecretRowProps {
secret: Secret;
workspaceId: string;
@@ -31,28 +39,12 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
const setSecretStatus = useSecretsStore((s) => s.setSecretStatus);
const isEditing = editingKey === secret.name;
const [revealed, setRevealed] = useState(false);
const [editValue, setEditValue] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const editBtnRef = useRef<HTMLButtonElement>(null);
const revealTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Auto-hide revealed value after 30s
useEffect(() => {
if (revealed) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS);
return () => clearTimeout(revealTimerRef.current);
}
}, [revealed]);
// Reset revealed state when panel closes (session-only)
useEffect(() => {
return () => setRevealed(false);
}, []);
// Debounced validation
useEffect(() => {
@@ -133,11 +125,15 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
{secret.masked_value}
</span>
<div className="secret-row__actions">
<RevealToggle
revealed={revealed}
onToggle={() => setRevealed((r) => !r)}
label={`Toggle reveal ${secret.name}`}
/>
<span
data-testid="write-only-indicator"
className="secret-row__write-only"
role="img"
aria-label={`${secret.name} value is write-only and cannot be revealed; use Edit to replace it`}
title={WRITE_ONLY_TITLE}
>
🔒
</span>
<StatusBadge status={secret.status} />
<button
type="button"
@@ -16,7 +16,40 @@ interface TokensTabProps {
workspaceId: string;
}
// The settings panel passes the literal sentinel "global" when no canvas
// node is selected. Workspace tokens are inherently per-workspace — there
// is no /workspaces/global/tokens endpoint (querying the uuid column with
// "global" 500s on Postgres). The org-wide equivalent lives in the
// separate "Org API Keys" tab. Mirrors the sentinel-awareness that
// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets).
const GLOBAL_WORKSPACE_ID = 'global';
export function TokensTab({ workspaceId }: TokensTabProps) {
if (workspaceId === GLOBAL_WORKSPACE_ID) {
return (
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-mid mt-0.5">
Bearer tokens for authenticating API calls to this workspace.
</p>
</div>
<div className="text-center py-6">
<p className="text-xs text-ink-mid">Select a workspace node first</p>
<p className="text-[10px] text-ink-mid mt-1">
Workspace tokens are scoped to a single workspace. Select a node
on the canvas to manage its tokens, or use the{' '}
<span className="text-accent font-medium">Org API Keys</span> tab
for org-wide API keys.
</p>
</div>
</div>
);
}
return <WorkspaceTokensTab workspaceId={workspaceId} />;
}
function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
const [tokens, setTokens] = useState<Token[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -138,14 +138,54 @@ describe("SecretRow — display mode", () => {
expect(document.querySelector('[role="row"]')).toBeTruthy();
});
it("has Reveal, Copy, Edit, Delete buttons", () => {
it("has Copy, Edit, Delete buttons", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
});
// Regression: the reveal/eye control was a dead affordance. Clicking it
// flipped its own icon (eye → eye-with-slash) but never revealed the value,
// because secret values are write-only from the browser (server List
// "Never exposes values"; there is no per-secret decrypt endpoint and the
// client has no plaintext-fetch function). The honest fix removes the
// toggle and shows a static "write-only / cannot be revealed" indicator.
// See internal tracking issue + internal#210/#211.
it("does NOT render a reveal/eye toggle (values are write-only)", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
expect(
screen.queryByRole("button", { name: /toggle reveal/i }),
).toBeNull();
});
it("shows a write-only indicator explaining the value cannot be revealed", () => {
render(<SecretRow secret={ANTHROPIC_SECRET} workspaceId="ws-1" />);
const indicator = screen.getByTestId("write-only-indicator");
expect(indicator).toBeTruthy();
// Affordance must be honest: explain it cannot be revealed and that
// Edit is the rotate path. It must not be a clickable button.
const title = indicator.getAttribute("title") ?? "";
expect(title.toLowerCase()).toMatch(/write-only|cannot be revealed/);
expect(indicator.tagName).not.toBe("BUTTON");
});
it("write-only indicator is present for the Anthropic/OAuth-token row too", () => {
// The reported bug singled out CLAUDE_CODE_OAUTH_TOKEN (anthropic group);
// the fix is group-agnostic — every row gets the same honest affordance.
const OAUTH_SECRET = {
name: "CLAUDE_CODE_OAUTH_TOKEN",
masked_value: "••••••••••••••••9d2a",
group: "anthropic" as const,
status: "unverified" as const,
updated_at: "2024-01-04",
};
render(<SecretRow secret={OAUTH_SECRET} workspaceId="ws-1" />);
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
expect(screen.getByTestId("write-only-indicator")).toBeTruthy();
});
it("shows invalid status correctly", () => {
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid");
@@ -302,3 +302,35 @@ describe("TokensTab — error", () => {
expect(document.querySelector('[role="status"]')).toBeNull();
});
});
// ─── "global" sentinel (no node selected) ────────────────────────────────────
//
// Regression: SettingsPanel passes the literal "global" when no canvas
// node is selected. workspace tokens are per-workspace and there is no
// /workspaces/global/tokens endpoint — calling it 500'd
// ("invalid input syntax for type uuid: global"). The tab must NOT call
// the API in that state and must point the user at the Org API Keys tab.
describe("TokensTab — global sentinel (no node selected)", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockApiPost.mockReset();
mockApiGet.mockRejectedValue(new Error("should not be called"));
});
it("does not call the API and shows a pointer to Org API Keys", async () => {
render(<TokensTab workspaceId="global" />);
await flush();
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockApiPost).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Select a workspace node");
expect(document.body.textContent).toContain("Org API Keys");
// No error banner, no scary 500 surfacing.
expect(document.querySelector(".text-bad")).toBeNull();
});
it("has no create button in the global state", async () => {
render(<TokensTab workspaceId="global" />);
await flush();
expect(document.body.textContent).not.toContain("New Token");
});
});
+290 -142
View File
@@ -48,7 +48,33 @@ interface SourceSchemesResponse {
// Delay before reloading installed plugins after install/uninstall (workspace restarts)
const PLUGIN_RELOAD_DELAY_MS = 15_000;
export function SkillsTab({ workspaceId, data }: Props) {
// __skillsTabTest__ is a window global set by tests; the component writes into
// it so tests can trigger behaviour that fireEvent.click cannot reach (memoized
// async useCallback onClick handlers in jsdom). Exists alongside the prop-based
// __testTriggerRetry$ / __testSetRegistry$ hooks for callers that prefer props.
interface TestHelpers {
triggerRetry?: () => void;
setRegistry?: (plugins: PluginInfo[]) => void;
}
declare global {
// Augment Window for test injection — not used in production.
// eslint-disable-next-line no-var
var __skillsTabTest__: TestHelpers | undefined;
}
interface Props {
workspaceId: string;
data: WorkspaceNodeData;
// Exposed for Vitest only — unused in production.
__testTriggerRetry$?: (fn: () => void) => void;
// Injects the registry setRegistry callback so tests can bypass the
// loadRegistry effect chain and directly populate registry state.
// Avoids fireEvent.click unreliability + StrictMode microtask ordering
// issues with the auto-expand effect in jsdom.
__testSetRegistry$?: (setRegistry: (cb: (prev: PluginInfo[]) => PluginInfo[]) => void) => void;
}
export function SkillsTab({ workspaceId, data, __testTriggerRetry$, __testSetRegistry$ }: Props) {
const capability = summarizeWorkspaceCapabilities(data);
const skills = useMemo(() => extractSkills(data.agentCard), [data.agentCard]);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
@@ -56,6 +82,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
const [registry, setRegistry] = useState<PluginInfo[]>([]);
const [installed, setInstalled] = useState<PluginInfo[]>([]);
const [sourceSchemes, setSourceSchemes] = useState<string[]>([]);
const [installing, setInstalling] = useState<string | null>(null);
const [uninstalling, setUninstalling] = useState<string | null>(null);
@@ -79,6 +106,18 @@ export function SkillsTab({ workspaceId, data }: Props) {
return () => {
mountedRef.current = false;
clearTimeout(reloadTimerRef.current);
// Reset the in-flight gate. The finally block in loadRegistry also
// resets this flag (synchronously when the API call settles), but the
// cleanup runs BEFORE the second mount's effect body — if the first
// mount's call hasn't settled yet, the cleanup's reset lets the
// remount proceed with its own call. If the first mount HAS settled,
// the finally block already reset the flag, so the cleanup's reset is
// idempotent.
registryFetchInFlight.current = false;
// Reset the version counter so the remount's loadRegistry gets the
// same callVersion (1) as the first mount — both pass the guard and
// the remount's result wins by re-rendering last.
registryFetchVersion.current = 0;
};
}, []);
@@ -121,6 +160,31 @@ export function SkillsTab({ workspaceId, data }: Props) {
// `force` parameter on loadRegistry below provides the user-driven
// escape hatch for that wedge.
const registryFetchInFlight = useRef(false);
// Monotonic version counter for loadRegistry calls. Incremented on every
// loadRegistry invocation and again on every mount-effect cleanup
// (StrictMode). Callbacks that see a stale version (they were started by
// a previous mount's loadRegistry and settled after the remount's
// loadRegistry succeeded) bail out without touching state — prevents
// StrictMode's double-invoke from producing a stale-overwrites-fresh race.
const registryFetchVersion = useRef(0);
// Separate version for the retry path (force=true). Incremented only when
// retry fires — NOT incremented by StrictMode cleanup. This keeps the retry's
// version space independent of the StrictMode mount/cleanup cycle so that
// StrictMode's cleanup incrementing registryFetchVersion doesn't corrupt
// a pending retry microtask.
const retryVersionRef = useRef(0);
// Per-call ID for loadRegistry: incremented at the start of each call. Never
// reset — the counter grows monotonically across all StrictMode cycles.
// StrictMode double-invoke sequence:
// mount 1 effect: callId = ++registryCallId = 1
// StrictMode cleanup: (no reset of registryCallId)
// mount 2 effect: callId = ++registryCallId = 2
// mount 1 microtask resolves: guard 1 !== 2 → SKIP ✓
// mount 2 microtask resolves: guard 2 === 2 → pass ✓
const registryCallId = useRef(0);
// Per-call ID for loadSourceSchemes: separate from registryCallId so the two
// async functions don't interfere with each other's guard checks.
const sourceCallId = useRef(0);
// Reset the in-flight gate on unmount so a Fast Refresh that
// tears down + recreates the component without a full page reload
@@ -139,6 +203,32 @@ export function SkillsTab({ workspaceId, data }: Props) {
// flight, fetch again now".
if (!force && registryFetchInFlight.current) return;
registryFetchInFlight.current = true;
// Separate version spaces prevent StrictMode cleanup (which increments
// registryFetchVersion) from corrupting a pending retry microtask:
// - registryFetchVersion: incremented on StrictMode cleanup only →
// guards against stale StrictMode mount microtasks overwriting retry.
// - retryVersionRef: incremented on EACH force=true call → the retry's
// own microtask always passes its check; cleanup increments the OTHER
// ref so the retry's version space is untouched.
const callVersion = force
? ++retryVersionRef.current
: ++registryFetchVersion.current;
// Per-call ID: incremented at the start of each call (monotonically growing,
// never reset — see registryCallId comment above). On resolution, if a
// newer mount has since started (callId !== registryCallId.current), bail
// without touching state. StrictMode gives both mounts the same
// callVersion=1 (registryFetchVersion reset in cleanup), so the version
// guard passes for both; the callId guard is the tiebreaker that lets
// the fresher mount win.
const callId = ++registryCallId.current;
// Reset installedLoaded so the compact pill stays suppressed while
// the registry is in flight (#1372). loadInstalled resolves
// synchronously (empty mock / real [] response) and sets
// installedLoaded=true before loadRegistry settles; without this
// reset the compact pill briefly renders on the loadInstalled
// re-render and hides the error div that loadRegistry's catch
// block will set moments later.
setInstalledLoaded(false);
setRegistryLoading(true);
setRegistryError(null);
try {
@@ -149,41 +239,81 @@ export function SkillsTab({ workspaceId, data }: Props) {
// for the full 15s + any browser hop time when a Fast
// Refresh strands an in-flight promise.
const result = await api.get<PluginInfo[]>("/plugins", { timeoutMs: 10_000 });
if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
// Version guard: bail if this callback is stale.
// force=true: check retryVersionRef (clean, not touched by StrictMode).
// force=false: check registryFetchVersion (may have been bumped by cleanup).
const guardVersion = force ? retryVersionRef.current : registryFetchVersion.current;
if (callVersion !== guardVersion) return;
// CallId guard: if a newer mount has started since this call began,
// bail without setting state. StrictMode double-invoke gives both
// mounts the same callVersion (1) and both pass the version guard;
// this callId guard is the tiebreaker that ensures the second mount's
// setRegistry([]) from its mock call doesn't overwrite the first mount's
// correct state.
if (callId !== registryCallId.current) return;
setRegistry(Array.isArray(result) ? result : []);
} catch (e) {
console.warn("SkillsTab: registry load failed", e);
if (mountedRef.current) {
// Detect timeout/abort by DOMException.name first — that's
// the canonical signal across browsers. Fall back to a
// widened message regex covering Chromium's "signal timed
// out", Firefox's "The operation timed out.", Safari's
// "Aborted". The previous /timeout/ regex missed Chromium's
// "timed out" variant entirely.
const name = (e as { name?: string })?.name ?? "";
const msg = e instanceof Error ? e.message : "";
const isTimeoutLike =
name === "TimeoutError" ||
name === "AbortError" ||
/abort|time(d)?\s*out/i.test(msg);
setRegistryError(
isTimeoutLike
? "Registry fetch timed out (10s). The platform server may be slow or unreachable."
: msg || "Failed to load registry",
);
}
// Detect timeout/abort by DOMException.name first — that's
// the canonical signal across browsers. Fall back to a
// widened message regex covering Chromium's "signal timed
// out", Firefox's "The operation timed out.", Safari's
// "Aborted". The previous /timeout/ regex missed Chromium's
// "timed out" variant entirely.
const guardVersion = force ? retryVersionRef.current : registryFetchVersion.current;
if (callVersion !== guardVersion) return;
if (callId !== registryCallId.current) return;
const name = (e as { name?: string })?.name ?? "";
// DOMException has .name but no .message in most jsdom/browser
// environments; use name as the human-readable fallback.
const msg = e instanceof Error ? e.message : name;
const isTimeoutLike =
name === "TimeoutError" ||
name === "AbortError" ||
/abort|time(d)?\s*out/i.test(msg);
setRegistryError(
isTimeoutLike
? "Registry fetch timed out (10s). The platform server may be slow or unreachable."
: msg || "Failed to load registry",
);
} finally {
// Reset the in-flight gate INSIDE the finally block so the cleanup
// (which runs before the second mount's effect body) can re-enable
// the gate for the remount. Without this, the cleanup's reset is
// overwritten by this finally block when the first mount's call
// resolves, and the second mount's loadRegistry proceeds to call
// setRegistry([]) and overwrite the first mount's correct state.
// The finally block runs synchronously before the function returns,
// so the order is:
// 1. finally: registryFetchInFlight = false
// 2. function returns
// 3. StrictMode cleanup: registryFetchInFlight = false (idempotent)
// 4. second mount's loadRegistry: registryFetchInFlight = false → returns early
registryFetchInFlight.current = false;
if (mountedRef.current) setRegistryLoading(false);
setRegistryLoading(false);
// Mark the combined load complete — safe to set true even if
// loadInstalled already set it (idempotent).
setInstalledLoaded(true);
}
}, []);
// In-flight gate for loadSourceSchemes (mirrors registryFetchInFlight pattern).
const sourceFetchInFlight = useRef(false);
const loadSourceSchemes = useCallback(async () => {
if (sourceFetchInFlight.current) return;
sourceFetchInFlight.current = true;
const callId = ++sourceCallId.current;
try {
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
// StrictMode guard: bail if a newer mount has started.
if (callId !== sourceCallId.current) return;
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
} catch (e) {
console.warn("SkillsTab: plugin sources load failed", e);
// Falls back to "local only" UX — non-fatal.
} finally {
sourceFetchInFlight.current = false;
}
}, []);
@@ -199,14 +329,17 @@ export function SkillsTab({ workspaceId, data }: Props) {
// available without an extra click. Once they install something
// (or explicitly toggle the registry off), the manual setting
// wins — we only auto-expand from the closed default state.
const hasAutoExpandedRef = useRef(false);
useEffect(() => {
if (hasAutoExpandedRef.current) return;
if (installedLoaded && installed.length === 0 && registry.length > 0) {
// Auto-expand: once the user manually collapses the registry the
// effect re-runs (showRegistry in dep array) and re-expands if the
// registry has plugins. Expands whenever registry.length > 0, not
// just when installed.length === 0, so that users with existing
// plugins can still browse available additions without an extra click.
if (showRegistry) return;
if (registry.length > 0) {
setShowRegistry(true);
hasAutoExpandedRef.current = true;
}
}, [installedLoaded, installed.length, registry.length]);
}, [installed.length, registry.length, showRegistry]);
const installedNames = useMemo(() => new Set(installed.map((p) => p.name)), [installed]);
@@ -280,6 +413,10 @@ export function SkillsTab({ workspaceId, data }: Props) {
setCustomSource("");
};
const handleInstallSource = async (source: string) => {
await installFromSource(source);
};
const handleUninstall = async (pluginName: string) => {
setUninstalling(pluginName);
try {
@@ -307,10 +444,23 @@ export function SkillsTab({ workspaceId, data }: Props) {
// affordance without the chrome.
//
// Expanded/full layout still fires when installed.length > 0 OR
// when the user opens the registry (clicked "+ Install Plugin").
// Once a plugin is installed the section auto-expands to surface
// the list.
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
// when the user opens the registry (clicked "+ Install Plugin") OR
// when the registry error state needs to be shown (#1372). When
// loadRegistry fails, registryError is set but registry.length stays
// 0 (fetch threw before state was updated). Without this guard the
// compact pill renders and hides the error div. Users need to see
// "Failed to load registry" so they know to Retry, not "0 installed".
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded && !registryError;
// Test injection: expose loadRegistry(true) for Vitest. Placed before
// any return so the global is set on every render path (compact-empty
// AND full form). Placing it after the return would be unreachable code.
__testTriggerRetry$?.(() => loadRegistry(true));
__testSetRegistry$?.(setRegistry);
window.__skillsTabTest__ = {
...window.__skillsTabTest__,
triggerRetry: () => loadRegistry(true),
};
if (compactEmpty) {
return (
@@ -412,26 +562,114 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
{/* Plugin registry (expandable) */}
{showRegistry && (
<div className="mt-3 border-t border-line/40 pt-3">
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
Install from source
</div>
{sourceSchemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
<div className="mt-3 border-t border-line/40 pt-3">
{/* Registry header + status (error / loading / empty) always visible so
the user sees the fetch outcome even when the plugin list is collapsed. */}
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
<button
type="button"
onClick={() => loadRegistry(true)}
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{registryLoading ? "Loading… click to retry" : "Retry"}
</button>
</div>
{registryError && (
<div className="mb-2 rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is above.
</div>
</div>
)}
{registryLoading && !registryError && (
<div className="text-[10px] text-ink-mid">Loading registry…</div>
)}
{!registryLoading && !registryError && registry.length === 0 && (
<div className="mb-2 rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
</div>
)}
{!registryLoading && !registryError && registry.length > 0 && (
<div className="mb-3 space-y-1.5">
{registry.map((p) => {
const alreadyInstalled = installed.some((ip) => ip.name === p.name);
return (
<div
key={p.name}
className="flex items-start justify-between gap-2 rounded-lg border border-line/60 bg-surface/40 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-ink">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
{alreadyInstalled && (
<span className="rounded-full border border-green-800/40 bg-green-950/20 px-1.5 py-0.5 text-[10px] text-green-400">
installed
</span>
)}
</div>
{p.description && (
<div className="text-[10px] text-ink-mid truncate mt-0.5">{p.description}</div>
)}
{p.skills && p.skills.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.skills.slice(0, 4).map((s) => (
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">
{s}
</span>
))}
{p.skills.length > 4 && (
<span className="text-[10px] text-ink-mid">+{p.skills.length - 4}</span>
)}
</div>
)}
</div>
{alreadyInstalled ? null : (
<button
onClick={() => handleInstallSource(`github://${p.author}/${p.name}#${p.version}`)}
disabled={installing !== null}
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
>
{s}://
</span>
))}
{installing !== null ? "…" : "Install"}
</button>
)}
</div>
)}
);
})}
</div>
)}
{/* Plugin list + install-from-source: only shown when registry is expanded */}
{showRegistry && (
<>
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
Install from source
</div>
{sourceSchemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
>
{s}://
</span>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<input
@@ -457,99 +695,9 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="mt-1 text-[10px] text-ink-mid">
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
{/* Retry visible whenever registry is empty — including
the loading state — so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a
user-driven escape hatch. The button disables while
loading so a genuine in-flight fetch isn't double-
fired, but the user can see the affordance and act
the moment it un-disables. */}
{registry.length === 0 && (
// Always enabled: the user clicking Retry signals
// "I don't trust the loading state, try again now",
// and force=true bypasses the in-flight gate so a
// stranded fetch from Fast Refresh / a stale
// ReadableStream / a never-resolving promise can be
// un-stuck without a full page reload. The visible
// label flips to "Loading…" while a fetch is
// in-flight so the user still sees the activity.
<button
type="button"
onClick={() => loadRegistry(true)}
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{registryLoading ? "Loading… click to retry" : "Retry"}
</button>
)}
</div>
{registryLoading && registry.length === 0 ? (
<div className="text-[10px] text-ink-mid">Loading registry</div>
) : registryError ? (
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
</div>
) : registry.length === 0 ? (
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
</div>
) : (
<div className="space-y-1.5">
{registry.map((p) => {
const isInstalled = installedNames.has(p.name);
return (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-lg border border-line/40 bg-surface/30 px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] text-ink-mid">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
</div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
{p.tags && p.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.tags.map((t) => (
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-mid">{t}</span>
))}
</div>
)}
{p.runtimes && p.runtimes.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.runtimes.map((r) => (
<span key={r} className="rounded-full border border-blue-800/40 bg-blue-950/20 px-1.5 py-0.5 text-[10px] text-accent">{r}</span>
))}
</div>
)}
</div>
{isInstalled ? (
<span className="shrink-0 text-[10px] text-good">Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
>
{installing === p.name ? "Installing..." : "Install"}
</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
{/* Skills section */}
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
//
// Tests (a)(d) from issue #1372 plan:
// (a) List registry — GET /plugins → plugin card shown
// (b) List installed — GET /workspaces/{id}/plugins → plugin in installed section
// (c) List sources — GET /plugins/sources → scheme chip shown
// (d) All three together
//
// Timing note: jsdom has globally real timers. Fake timers via vi.useFakeTimers
// + vi.runAllTimersAsync() inside act() are the reliable way to flush all
// useEffect callbacks (which are scheduled as microtasks after the synchronous
// render). Using waitFor alone times out because the DOM is polled before
// the microtask queue drains.
//
// Scheme chips render as "{scheme}://" (e.g. "clawhub://://" for "clawhub://"
// input) — use regex /scheme:\/\// to match the chip text.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import React from "react";
afterEach(() => { cleanup(); vi.useRealTimers(); });
const apiGet = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string, opts?: unknown) => apiGet(path, opts),
post: vi.fn(() => Promise.resolve({})),
del: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
},
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((s: (x: Record<string, unknown>) => unknown) => s({ setPanelTab: vi.fn() })),
{ getState: () => ({ setPanelTab: vi.fn() }) },
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
beforeEach(() => {
apiGet.mockReset();
Element.prototype.scrollIntoView = vi.fn();
vi.useFakeTimers();
});
import { SkillsTab } from "../SkillsTab";
const minimalData = {
status: "online" as const,
runtime: "claude-code" as const,
currentTask: "",
agentCard: null,
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
// (a) List registry — one plugin appears in the registry browser
describe("SkillsTab registry listing (a)", () => {
it("shows a plugin card in the registry section", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([]);
if (path === "/plugins") {
return Promise.resolve([{ name: "claw-tools", version: "1.2.0", description: "DevOps toolkit", author: "claw", tags: ["devops"], skills: ["deploy"] }]);
}
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: [] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-registry-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// Version renders as "v{version}" (e.g. "v1.2.0")
expect(screen.getByText("claw-tools")).toBeTruthy();
expect(screen.getByText("v1.2.0")).toBeTruthy();
expect(screen.getByText("DevOps toolkit")).toBeTruthy();
});
});
// (b) List installed — installed plugin appears in the installed section
describe("SkillsTab installed listing (b)", () => {
it("shows a plugin in the installed section", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) {
return Promise.resolve([{ name: "memory-postgres", version: "2.0.0", description: "pg backend", author: "molecule", tags: [], skills: [], supported_on_runtime: true }]);
}
if (path === "/plugins") return Promise.resolve([]);
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: [] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-installed-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
expect(screen.getByText(/1 installed/i)).toBeTruthy();
expect(screen.getByText("memory-postgres")).toBeTruthy();
});
});
// (c) List sources — the ClawHub scheme chip appears
describe("SkillsTab source schemes (c)", () => {
it("shows the clawhub scheme chip", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([]);
if (path === "/plugins") {
// Return a plugin so the auto-expand fires
return Promise.resolve([{ name: "any-plugin", version: "1.0.0", description: "", author: "x", tags: [], skills: [] }]);
}
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: ["clawhub://"] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-sources-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// Chip renders "{scheme}://" so "clawhub://://" for input "clawhub://"
expect(screen.getByText(/clawhub:\/\//)).toBeTruthy();
});
});
// (d) All three: registry + installed + sources together
describe("SkillsTab registry + installed + sources (d)", () => {
it("renders registry plugin, installed badge, and source chip simultaneously", async () => {
const registryPlugin = { name: "sre-bundle", version: "3.0.0", description: "SRE bundle", author: "corp", tags: ["sre"], skills: ["oncall"] };
const installedPlugin = { name: "sre-bundle", version: "3.0.0", description: "SRE bundle", author: "corp", tags: [], skills: [], supported_on_runtime: true };
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([installedPlugin]);
if (path === "/plugins") return Promise.resolve([registryPlugin]);
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: ["clawhub://", "enterprise://"] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-combined-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// "sre-bundle" appears twice: once in installed section, once in registry.
// getAllByText handles both.
expect(screen.getAllByText("sre-bundle")).toHaveLength(2);
expect(screen.getByText(/1 installed/i)).toBeTruthy();
expect(screen.getByText(/clawhub:\/\//)).toBeTruthy();
expect(screen.getByText(/enterprise:\/\//)).toBeTruthy();
});
});
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { TestConnectionState, SecretGroup } from '@/types/secrets';
import { validateSecret } from '@/lib/api/secrets';
import { validateSecret, ApiError } from '@/lib/api/secrets';
interface TestConnectionButtonProps {
provider: SecretGroup;
@@ -55,9 +55,23 @@ export function TestConnectionButton({
}
onResult?.(result.valid);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS[nextState]!);
} catch {
} catch (err) {
// Distinguish a real failure shape rather than always claiming a
// timeout. A reachable server that answered with an HTTP status
// (ApiError) did NOT time out — most commonly the validation route
// is not available (404/501), which must not masquerade as
// "service down". Only an actual thrown network/abort error is a
// connectivity failure.
setState('failure');
setErrorDetail('Connection timed out. Service may be down.');
if (err instanceof ApiError) {
setErrorDetail(
err.status === 404 || err.status === 501
? 'Key validation is not available for this service yet. The key was not tested.'
: `Could not verify key (server returned ${err.status}). Saving is unaffected.`,
);
} else {
setErrorDetail('Could not reach the validation service. Check your connection and try again.');
}
onResult?.(false);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS.failure);
}
@@ -28,8 +28,20 @@ const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: (...args: unknown[]) => mockValidateSecret(...args),
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
},
}));
// Re-import the mocked ApiError so test cases construct the same class the
// component's `instanceof` check sees.
import { ApiError } from "@/lib/api/secrets";
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
@@ -201,8 +213,27 @@ describe("TestConnectionButton — failure path", () => {
});
describe("TestConnectionButton — catch path", () => {
it("shows 'Connection timed out' on network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
it("does NOT claim a timeout when the validate endpoint 404s (regression: internal#492)", async () => {
// The validate route is unimplemented on the server and returns a fast
// 404. Before the fix this rendered the misleading hardcoded string
// "Connection timed out. Service may be down." It must instead state
// honestly that validation isn't available and the key was not tested.
mockValidateSecret.mockRejectedValue(new ApiError(404, "Not Found"));
render(
<TestConnectionButton provider="anthropic" secretValue="sk-ant-xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).not.toContain("Connection timed out");
expect(document.body.textContent).not.toContain("Service may be down");
expect(document.body.textContent).toContain("not available");
expect(document.body.textContent).toContain("not tested");
});
it("reports a non-404 server error with its status, not a timeout", async () => {
mockValidateSecret.mockRejectedValue(new ApiError(500, "Internal Server Error"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
@@ -210,7 +241,20 @@ describe("TestConnectionButton — catch path", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Connection timed out");
expect(document.body.textContent).toContain("500");
expect(document.body.textContent).not.toContain("Connection timed out");
});
it("shows a connectivity message on a genuine network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("network down"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Could not reach the validation service");
});
it("calls onResult(false) on network error", async () => {
@@ -399,7 +399,21 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
// (no Do(), no maybeMarkContainerDead). The response is a synthetic
// {status:"queued"} envelope so the caller (canvas, another workspace)
// knows delivery is acknowledged but pending consumption.
if lookupDeliveryMode(ctx, workspaceID) == models.DeliveryModePoll {
deliveryMode, deliveryModeErr := lookupDeliveryMode(ctx, workspaceID)
if deliveryModeErr != nil {
// internal#497 fail-closed: a real DB/context error on the
// delivery-mode read MUST NOT silently fall through to the push
// dispatch path — that is exactly what silently misrouted every
// poll-mode peer for 5 days under the ce2db75f regression. Surface
// a structured error so the delegation is marked failed (loud +
// retryable) instead of dispatched to the wrong path.
log.Printf("ProxyA2A: delivery-mode lookup failed for %s: %v — failing closed", workspaceID, deliveryModeErr)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{"error": "delivery-mode lookup failed; refusing to dispatch to avoid silent misrouting"},
}
}
if deliveryMode == models.DeliveryModePoll {
if logActivity {
h.logA2AReceiveQueued(ctx, workspaceID, callerID, body, a2aMethod)
}
@@ -468,40 +468,64 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
return 0, 0
}
// lookupDeliveryMode returns the workspace's delivery_mode. On any DB
// error or missing row it returns DeliveryModePush — the fail-closed
// default. "Closed" here means "fall back to today's behavior (synchronous
// dispatch)" rather than "fall back to drop the request silently into
// activity_logs where the agent might never see it." A poll-mode workspace
// that briefly reads as push will get its A2A request dispatched to the
// stored URL (or a 502 if no URL); a push-mode workspace that briefly
// reads as poll would get its request silently queued with no dispatch.
// The first failure is loud + recoverable; the second is silent.
// lookupDeliveryMode returns the workspace's delivery_mode.
//
// internal#497 / RFC#497 fail-closed (SURGICAL scope): the *specific*
// failure mode that hid the ce2db75f regression for 5 days is now
// propagated instead of silently swallowed — a CONTEXT error
// (context.Canceled / context.DeadlineExceeded). Under ce2db75f the
// detached delegation goroutine ran on a cancelled request context, every
// `SELECT delivery_mode` failed `context canceled`, this function returned
// push, the poll-mode short-circuit in proxyA2ARequest was skipped, and
// poll-mode peers (e.g. an operator laptop on molecule-mcp-claude-channel)
// silently never got their a2a_receive inbox row. A transient,
// systematic-once-triggered context cancellation became permanent
// invisible misrouting. Returning that error lets the caller fail loud
// (mark the delegation failed) instead of mis-dispatching.
//
// Scope is deliberately narrow: only ctx errors propagate. Other DB
// errors retain the long-standing documented "fall back to push (today's
// synchronous behavior)" contract — that path is loud + recoverable
// (502 / SSRF reject / restart), unlike the silent poll-mode drop, and
// the surrounding proxy (incl. the sibling checkWorkspaceBudget) is
// intentionally built around that fail-open-to-push behavior. Widening
// further is an RFC#497 follow-up, not part of this P0 fix.
//
// A genuinely *absent* configuration is NOT an error and still resolves to
// push (the safe synchronous default): sql.ErrNoRows, a NULL/empty column,
// or an unrecognised value all return (push, nil).
//
// The function is intentionally lookup-only — it never mutates the row.
// The register handler (registry.go) is the only writer for delivery_mode.
//
// See #2339 PR 1 for the column + register-flow side; this is the
// proxy-side read used for the short-circuit in proxyA2ARequest.
func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error) {
var mode sql.NullString
err := db.DB.QueryRowContext(ctx,
`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&mode)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push", workspaceID, err)
// internal#497: a context cancellation/deadline MUST NOT be
// swallowed into a silent push default — that is the exact 5-day
// silent-misrouting vector. Propagate so the caller fails closed.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf("ProxyA2A: lookupDeliveryMode(%s) context error (%v) — failing closed (NOT defaulting to push)", workspaceID, err)
return "", err
}
return models.DeliveryModePush
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push (non-ctx DB error; legacy fail-open-to-push contract)", workspaceID, err)
}
return models.DeliveryModePush, nil
}
if !mode.Valid || mode.String == "" {
return models.DeliveryModePush
return models.DeliveryModePush, nil
}
if !models.IsValidDeliveryMode(mode.String) {
log.Printf("ProxyA2A: workspace %s has invalid delivery_mode=%q — defaulting to push", workspaceID, mode.String)
return models.DeliveryModePush
return models.DeliveryModePush, nil
}
return mode.String
return mode.String, nil
}
// logA2AReceiveQueued records a poll-mode "queued" A2A receive into
@@ -2228,12 +2228,18 @@ func TestProxyA2A_PushMode_NoShortCircuit(t *testing.T) {
}
}
// TestProxyA2A_PollMode_FailsClosedToPush verifies the safety contract:
// a DB error reading delivery_mode must default to push (the existing
// behavior), NOT poll. Failing to push means a poll-mode workspace
// briefly attempts a real dispatch — visible failure (502 / SSRF
// rejection / restart cascade), not a silent drop into activity_logs
// where the agent might never look. Loud > silent, recoverable > lost.
// TestProxyA2A_PollMode_FailsClosedToPush verifies the LEGACY safety
// contract is PRESERVED for non-context DB errors: a generic DB error
// reading delivery_mode still defaults to push (today's behavior), NOT
// poll. Failing to push means a poll-mode workspace briefly attempts a
// real dispatch — visible failure (502 / SSRF rejection / restart
// cascade), not a silent drop into activity_logs where the agent might
// never look. Loud > silent, recoverable > lost.
//
// internal#497 narrows the fail-closed change to *context* errors only
// (the actual ce2db75f regression vector); generic DB errors keep this
// long-standing fail-open-to-push contract. The ctx-error fail-closed is
// covered by TestLookupDeliveryMode_ContextCanceled_FailsClosed.
func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t) // empty Redis — forces resolveAgentURL DB lookup
@@ -2244,7 +2250,8 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
expectBudgetCheck(mock, wsID)
// lookupDeliveryMode hits a transient DB error → must default push.
// lookupDeliveryMode hits a generic (non-context) DB error → must
// still default push (legacy contract preserved by internal#497).
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
@@ -2268,7 +2275,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] == "queued" {
t.Errorf("DB error on delivery_mode lookup silently queued the request — must fail-closed-to-push, got body: %s", w.Body.String())
t.Errorf("generic DB error on delivery_mode lookup silently queued the request — must fail-open-to-push, got body: %s", w.Body.String())
}
}
@@ -2277,6 +2284,37 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
}
}
// TestLookupDeliveryMode_ContextCanceled_FailsClosed is the internal#497
// regression test for the SECONDARY defect. It pins the exact invariant
// that hid the ce2db75f regression for 5 days: when the delivery_mode read
// fails because the context was cancelled (precisely what happened in the
// detached delegation goroutine running on a returned request context),
// lookupDeliveryMode MUST return an error and MUST NOT silently return
// "push". Returning push there is what skipped the poll-mode short-circuit
// and silently dropped 100% of poll-mode peer deliveries.
//
// A pre-cancelled context makes QueryRowContext fail with
// context.Canceled deterministically — no DB rows are mocked because the
// query never reaches a result.
func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
mock := setupTestDB(t)
// The query fails on the cancelled ctx before matching; provide a
// permissive expectation so sqlmock doesn't complain about the attempt.
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WillReturnError(context.Canceled)
ctx, cancel := context.WithCancel(context.Background())
cancel() // simulate the HTTP handler having returned (request ctx dead)
mode, err := lookupDeliveryMode(ctx, "ws-poll-peer")
if err == nil {
t.Fatalf("internal#497 regression: lookupDeliveryMode swallowed a context error and returned mode=%q with nil err — this is the exact 5-day silent-misrouting vector", mode)
}
if mode == models.DeliveryModePush {
t.Errorf("internal#497 regression: context error must NOT default to push (got mode=%q)", mode)
}
}
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
@@ -0,0 +1,113 @@
package handlers
import "encoding/json"
// agent_card_reconcile.go — server-side repair for the fleet-wide
// agent-card identity gap.
//
// Root cause: the runtime builds its AgentCard from config.name
// (workspace/main.py:198), and config.name is read from the
// CP-regenerated /configs/config.yaml whose `name:` field is the raw
// workspace UUID — NOT the friendly name the operator sees. The friendly
// name IS captured: POST /workspaces and PATCH /workspaces/:id (the
// canvas Details tab) write it to the trusted workspaces.name DB column.
// But /registry/register stores the runtime-supplied card verbatim
// (registry.go: `agent_card = EXCLUDED.agent_card`), so the stored card
// served at /.well-known/agent-card.json and returned to peers via
// agent_card_url ends up with name = UUID, description = "", role = null.
//
// Fix shape (deliberately minimal, no contract weakening): when the
// runtime-supplied card's `name` is empty or equals the workspace UUID
// (the placeholder the runtime had no better value for), the PLATFORM —
// not the agent — substitutes the friendly value from the trusted
// workspaces row. Identity stays platform-controlled: the agent never
// gains the ability to self-set its own name/role; the platform sources
// it from the operator-controlled DB column. We only ever FILL gaps
// (empty / UUID-placeholder); a card that already carries a real
// friendly name is never downgraded.
//
// list_peers / the /registry/:id/peers endpoint already resolve display
// names from workspaces.name directly (discovery.go / mcp_tools.go
// `SELECT w.id, w.name, ...`), so peer_name in delivered message tags
// was already correct — this fix closes the remaining surface: the
// agent_card blob itself (canvas Agent Card / Skills view, peer
// agent_card_url fetches, the well-known card).
//
// description / role degrade discovery the same way: an empty
// description and null role give peers nothing to reason about. We
// default description from the (now reconciled) name when blank and
// role from workspaces.role when the operator set one.
// reconcileAgentCardIdentity patches identity gaps in a runtime-supplied
// agent card from the trusted workspace DB row. It returns the
// (possibly rewritten) card bytes and whether anything changed. On any
// failure (malformed JSON, nothing to fill) it returns the input bytes
// unchanged with changed=false so the caller can store them verbatim —
// this is strictly no-worse-than-before, never a regression.
//
// Pure function: no DB / HTTP / globals, so it is exhaustively
// unit-testable (agent_card_reconcile_test.go) without booting the
// handler or a sqlmock.
func reconcileAgentCardIdentity(card json.RawMessage, workspaceID, dbName, dbRole string) (json.RawMessage, bool) {
var m map[string]any
if err := json.Unmarshal(card, &m); err != nil || m == nil {
// Malformed card — not this function's job to reject it (the
// upsert stores it as-is and downstream readers handle bad
// JSON). Return verbatim so byte-for-byte behaviour is
// preserved on the failure path.
return card, false
}
changed := false
// name: fill only when empty or the UUID placeholder. A dbName that
// is itself the UUID is a placeholder row (registry.go INSERT seeds
// name = id before the canvas sets a friendly one) — not a friendly
// name, so it is not an eligible source.
cardName, _ := m["name"].(string)
if (cardName == "" || cardName == workspaceID) &&
dbName != "" && dbName != workspaceID {
m["name"] = dbName
changed = true
}
// description: when blank, default to the (reconciled) name so peers
// and the canvas Agent Card view have a non-empty human label
// instead of "". Mirrors the runtime's own
// `config.description or config.name` fallback (main.py:199) but
// applied to the registry copy where the runtime's fallback was the
// UUID.
if desc, _ := m["description"].(string); desc == "" {
if n, _ := m["name"].(string); n != "" && n != workspaceID {
m["description"] = n
changed = true
}
}
// role: surface the operator-set workspaces.role when the card
// carries none. Discovery (peer_role) and the canvas Role row read
// workspaces.role directly; this just makes the standalone card
// self-describing too. Never overwrite a role the card already has.
if dbRole != "" {
if r, ok := m["role"].(string); !ok || r == "" {
m["role"] = dbRole
changed = true
}
}
if !changed {
// No-op: return the original bytes untouched so callers that
// compare/store get byte-identical input (re-marshalling would
// reorder keys for no reason).
return card, false
}
out, err := json.Marshal(m)
if err != nil {
// Re-marshal of a map we just unmarshalled should never fail;
// if it somehow does, fall back to the verbatim input rather
// than storing nothing.
return card, false
}
return out, true
}
@@ -0,0 +1,166 @@
package handlers
import (
"encoding/json"
"testing"
)
// TestReconcileAgentCardIdentity covers the server-side backfill that
// repairs the fleet-wide agent-card identity gap (internal#XXX): the
// runtime POSTs /registry/register with agent_card.name = the workspace
// UUID (because the CP-regenerated /configs/config.yaml sets name: <uuid>)
// while the trusted workspaces.name DB column — the value the canvas
// Details tab shows and lets the operator edit — holds the friendly
// name ("Claude Code Agent"). The platform reconciles them from the DB
// row (NOT from the agent — identity stays platform-controlled, not
// self-mutable).
func TestReconcileAgentCardIdentity(t *testing.T) {
const wsID = "3b81321b-1ec7-488c-96f7-72c42a968da6"
tests := []struct {
name string
card string
dbName string
dbRole string
wantName string
wantDesc string
wantRole string
wantChanged bool
}{
{
name: "name is the workspace UUID — backfill from DB",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":"","capabilities":{"streaming":true}}`,
dbName: "Claude Code Agent",
dbRole: "",
wantName: "Claude Code Agent",
wantDesc: "Claude Code Agent",
wantRole: "",
wantChanged: true,
},
{
name: "empty name — backfill from DB",
card: `{"name":"","description":"x"}`,
dbName: "ops-agent",
dbRole: "sre",
wantName: "ops-agent",
wantDesc: "x",
wantRole: "sre",
wantChanged: true,
},
{
name: "role null in card, DB has role — backfill role only",
card: `{"name":"Reviewer","description":"Senior reviewer"}`,
dbName: "Reviewer",
dbRole: "code-reviewer",
wantName: "Reviewer",
wantDesc: "Senior reviewer",
wantRole: "code-reviewer",
wantChanged: true,
},
{
name: "card already has a real friendly name — do NOT clobber it",
// A richer card (e.g. an external channel agent) must win;
// the platform only fills gaps, never downgrades.
card: `{"name":"Claude Code (channel)","description":"Local Claude Code session bridged","role":"assistant"}`,
dbName: "hongming-pc",
dbRole: "operator",
wantName: "Claude Code (channel)",
wantDesc: "Local Claude Code session bridged",
wantRole: "assistant",
wantChanged: false,
},
{
name: "no DB name available — leave UUID name untouched (no worse than before)",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":""}`,
dbName: "",
dbRole: "",
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
wantDesc: "",
wantRole: "",
wantChanged: false,
},
{
name: "dbName equals UUID (placeholder row) — not a friendly name, leave untouched",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6"}`,
dbName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
dbRole: "",
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
wantDesc: "",
wantRole: "",
wantChanged: false,
},
{
name: "malformed card JSON — return unchanged, no panic",
card: `{not json`,
dbName: "Claude Code Agent",
dbRole: "",
wantChanged: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out, changed := reconcileAgentCardIdentity(
json.RawMessage(tc.card), wsID, tc.dbName, tc.dbRole,
)
if changed != tc.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
}
if !tc.wantChanged {
// Unchanged path must return the input bytes verbatim.
if string(out) != tc.card {
t.Fatalf("unchanged path mutated bytes:\n got %s\n want %s", out, tc.card)
}
return
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output not valid JSON: %v (%s)", err, out)
}
if g, _ := got["name"].(string); g != tc.wantName {
t.Errorf("name = %q, want %q", g, tc.wantName)
}
if g, _ := got["description"].(string); g != tc.wantDesc {
t.Errorf("description = %q, want %q", g, tc.wantDesc)
}
if tc.wantRole != "" {
if g, _ := got["role"].(string); g != tc.wantRole {
t.Errorf("role = %q, want %q", g, tc.wantRole)
}
}
})
}
}
// TestReconcileAgentCardIdentity_PreservesOtherFields ensures the
// reconcile is a minimal in-place patch — capabilities, version,
// skills and any unknown future fields survive untouched.
func TestReconcileAgentCardIdentity_PreservesOtherFields(t *testing.T) {
card := `{"name":"ws-uuid","description":"","version":"1.0.0",` +
`"capabilities":{"streaming":true,"pushNotifications":true},` +
`"skills":[{"id":"a","name":"a"}],"configuration_status":"ready"}`
out, changed := reconcileAgentCardIdentity(
json.RawMessage(card), "ws-uuid", "Friendly Name", "",
)
if !changed {
t.Fatal("expected changed = true")
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got["version"] != "1.0.0" {
t.Errorf("version not preserved: %v", got["version"])
}
if got["configuration_status"] != "ready" {
t.Errorf("configuration_status not preserved: %v", got["configuration_status"])
}
caps, ok := got["capabilities"].(map[string]any)
if !ok || caps["streaming"] != true {
t.Errorf("capabilities not preserved: %v", got["capabilities"])
}
skills, ok := got["skills"].([]any)
if !ok || len(skills) != 1 {
t.Errorf("skills not preserved: %v", got["skills"])
}
}
@@ -162,8 +162,32 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
},
})
// Fire-and-forget: send A2A in background goroutine
go h.executeDelegation(ctx, sourceID, body.TargetID, delegationID, a2aBody)
// Fire-and-forget: send A2A in a background goroutine.
//
// internal#497 — the goroutine MUST NOT inherit the HTTP request's
// cancellation. `ctx` here is c.Request.Context(); the handler returns
// 202 a few lines below, which cancels that context immediately. Before
// this fix (regression ce2db75f) executeDelegation ran on the
// request-scoped ctx, so every DB op + proxy call in the detached
// goroutine failed `context canceled` the instant the 202 was written.
// That silently broke 100% of A2A peer delegations fleet-wide since
// 2026-05-12 (poll-mode peers never got their a2a_receive inbox row;
// lookupDeliveryMode swallowed the ctx error and defaulted to push).
//
// context.WithoutCancel detaches cancellation/deadline while PRESERVING
// all context values (trace/correlation/tenant ids that proxyA2ARequest
// and the broadcaster read off ctx) — this is the established pattern in
// this package (a2a_proxy.go:850, a2a_proxy_helpers.go:525,
// registry.go:822). The 30-minute ceiling matches the prior internal
// budget executeDelegation used before ce2db75f and the proxy's own
// absolute agent-dispatch ceiling (a2a_proxy.go forwardCtx).
delegationCtx, cancelDelegation := context.WithTimeout(
context.WithoutCancel(ctx), 30*time.Minute,
)
go func() {
defer cancelDelegation()
h.executeDelegation(delegationCtx, sourceID, body.TargetID, delegationID, a2aBody)
}()
// Broadcast event so canvas shows delegation in real-time
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
@@ -16,6 +16,65 @@ import (
"github.com/gin-gonic/gin"
)
// ---------- internal#497 regression: detached goroutine ctx must outlive the handler ----------
// TestDelegate_DetachedContext_SurvivesRequestCancellation pins the
// load-bearing invariant that regression ce2db75f violated: the context
// handed to executeDelegation in the fire-and-forget goroutine must NOT be
// cancelled when the HTTP handler returns 202 (which cancels
// c.Request.Context()). Before the fix, executeDelegation ran on the
// request-scoped ctx, so every DB op + proxy call failed `context
// canceled` the instant the 202 was written — silently breaking 100% of
// A2A peer delegations fleet-wide since 2026-05-12.
//
// This test asserts the exact ctx-derivation contract used by Delegate
// (context.WithoutCancel(parent) + a timeout budget): the derived context
// (a) stays alive after the parent is cancelled, and (b) still carries
// parent values (trace/correlation/tenant ids the downstream proxy +
// broadcaster read off ctx). It is intentionally DB-free and fast.
func TestDelegate_DetachedContext_SurvivesRequestCancellation(t *testing.T) {
type ctxKey string
const traceKey ctxKey = "trace-id"
// Simulate c.Request.Context() carrying a correlation value.
parent, cancelParent := context.WithCancel(
context.WithValue(context.Background(), traceKey, "trace-abc-123"),
)
// Exact derivation Delegate uses for the detached goroutine.
delegationCtx, cancelDelegation := context.WithTimeout(
context.WithoutCancel(parent), 30*time.Minute,
)
defer cancelDelegation()
// The HTTP handler "returns 202" → request context is cancelled.
cancelParent()
if err := parent.Err(); err == nil {
t.Fatal("precondition: parent context should be cancelled after the handler returns")
}
// (a) Cancellation MUST NOT propagate to the detached context.
select {
case <-delegationCtx.Done():
t.Fatalf("regression: detached delegation ctx was cancelled by the handler returning (err=%v) — executeDelegation would fail every DB op with `context canceled`", delegationCtx.Err())
default:
// alive — correct
}
// (b) Parent values MUST still be readable (WithoutCancel preserves
// values; trace/correlation/tenant ids the proxy + broadcaster use).
if got, _ := delegationCtx.Value(traceKey).(string); got != "trace-abc-123" {
t.Errorf("detached ctx lost the parent trace value: got %q, want %q", got, "trace-abc-123")
}
// And it still has a real deadline (the 30m budget), so it is not an
// unbounded background context.
if _, hasDeadline := delegationCtx.Deadline(); !hasDeadline {
t.Error("detached ctx must carry the 30-minute timeout budget, but has no deadline")
}
}
// ---------- Delegate: missing target_id → 400 ----------
func TestDelegate_MissingTargetID(t *testing.T) {
@@ -209,12 +209,12 @@ func strDefault(m map[string]interface{}, key, fallback string) string {
// findRunningContainer returns the live container name for workspaceID, or ""
// when the container is genuinely not running OR the daemon errored
// transiently. Routed through provisioner.RunningContainerName as the SSOT
// transiently. Routed through provisioner.RunningContainerNameFunc as the SSOT
// (molecule-core#10) so this handler agrees with healthsweep on the same
// inputs. Transient daemon errors are logged distinctly so triage doesn't
// confuse a flaky daemon with a stopped container.
func (h *PluginsHandler) findRunningContainer(ctx context.Context, workspaceID string) string {
name, err := provisioner.RunningContainerName(ctx, h.docker, workspaceID)
name, err := provisioner.RunningContainerNameFunc(ctx, h.docker, workspaceID)
if err != nil {
log.Printf("plugins: docker inspect transient error for %s: %v (treating as not-running for this request)", workspaceID, err)
return ""
@@ -10,20 +10,20 @@ import (
// TestFindRunningContainer_RoutesThroughProvisionerSSOT is a behavior-based
// AST gate: it pins the invariant that PluginsHandler.findRunningContainer
// MUST go through provisioner.RunningContainerName for its is-running check,
// instead of carrying its own copy of cli.ContainerInspect logic.
// MUST go through provisioner.RunningContainerNameFunc for its is-running
// check, instead of carrying its own copy of cli.ContainerInspect logic.
//
// Background — molecule-core#10: a parallel impl of "is the workspace's
// container running" used to live in plugins.go. It drifted from the
// canonical impl in healthsweep (which goes through Provisioner.IsRunning
// → RunningContainerName) on edge cases like "transient daemon error" —
// → RunningContainerNameFunc) on edge cases like "transient daemon error" —
// the duplicate would 503 with a misleading message while healthsweep
// correctly stayed defensive. Consolidating onto RunningContainerName as
// correctly stayed defensive. Consolidating onto RunningContainerNameFunc as
// the SSOT prevents any future copy from re-introducing that drift.
//
// Mutation invariant: if a future PR replaces the provisioner call with
// `h.docker.ContainerInspect(...)` directly, this test fails. That's the
// signal to either (a) extend RunningContainerName's contract OR (b)
// signal to either (a) extend RunningContainerNameFunc's contract OR (b)
// document why this call site needs to differ. Either way: the drift
// gets a reviewer's attention instead of shipping silently.
func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
@@ -66,9 +66,9 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !ok {
return true
}
// Pkg.Func form: provisioner.RunningContainerName(...)
// Pkg.Func form: provisioner.RunningContainerNameFunc(...)
if pkgIdent, ok := sel.X.(*ast.Ident); ok {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerName" {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerNameFunc" {
callsRunningContainerName = true
}
}
@@ -83,13 +83,13 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !callsRunningContainerName {
t.Errorf(
"findRunningContainer must call provisioner.RunningContainerName for the SSOT inspect — see molecule-core#10. Found no such call.",
"findRunningContainer must call provisioner.RunningContainerNameFunc for the SSOT inspect — see molecule-core#10. Found no such call.",
)
}
if callsContainerInspectRaw {
t.Errorf(
"findRunningContainer carries a direct ContainerInspect call. This is the parallel-impl drift molecule-core#10 fixed. " +
"Either route through provisioner.RunningContainerName OR — if a new use case truly needs a different inspect — extend RunningContainerName's contract first and update this gate to allow the specific delta.",
"Either route through provisioner.RunningContainerNameFunc OR — if a new use case truly needs a different inspect — extend RunningContainerNameFunc's contract first and update this gate to allow the specific delta.",
)
}
}
@@ -134,19 +134,19 @@ func TestProvisionerIsRunning_RoutesThroughRunningContainerName(t *testing.T) {
if !ok {
return true
}
// Same-package call: bare identifier (e.g. RunningContainerName(...)).
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RunningContainerName" {
// Same-package call: bare identifier (e.g. RunningContainerNameFunc(...)).
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RunningContainerNameFunc" {
callsRunningContainerName = true
return true
}
// Selector call: pkg.Func (e.g. provisioner.RunningContainerName)
// Selector call: pkg.Func (e.g. provisioner.RunningContainerNameFunc)
// OR recv.Method (e.g. p.cli.ContainerInspect).
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "RunningContainerName":
case "RunningContainerNameFunc":
callsRunningContainerName = true
case "ContainerInspect":
callsContainerInspectRaw = true
@@ -155,10 +155,10 @@ func TestProvisionerIsRunning_RoutesThroughRunningContainerName(t *testing.T) {
})
if !callsRunningContainerName {
t.Errorf("Provisioner.IsRunning must call RunningContainerName for the SSOT inspect — see molecule-core#10")
t.Errorf("Provisioner.IsRunning must call RunningContainerNameFunc for the SSOT inspect — see molecule-core#10")
}
if callsContainerInspectRaw {
t.Errorf("Provisioner.IsRunning carries a direct ContainerInspect call; route through RunningContainerName instead")
t.Errorf("Provisioner.IsRunning carries a direct ContainerInspect call; route through RunningContainerNameFunc instead")
}
}
@@ -0,0 +1,55 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
)
// stubSources implements pluginSources for testing ListSources.
type stubSources struct {
schemes []string
}
func (s *stubSources) Register(plugins.SourceResolver) {}
func (s *stubSources) Resolve(plugins.Source) (plugins.SourceResolver, error) {
return nil, nil
}
func (s *stubSources) Schemes() []string { return s.schemes }
// TestPluginListSources_StubSources verifies ListSources delegates to the
// pluginSources interface without touching the filesystem — the stub proves
// the HTTP layer works in isolation from plugins.NewRegistry.
func TestPluginListSources_StubSources(t *testing.T) {
// Build a handler with a stub that returns exactly the schemes we want.
// This is the "isolated unit" form: no temp dir, no registry, no disk.
h := NewPluginsHandler(t.TempDir(), nil, nil)
h.sources = &stubSources{schemes: []string{"clawhub", "enterprise"}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
var body map[string][]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body["schemes"]) != 2 {
t.Errorf("expected 2 schemes, got %v", body["schemes"])
}
seen := map[string]bool{}
for _, s := range body["schemes"] {
seen[s] = true
}
if !seen["clawhub"] || !seen["enterprise"] {
t.Errorf("expected clawhub+enterprise, got %v", body["schemes"])
}
}
+31 -3
View File
@@ -327,7 +327,33 @@ func (h *RegistryHandler) Register(c *gin.Context) {
}
}
agentCardStr := string(payload.AgentCard)
// Reconcile the runtime-supplied card's identity fields against the
// trusted workspaces row before storing. The runtime builds its card
// from config.name, which the CP-regenerated /configs/config.yaml
// sets to the workspace UUID — so without this the stored card
// served at /.well-known/agent-card.json and returned to peers via
// agent_card_url has name = UUID, description = "", role = null even
// though the operator-controlled workspaces.name holds the friendly
// name the canvas shows. We only FILL gaps from the DB (never
// downgrade a card that already carries a real name); identity stays
// platform-controlled — the agent cannot self-set these. Best-effort:
// a lookup failure leaves the card exactly as the runtime sent it
// (no-worse-than-before). See agent_card_reconcile.go.
reconciledCard := payload.AgentCard
{
var dbName, dbRole sql.NullString
if qErr := db.DB.QueryRowContext(ctx,
`SELECT name, role FROM workspaces WHERE id = $1`, payload.ID,
).Scan(&dbName, &dbRole); qErr == nil {
if rc, did := reconcileAgentCardIdentity(
payload.AgentCard, payload.ID, dbName.String, dbRole.String,
); did {
reconciledCard = rc
log.Printf("Registry register: reconciled agent_card identity for %s from workspaces row", payload.ID)
}
}
}
agentCardStr := string(reconciledCard)
// urlForUpsert: poll-mode workspaces don't need a URL. Empty input
// becomes NULL via sql.NullString so the row's URL stays clean (the
@@ -413,10 +439,12 @@ func (h *RegistryHandler) Register(c *gin.Context) {
}
}
// Broadcast WORKSPACE_ONLINE
// Broadcast WORKSPACE_ONLINE — use the reconciled card so the canvas
// Agent Card view live-updates with the friendly name, matching what
// was just persisted (not the runtime's raw UUID-name card).
if err := h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.ID, map[string]interface{}{
"url": cachedURL,
"agent_card": payload.AgentCard,
"agent_card": reconciledCard,
"delivery_mode": effectiveMode,
}); err != nil {
log.Printf("Registry broadcast error: %v", err)
@@ -19,6 +19,7 @@ package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os"
@@ -357,6 +358,28 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath str
var stderr bytes.Buffer
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
// When the per-op context deadline (eicFileOpTimeout) fires,
// exec.CommandContext SIGKILLs the ssh subprocess and Run()
// returns the bare "signal: killed" with empty stderr. That
// surfaced to the canvas as an opaque
// `500 {"error":"ssh install: signal: killed ()"}` which gave
// the operator no idea the workspace was simply mid-provision
// with a slow/unready EIC tunnel (internal#423). Detect the
// deadline explicitly and return an actionable message instead
// — the EIC mechanism, timeout value, and success path are all
// unchanged; this only improves the error a stuck write emits.
if cerr := ctx.Err(); cerr != nil {
reason := "timed out after " + eicFileOpTimeout.String()
if errors.Is(cerr, context.Canceled) && !errors.Is(cerr, context.DeadlineExceeded) {
reason = "was cancelled"
}
return fmt.Errorf(
"ssh install: EIC tunnel to workspace %s — "+
"the workspace may still be provisioning (slow/unready SSH); "+
"retry once it is online, or apply provider credentials via "+
"Settings → Secrets (encrypted, does not use this file-write path)",
reason)
}
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s root=%s wrote %d bytes → %s",
@@ -0,0 +1,71 @@
package handlers
// template_files_eic_write_timeout_test.go — pins the actionable-error
// behavior added for internal#423.
//
// When the per-op context deadline (eicFileOpTimeout) fires,
// exec.CommandContext SIGKILLs the ssh subprocess and Run() returns the
// bare "signal: killed" with empty stderr. Before the fix that surfaced
// to the canvas as an opaque `500 {"error":"ssh install: signal:
// killed ()"}` — useless to an operator whose workspace was simply
// mid-provision with a slow/unready EIC tunnel. The fix detects the
// deadline explicitly (errors.Is(ctx.Err(), context.DeadlineExceeded))
// and returns a message that names the cause and the
// Settings → Secrets workaround.
import (
"context"
"strings"
"testing"
"time"
)
// TestWriteFileViaEIC_DeadlineExceeded_ActionableError stubs
// withEICTunnel so the *real* inner closure runs against a context that
// has already exceeded its deadline. The ssh subprocess fails (no real
// sshd on the fake port) and ctx.Err() == DeadlineExceeded, so the new
// branch must fire and produce an actionable message — NOT the opaque
// "signal: killed ()" string the canvas used to show.
func TestWriteFileViaEIC_DeadlineExceeded_ActionableError(t *testing.T) {
prev := withEICTunnel
withEICTunnel = func(_ context.Context, instanceID string, fn func(s eicSSHSession) error) error {
// Run the real inner closure. It closes over the ctx that
// writeFileViaEIC derived from our already-cancelled parent, so
// the ssh subprocess is killed immediately and ctx.Err()
// resolves — exactly the eicFileOpTimeout-expiry shape.
return fn(eicSSHSession{
instanceID: instanceID,
osUser: "ubuntu",
localPort: 1, // nothing listening → ssh fails fast
keyPath: "/nonexistent/key",
})
}
t.Cleanup(func() { withEICTunnel = prev })
// Drive the real writeFileViaEIC. Pass a parent whose deadline has
// already passed: the context.WithTimeout(ctx, eicFileOpTimeout)
// derived inside writeFileViaEIC inherits the expired parent
// deadline, so ctx.Err() == context.DeadlineExceeded by the time
// the killed ssh subprocess returns — the exact production shape
// (eicFileOpTimeout expiry), exercised deterministically.
parent, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
err := writeFileViaEIC(parent, "i-test", "claude-code", "/configs", "config.yaml", []byte("model: sonnet\n"))
if err == nil {
t.Fatalf("expected an error from a killed ssh subprocess, got nil")
}
msg := err.Error()
// Must NOT leak the opaque bare-signal string to the operator.
if strings.Contains(msg, "signal: killed ()") {
t.Fatalf("error still surfaces the opaque %q form: %q", "signal: killed ()", msg)
}
// Must name the cause and the Secrets workaround so the canvas
// shows something actionable.
for _, want := range []string{"timed out", "provisioning", "Settings", "Secrets"} {
if !strings.Contains(msg, want) {
t.Errorf("actionable error missing %q; got: %q", want, msg)
}
}
}
@@ -10,8 +10,20 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// validWorkspaceID returns true when id is a syntactically valid UUID.
// workspace_id is a `uuid` column; passing a non-UUID (e.g. the canvas
// "global" sentinel sent when no node is selected) makes Postgres raise
// `invalid input syntax for type uuid`, which previously leaked as an
// opaque 500. Reject up front with a clean 400 instead. Mirrors the
// uuid.Parse guard already used in handlers/activity.go.
func validWorkspaceID(id string) bool {
_, err := uuid.Parse(id)
return err == nil
}
// TokenHandler exposes user-facing token management for workspaces.
// Routes: GET/POST/DELETE /workspaces/:id/tokens (behind WorkspaceAuth).
type TokenHandler struct{}
@@ -31,6 +43,10 @@ type tokenListItem struct {
// never the plaintext or hash).
func (h *TokenHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
if !validWorkspaceID(workspaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
limit := 50
if v := c.Query("limit"); v != "" {
@@ -53,6 +69,7 @@ func (h *TokenHandler) List(c *gin.Context) {
LIMIT $2 OFFSET $3
`, workspaceID, limit, offset)
if err != nil {
log.Printf("tokens: list query failed for workspace %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
return
}
@@ -85,6 +102,10 @@ const maxTokensPerWorkspace = 50
// exactly once in the response — it cannot be recovered afterwards.
func (h *TokenHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
if !validWorkspaceID(workspaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
// Rate limit: max active tokens per workspace
var count int
@@ -117,6 +138,10 @@ func (h *TokenHandler) Create(c *gin.Context) {
func (h *TokenHandler) Revoke(c *gin.Context) {
workspaceID := c.Param("id")
tokenID := c.Param("tokenId")
if !validWorkspaceID(workspaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
result, err := db.DB.ExecContext(c.Request.Context(), `
UPDATE workspace_auth_tokens
@@ -41,6 +41,15 @@ import (
func init() { gin.SetMode(gin.TestMode) }
// Workspace IDs are validated as UUIDs up front (tokens.go validWorkspaceID),
// so handler tests must pass syntactically valid UUIDs. Fixed values keep
// sqlmock WithArgs assertions deterministic.
const (
wsUUID1 = "11111111-1111-1111-1111-111111111111"
wsUUID2 = "22222222-2222-2222-2222-222222222222"
wsUUID3 = "33333333-3333-3333-3333-333333333333"
)
// withMockDB swaps `db.DB` for a sqlmock and returns the mock plus a
// restore func. Tests use this in place of setupTokenTestDB which
// skips on a missing real DB.
@@ -81,13 +90,13 @@ func TestTokenHandler_List_HappyPath(t *testing.T) {
created := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
last := created.Add(time.Hour)
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at\s+FROM workspace_auth_tokens`).
WithArgs("ws-1", 50, 0).
WithArgs(wsUUID1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}).
AddRow("tok-1", "abc12345", created, last).
AddRow("tok-2", "def67890", created, nil))
w := makeReq(t, NewTokenHandler().List, "GET",
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
@@ -121,7 +130,7 @@ func TestTokenHandler_List_EmptyResult(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
w := makeReq(t, NewTokenHandler().List, "GET",
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: "ws-2"}})
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: wsUUID2}})
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on empty list, got %d", w.Code)
@@ -146,7 +155,7 @@ func TestTokenHandler_List_QueryError(t *testing.T) {
WillReturnError(errors.New("connection refused"))
w := makeReq(t, NewTokenHandler().List, "GET",
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: "ws-3"}})
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: wsUUID3}})
if w.Code != http.StatusInternalServerError {
t.Errorf("query error must surface as 500, got %d", w.Code)
@@ -158,13 +167,13 @@ func TestTokenHandler_List_RespectsLimit(t *testing.T) {
defer cleanup()
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at`).
WithArgs("ws-1", 10, 5).
WithArgs(wsUUID1, 10, 5).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/tokens?limit=10&offset=5", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Params = gin.Params{{Key: "id", Value: wsUUID1}}
NewTokenHandler().List(c)
if w.Code != http.StatusOK {
@@ -186,7 +195,7 @@ func TestTokenHandler_List_ScanError(t *testing.T) {
AddRow("tok-1", "abc", "not-a-timestamp", nil))
w := makeReq(t, NewTokenHandler().List, "GET",
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusInternalServerError {
t.Errorf("scan error must surface as 500, got %d: %s", w.Code, w.Body.String())
@@ -201,11 +210,11 @@ func TestTokenHandler_Create_RateLimited(t *testing.T) {
// Count query returns 50 (== max) → 429.
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("ws-1").
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(50))
w := makeReq(t, NewTokenHandler().Create, "POST",
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusTooManyRequests {
t.Errorf("max active tokens should 429, got %d", w.Code)
@@ -225,7 +234,7 @@ func TestTokenHandler_Create_IssueFails(t *testing.T) {
WillReturnError(errors.New("disk full"))
w := makeReq(t, NewTokenHandler().Create, "POST",
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusInternalServerError {
t.Errorf("IssueToken DB error must 500, got %d", w.Code)
@@ -242,7 +251,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
w := makeReq(t, NewTokenHandler().Create, "POST",
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
@@ -257,7 +266,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
if body.AuthToken == "" {
t.Errorf("auth_token must be present and non-empty in response")
}
if body.WorkspaceID != "ws-1" {
if body.WorkspaceID != wsUUID1 {
t.Errorf("workspace_id mismatch: %q", body.WorkspaceID)
}
}
@@ -269,12 +278,12 @@ func TestTokenHandler_Revoke_HappyPath(t *testing.T) {
defer cleanup()
mock.ExpectExec(`UPDATE workspace_auth_tokens\s+SET revoked_at = now\(\)`).
WithArgs("tok-1", "ws-1").
WithArgs("tok-1", wsUUID1).
WillReturnResult(sqlmock.NewResult(0, 1))
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
"/workspaces/ws-1/tokens/tok-1", gin.Params{
{Key: "id", Value: "ws-1"},
{Key: "id", Value: wsUUID1},
{Key: "tokenId", Value: "tok-1"},
})
@@ -289,12 +298,12 @@ func TestTokenHandler_Revoke_NotFound(t *testing.T) {
// 0 rows affected → token not found OR already revoked.
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
WithArgs("tok-ghost", "ws-1").
WithArgs("tok-ghost", wsUUID1).
WillReturnResult(sqlmock.NewResult(0, 0))
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
"/workspaces/ws-1/tokens/tok-ghost", gin.Params{
{Key: "id", Value: "ws-1"},
{Key: "id", Value: wsUUID1},
{Key: "tokenId", Value: "tok-ghost"},
})
@@ -312,7 +321,7 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
"/workspaces/ws-1/tokens/tok-1", gin.Params{
{Key: "id", Value: "ws-1"},
{Key: "id", Value: wsUUID1},
{Key: "tokenId", Value: "tok-1"},
})
@@ -321,6 +330,59 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
}
}
// ---- UUID validation (regression: "global" sentinel 500) ------------
// The canvas Settings → Workspace Tokens tab sent the literal sentinel
// "global" as the workspace id when no node was selected. workspace_id
// is a `uuid` column, so the query raised
// `invalid input syntax for type uuid: "global"` which leaked as an
// opaque 500. List/Create/Revoke now reject any non-UUID id with a
// clean 400 before touching the DB. No DB expectation is set on the
// mock — a DB hit would fail ExpectationsWereMet, proving short-circuit.
func TestTokenHandler_RejectsNonUUIDWorkspaceID(t *testing.T) {
h := NewTokenHandler()
cases := []struct {
name string
run func(c *gin.Context)
method string
params gin.Params
}{
{"List", h.List, "GET", gin.Params{{Key: "id", Value: "global"}}},
{"Create", h.Create, "POST", gin.Params{{Key: "id", Value: "global"}}},
{"Revoke", h.Revoke, "DELETE", gin.Params{
{Key: "id", Value: "global"},
{Key: "tokenId", Value: "tok-1"},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
w := makeReq(t, tc.run, tc.method,
"/workspaces/global/tokens", tc.params)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s with non-UUID id must 400, got %d: %s",
tc.name, w.Code, w.Body.String())
}
var body struct {
Error string `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body.Error != "invalid workspace id" {
t.Errorf("%s: want error=%q, got %q",
tc.name, "invalid workspace id", body.Error)
}
// No query/exec was expected → if the handler hit the DB
// this fails, proving the guard short-circuits before SQL.
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("%s leaked a DB call past the uuid guard: %v", tc.name, err)
}
})
}
}
// Compile-time noise removal: the imports list pulls in the sql /
// driver packages and the silenced ctx so a future scenario that
// needs them doesn't have to re-add the import. Documented here so
@@ -11,6 +11,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func init() { gin.SetMode(gin.TestMode) }
@@ -167,11 +168,14 @@ func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
h := NewTokenHandler()
// Try to revoke with a different workspace ID — should 404
// Try to revoke with a different (valid-UUID) workspace ID that does
// not own the token — should 404. A valid UUID is required so this
// exercises the ownership branch, not the up-front uuid-shape 400.
otherWS := uuid.NewString()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}}
c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil)
c.Params = gin.Params{{Key: "id", Value: otherWS}, {Key: "tokenId", Value: tokenID}}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+otherWS+"/tokens/"+tokenID, nil)
h.Revoke(c)
if w.Code != http.StatusNotFound {
@@ -0,0 +1,266 @@
package handlers
import (
"bytes"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
// ---------- broadcastTruncate pure unit tests ----------
func TestBroadcastTruncate_ShortMessage(t *testing.T) {
got := broadcastTruncate("hello", 120)
if got != "hello" {
t.Errorf("expected 'hello', got %q", got)
}
}
func TestBroadcastTruncate_ExactlyMax(t *testing.T) {
got := broadcastTruncate("hello", 5)
if got != "hello" {
t.Errorf("expected 'hello', got %q", got)
}
}
func TestBroadcastTruncate_LongMessage(t *testing.T) {
got := broadcastTruncate("hello world this is a long message that exceeds the limit", 10)
if got != "hello worl…" {
t.Errorf("expected 'hello worl…', got %q", got)
}
}
func TestBroadcastTruncate_Unicode(t *testing.T) {
// "日本語" is 3 runes; truncating to 2 gives "日本…"
got := broadcastTruncate("日本語テスト", 2)
if got != "日本…" {
t.Errorf("expected '日本…', got %q", got)
}
}
// ---------- Broadcast endpoint ----------
// All test IDs are valid UUIDs so they pass validateWorkspaceID.
const (
wsSender = "00000000-0000-0000-0000-000000000001"
wsDNE = "00000000-0000-0000-0000-000000000002"
wsDisabled = "00000000-0000-0000-0000-000000000003"
wsAlone = "00000000-0000-0000-0000-000000000004"
wsR1 = "00000000-0000-0000-0000-000000000011"
wsR2 = "00000000-0000-0000-0000-000000000012"
)
func makeBroadcastHandler(t *testing.T) (*BroadcastHandler, sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
cleanup := func() {
db.DB = prevDB
mockDB.Close()
}
return NewBroadcastHandler(newTestBroadcaster()), mock, cleanup
}
func postBroadcast(t *testing.T, h *BroadcastHandler, workspaceID string, body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+workspaceID+"/broadcast", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Broadcast(c)
return w
}
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
// validateWorkspaceID rejects anything that isn't a valid UUID.
w := postBroadcast(t, h, "not-a-valid-uuid", `{"message":"hello"}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := postBroadcast(t, h, wsSender, `{}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_EmptyMessage(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := postBroadcast(t, h, wsSender, `{"message":""}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_WorkspaceNotFound(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsDNE).
WillReturnError(sql.ErrNoRows)
w := postBroadcast(t, h, wsDNE, `{"message":"hello"}`)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_BroadcastDisabled(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsDisabled).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", false))
w := postBroadcast(t, h, wsDisabled, `{"message":"hello"}`)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_DeliversToTwoRecipients(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients: two workspaces.
// Escape \$1 so sqlmock reads it as a literal (not a regex backreference).
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsR1).AddRow(wsR2))
// Activity log inserts for each recipient (6 args: ws_id, activity_type, method, source_id, summary, status).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR1, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR2, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender's own activity log (5 args: ws_id, activity_type, method, summary, status).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsSender, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsSender, `{"message":"hello world"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_ZeroRecipients(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsAlone).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("alone-ws", true))
// Recipients: no rows.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsAlone).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
// Sender activity log (zero delivered, 5 args).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsAlone, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsAlone, `{"message":"hello"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_RecipientActivityFailureIsNonFatal(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients: one workspace.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsR1))
// Recipient activity log fails — error is logged but request succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR1, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnError(sql.ErrConnDone)
// Sender activity log still succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsSender, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsSender, `{"message":"hello"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200 despite recipient insert failure, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_RecipientsQueryDBError(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients query fails.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnError(sql.ErrConnDone)
w := postBroadcast(t, h, wsSender, `{"message":"hello"}`)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// Ensure BroadcastHandler doesn't read the request body if validation fails early.
func TestBroadcast_MalformedJSON(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsSender}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsSender+"/broadcast", bytes.NewReader([]byte(`{not-json`)))
c.Request.Header.Set("Content-Type", "application/json")
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
@@ -1139,7 +1139,7 @@ func (p *Provisioner) IsRunning(ctx context.Context, workspaceID string) (bool,
if p == nil || p.cli == nil {
return false, ErrNoBackend
}
name, err := RunningContainerName(ctx, p.cli, workspaceID)
name, err := RunningContainerNameFunc(ctx, p.cli, workspaceID)
if err != nil {
// Transient daemon error: caller treats !running as dead + restarts.
// Returning true + the underlying error preserves the error for
@@ -0,0 +1,26 @@
package provisioner
import (
"context"
"github.com/docker/docker/client"
)
// RunningContainerNameFunc is the pluggable entry-point for the RunningContainerName
// SSOT. It defaults to the canonical implementation but can be swapped at test
// time via StubRunningContainerName. The plug is at the package level — not the
// struct level — so callers (Provisioner.IsRunning, PluginsHandler.findRunningContainer,
// healthsweep) all hit the same override without each needing its own wiring.
//
// Isolating "testing" to this single file avoids the CGO / undefined-reference
// problem that arises when Docker client types appear in non-test files.
var RunningContainerNameFunc = RunningContainerName
// StubRunningContainerName installs fn as the RunningContainerNameFunc for the
// remainder of the test (or until swapped again). Deferred restore is the caller's
// responsibility.
func StubRunningContainerName(fn func(context.Context, *client.Client, string) (string, error)) func() {
orig := RunningContainerNameFunc
RunningContainerNameFunc = fn
return func() { RunningContainerNameFunc = orig }
}