Compare commits

...

47 Commits

Author SHA1 Message Date
claude-ceo-assistant 7da843f2fa fix(ci): move all-required to meta runner lane
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Platform (Go) (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 24m41s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m29s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m14s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 7s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m26s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m13s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m22s
audit-force-merge / audit (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
2026-05-23 20:55:06 -07:00
hongming e5521c7675 merge PR #1765
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
publish-workspace-server-image / build-and-push (push) Successful in 9m10s
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 17s
CI / Python Lint & Test (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m42s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m44s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m9s
Harness Replays / Harness Replays (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m24s
E2E Chat / E2E Chat (push) Successful in 4m48s
CI / Platform (Go) (push) Failing after 5m31s
CI / all-required (push) Failing after 33m35s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
gate-check-v3 / gate-check (push) Successful in 25s
main-red-watchdog / watchdog (push) Successful in 39s
SOP local verification complete; Actions unavailable/stuck, force_merge used.
2026-05-24 02:58:00 +00:00
claude-ceo-assistant a52110502d fix: narrow supported workspace runtime catalog
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
audit-force-merge / audit (pull_request) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
2026-05-23 19:56:03 -07:00
hongming 69bcc55ad3 refactor(memory): #1733 A1 — kill v1 SQL fallback, v2 plugin is the only backend (#1747)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
publish-workspace-server-image / build-and-push (push) Successful in 3m8s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 2m18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m35s
CTO-bypass merge per 2026-05-24 directive; SOP-6 checklist filled + persona-acked, REQUEST_CHANGES dismissed, dispatched-review evidence in PR comments.
2026-05-24 02:45:56 +00:00
hongming 36c63798eb fix(memory): #1734 delete dead MemoryTab + live-refresh MemoryInspectorPanel (#1749)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 2m10s
publish-workspace-server-image / build-and-push (push) Successful in 3m18s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
CTO-bypass merge per 2026-05-24 directive — SOP-6 checklist acked, 2 non-author approvals on current HEAD, dispatched-review evidence in PR comments.
2026-05-24 02:41:35 +00:00
hongming 43422e0ba9 Merge pull request 'feat(display): proxy native desktop streams after takeover' (#1752) from feat/1686-display-session-proxy into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
publish-canvas-image / Build & push canvas image (push) Successful in 3m1s
publish-workspace-server-image / build-and-push (push) Successful in 6m15s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 2s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m13s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 12s
CI / all-required (push) Has been cancelled
main-red-watchdog / watchdog (push) Successful in 34s
gate-check-v3 / gate-check (push) Successful in 39s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 10s
ci-required-drift / drift (push) Successful in 1m39s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 8s
2026-05-24 01:33:24 +00:00
agent-dev-b 0ffb29f371 Merge pull request 'fix(handlers): bypass CanCommunicate for canvas-user identity callers (#1674)' (#1756) from fix/memory-list-rows-err into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Successful in 3m18s
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 33s
Handlers Postgres Integration / detect-changes (push) Successful in 3s
Harness Replays / detect-changes (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
main-red-watchdog / watchdog (push) Successful in 54s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m29s
gate-check-v3 / gate-check (push) Successful in 1m31s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
CI / all-required (push) Has been cancelled
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 32s
ci-required-drift / drift (push) Successful in 1m10s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
2026-05-24 01:04:29 +00:00
Molecule AI Dev Engineer A (Kimi) 226698239f fix(handlers): bypass CanCommunicate for canvas-user identity callers (#1674)
CI / all-required (pull_request) compensating
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
Post-RFC#637, canvas users send X-Workspace-ID (their identity workspace
UUID). validateCallerToken now detects canvas/admin/org auth on a tokenless
workspace and returns isCanvasUser=true. The A2A proxy and ScheduleHealth
endpoint use this flag to bypass CanCommunicate, since human users sit
outside the workspace hierarchy.

Detection paths for tokenless workspaces:
- same-origin canvas request (middleware.IsSameOriginCanvas)
- Authorization: Bearer matching ADMIN_TOKEN
- Authorization: Bearer matching a live org_api_tokens row

Also fixes mcp_tools.go which was calling MCPHandler.proxyA2ARequest
(5 args) with an extra argument.

Fixes #1674

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:49:46 +00:00
claude-ceo-assistant 3c82b39f3d feat(display): proxy native desktop streams after takeover
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 19s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 42s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Successful in 4s
security-review / approved (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-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E Chat / E2E Chat (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m15s
Harness Replays / Harness Replays (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m49s
CI / Platform (Go) (pull_request) Successful in 6m56s
CI / Canvas (Next.js) (pull_request) Successful in 7m23s
CI / all-required (pull_request) Successful in 25m7s
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-23 17:41:45 -07:00
hongming 4d32736e25 Merge pull request 'fix(a2a): avoid false failure on busy queue fallback' (#1751) from fix/codex-scheduled-a2a-timeout into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 10s
publish-workspace-server-image / build-and-push (push) Successful in 3m15s
CI / Detect changes (push) Successful in 9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 38s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m45s
CI / Canvas (Next.js) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m38s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m56s
CI / Platform (Go) (push) Successful in 5m17s
E2E Chat / E2E Chat (push) Failing after 3m30s
CI / all-required (push) Successful in 22m26s
Harness Replays / Harness Replays (push) Successful in 2s
publish-workspace-server-image / Production auto-deploy (push) Successful in 24m55s
CI / Canvas Deploy Reminder (push) Successful in 3s
gate-check-v3 / gate-check (push) Successful in 29s
main-red-watchdog / watchdog (push) Successful in 2m20s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 10s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m17s
Merge PR #1751: fix(a2a): avoid false failure on busy queue fallback
2026-05-23 23:29:00 +00:00
claude-ceo-assistant 691d341fbb fix(a2a): avoid false failure on busy queue fallback
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 35s
sop-checklist / review-refire (pull_request) Has been skipped
qa-review / approved (pull_request) Failing after 9s
security-review / approved (pull_request) Failing after 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
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Successful in 23s
Harness Replays / Harness Replays (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m51s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m26s
CI / Platform (Go) (pull_request) Successful in 5m48s
CI / all-required (pull_request) Successful in 6m45s
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-23 16:21:22 -07:00
hongming ef42e17224 Merge pull request 'fix(ci): keep production auto-deploy nonblocking' (#1746) from fix/prod-auto-deploy-nonblocking into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Successful in 3m6s
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Detect changes (push) Successful in 16s
CI / Python Lint & Test (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 32s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m16s
CI / Platform (Go) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 12s
CI / all-required (push) Successful in 4m30s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 7s
E2E Chat / E2E Chat (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m36s
publish-workspace-server-image / Production auto-deploy (push) Successful in 18m24s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m59s
main-red-watchdog / watchdog (push) Successful in 41s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m11s
gate-check-v3 / gate-check (push) Successful in 24s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m3s
2026-05-23 22:40:30 +00:00
agent-dev-b b13c9f94f1 Merge pull request 'fix(workspace-server): check rows.Err() after iteration in MemoryHandler.List' (#1748) from fix/memory-list-rows-err into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Successful in 3m10s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 3s
E2E API Smoke Test / detect-changes (push) Successful in 5s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / all-required (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 31s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m18s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m26s
Harness Replays / Harness Replays (push) Successful in 3s
2026-05-23 22:20:25 +00:00
Molecule AI Dev Engineer A (Kimi) 600f88b172 fix(workspace-server): check rows.Err() after iteration in MemoryHandler.List
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 7s
CI / all-required (pull_request) compensating
audit-force-merge / audit (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
The List handler iterated over rows.Next() but never checked rows.Err()
after the loop. If the database connection fails during iteration, the
error is silently swallowed and partial results are returned with 200 OK.

Add a rows.Err() guard that returns 500 when iteration encounters an
error, plus a sqlmock test that injects a storage-engine fault mid-loop.

Tracked: rows.Err() audit gap (follow-up to internal#348 / PR #1743).
2026-05-23 22:12:45 +00:00
claude-ceo-assistant df94fd1764 fix(ci): keep production auto-deploy nonblocking
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m26s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
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 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 23m56s
audit-force-merge / audit (pull_request) Successful in 11s
2026-05-23 15:01:39 -07:00
agent-dev-a 8346b06291 Merge pull request 'fix(ci): arm64 pilot runs-on label matches Mac mini registration' (#1744) from fix/arm64-pilot-label into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Successful in 2m59s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m14s
CI / all-required (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
main-red-watchdog / watchdog (push) Successful in 45s
gate-check-v3 / gate-check (push) Successful in 24s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 10s
ci-required-drift / drift (push) Successful in 1m1s
2026-05-23 21:57:42 +00:00
agent-dev-b b7da21063e fix(ci): guard review-check against empty PRs (head == base) (#1743)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
review-check-tests / review-check.sh regression tests (push) Successful in 10s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m9s
Co-authored-by: agent-dev-b <agent-dev-b@agents.moleculesai.app>
Co-committed-by: agent-dev-b <agent-dev-b@agents.moleculesai.app>
2026-05-23 21:56:43 +00:00
agent-dev-a 2f7b5ad871 Merge pull request 'ci: add internal#418 tracker for arm64 advisory continue-on-error' (#1745) from fix/arm64-advisory-tracker into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
2026-05-23 21:55:41 +00:00
Molecule AI Dev Engineer B (MiniMax) 213ea06840 fix(ci): arm64 shellcheck pilot resilience
CI / all-required (pull_request) compensating
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
- Add continue-on-error on Install shellcheck step
- Add command -v check before running shellcheck (skip if binary
  missing, exit 0 — pilot mode)
- Add continue-on-error on Run shellcheck step

The arm64-darwin Mac mini pilot runner may not have shellcheck
pre-installed; this makes the workflow resilient rather than failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:52:36 +00:00
Molecule AI Dev Engineer A (Kimi) f07dfa7af6 ci: add internal#418 tracker for arm64 advisory continue-on-error (#1731)
CI / all-required (pull_request) compensating
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
audit-force-merge / audit (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
The lint-continue-on-error-tracking script requires a tracker comment
within 2 lines of every advisory continue-on-error directive. This adds
the missing internal#418 tracker to ci-arm64-advisory.yml.

Fixes #1731
2026-05-23 21:49:44 +00:00
hongming 93f5a4aac3 Merge pull request 'fix(ci): use writable Docker config for canvas publish' (#1740) from fix/canvas-publish-docker-config into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 1m24s
publish-workspace-server-image / build-and-push (push) Successful in 2m56s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
2026-05-23 21:48:45 +00:00
Molecule AI Dev Engineer A (Kimi) e5d6e45ab1 fix(ci): arm64 pilot runs-on label matches Mac mini registration (#1679)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Failing after 8s
CI / Detect changes (pull_request) Successful in 6s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / all-required (pull_request) Failing after 40m17s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
Mac mini registers with labels self-hosted, macos-self-hosted-arm64,
arm64-darwin — no plain 'arm64'. The workflow was perpetually CANCELLED
because Gitea could not assign a runner.

Fixes #1679
2026-05-23 21:40:46 +00:00
claude-ceo-assistant a1cf56cdab fix(ci): use writable Docker config for canvas publish
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m8s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m29s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m37s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 18m56s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-23 14:11:53 -07:00
agent-dev-a 436fae8949 fix: GitHub token HTTP timeout (split from #1700) (#1728)
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 3m8s
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
CI / Shellcheck (E2E scripts) (push) Successful in 2s
CI / Canvas (Next.js) (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m47s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m6s
Harness Replays / Harness Replays (push) Successful in 7s
E2E Chat / E2E Chat (push) Successful in 4m20s
CI / Platform (Go) (push) Successful in 5m14s
CI / all-required (push) Successful in 9m39s
main-red-watchdog / watchdog (push) Successful in 2m4s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Successful in 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Waiting to run
CI / Canvas Deploy Reminder (push) Successful in 1s
gate-check-v3 / gate-check (push) Successful in 24s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m32s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m8s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 38m32s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 21:01:09 +00:00
hongming 2d1a853bf9 Merge pull request 'feat(display): add desktop workspace creation flow' (#1732) from feat/1686-display-workspace-flow into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
publish-canvas-image / Build & push canvas image (push) Failing after 7s
CI / Detect changes (push) Successful in 15s
CI / Python Lint & Test (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
publish-workspace-server-image / build-and-push (push) Has been cancelled
CI / all-required (push) Has been cancelled
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 32s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m54s
2026-05-23 20:59:59 +00:00
claude-ceo-assistant 5551ef40e3 feat(display): add desktop workspace creation flow
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 17s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 31s
E2E Chat / detect-changes (pull_request) Successful in 31s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 29s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
Harness Replays / detect-changes (pull_request) Successful in 12s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 41s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
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 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 21s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m49s
Harness Replays / Harness Replays (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m40s
CI / Platform (Go) (pull_request) Successful in 6m28s
CI / Canvas (Next.js) (pull_request) Successful in 7m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 10m21s
audit-force-merge / audit (pull_request) Successful in 13s
2026-05-23 13:46:20 -07:00
agent-dev-a 656176d511 fix(workspace-server): #1687 — alias GH_PAT to GH_TOKEN / GITHUB_TOKEN at provision time (#1697)
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 12s
CI / Python Lint & Test (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 31s
Handlers Postgres Integration / detect-changes (push) Successful in 2s
Harness Replays / detect-changes (push) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 4m42s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m9s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
main-red-watchdog / watchdog (push) Successful in 56s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m50s
Harness Replays / Harness Replays (push) Successful in 4s
gate-check-v3 / gate-check (push) Successful in 56s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m9s
E2E Chat / E2E Chat (push) Successful in 4m11s
CI / Platform (Go) (push) Successful in 5m22s
CI / all-required (push) Successful in 13m58s
publish-workspace-server-image / Production auto-deploy (push) Successful in 14m28s
CI / Canvas Deploy Reminder (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 14s
ci-required-drift / drift (push) Successful in 1m11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m19s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m25s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 22s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 20s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 20:01:40 +00:00
agent-dev-a 1424af51fa fix(tests): make SSRF and admin-token tests hermetic against env vars (#1703)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
E2E Staging SaaS (full lifecycle) / pr-validate (push) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Waiting to run
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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 53s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 20:01:38 +00:00
agent-dev-a 7f0f33739b fix(e2e): #1644 Part A — peer-visibility scripts consume inline auth_token (#1680)
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 3m13s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been cancelled
CI / all-required (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m20s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 19:51:59 +00:00
agent-dev-a 339d73d9d4 fix(workspace-crud): add missing descRows.Err() check in CascadeDelete (#1714)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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 / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Harness Replays / detect-changes (push) Successful in 5s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 19:51:47 +00:00
agent-dev-a 50fe4976e6 fix(provisioner): check io.ReadAll + json.Unmarshal errors in CP client (#1719)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 56s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m39s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 19:51:45 +00:00
hongming e05fc4daae ci(arm64): ADVISORY Mac arm64 fast-check lane (Pilot ②, internal#418 relief) (#1442)
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 11s
CI / Platform (Go) (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
E2E Chat / E2E Chat (push) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 12s
CI / all-required (push) Successful in 1m6s
CI / Canvas Deploy Reminder (push) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m29s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m43s
publish-workspace-server-image / build-and-push (push) Successful in 4m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m7s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Failing after 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Failing after 1m11s
main-red-watchdog / watchdog (push) Successful in 2m5s
gate-check-v3 / gate-check (push) Successful in 25s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m12s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m19s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m55s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
2026-05-23 11:08:57 +00:00
cp-be 6c7f66fa31 feat(workspace-server): kill DefaultModel + require model at Create (CTO 2026-05-22 SSOT) (#1667)
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 2m51s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 14s
E2E API Smoke Test / detect-changes (push) Successful in 16s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m32s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 13s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m9s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 49s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m20s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m18s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m10s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Shellcheck (E2E scripts) (push) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
Harness Replays / Harness Replays (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m52s
CI / Platform (Go) (push) Failing after 4m42s
E2E Chat / E2E Chat (push) Successful in 4m30s
publish-workspace-server-image / Production auto-deploy (push) Failing after 18m43s
CI / all-required (push) Failing after 14m56s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 8s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m47s
main-red-watchdog / watchdog (push) Successful in 1m57s
gate-check-v3 / gate-check (push) Successful in 25s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m19s
Co-authored-by: Molecule AI · cp-be <cp-be@agents.moleculesai.app>
Co-committed-by: Molecule AI · cp-be <cp-be@agents.moleculesai.app>
2026-05-23 10:15:18 +00:00
agent-dev-a acf784cd81 fix(mcp-tools): log scanPeers errors instead of silently dropping them (#1713)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
E2E Staging External Runtime / E2E Staging External Runtime (push) Waiting to run
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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 10:15:14 +00:00
agent-dev-a 543519ed69 fix(channels): handle io.ReadAll error in Lark adapter (#1724)
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Detect changes (push) Successful in 14s
CI / Python Lint & Test (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 14s
E2E API Smoke Test / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / all-required (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 53s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 10:11:21 +00:00
agent-dev-a 010ec0f81b fix(server): add ReadHeaderTimeout to http.Server (#1715)
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 2m57s
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 3s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m50s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m5s
E2E Chat / E2E Chat (push) Has been cancelled
CI / Platform (Go) (push) Has been cancelled
CI / all-required (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m30s
main-red-watchdog / watchdog (push) Successful in 38s
gate-check-v3 / gate-check (push) Successful in 44s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m53s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:40:04 +00:00
agent-dev-a bc73f6397a fix(channels): handle io.ReadAll error in Discord adapter (#1725)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:39:27 +00:00
agent-dev-a e79a842859 fix(handlers): add missing rows.Err() checks in schedules/events listers (#1720)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:35:42 +00:00
agent-dev-a c3bcf903bd fix(channels): log and propagate json.Unmarshal errors in manager (#1717)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:35:16 +00:00
agent-dev-a 008a19dbdd fix(handlers): handle io.ReadAll error in traces proxy (#1721)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:35:06 +00:00
agent-dev-a e51dae906f fix(channels): handle io.ReadAll errors and close body in Slack adapter (#1722)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:35:02 +00:00
agent-dev-a f1f7492b66 fix(pgplugin): log JSON encode errors in writeJSON (#1727)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:34:50 +00:00
hongming 3161d43cec Merge pull request 'Add display control state to Display tab' (#1726)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
publish-canvas-image / Build & push canvas image (push) Successful in 3m9s
2026-05-23 09:34:18 +00:00
agent-dev-a 29349e7af0 fix(memory): handle io.ReadAll error in decodeError (#1723)
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 5m33s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 4s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
CI / all-required (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
Harness Replays / detect-changes (push) Has been cancelled
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Has been cancelled
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m1s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m49s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 09:26:41 +00:00
hongming 78e1025f41 fix: scheduler detectResultKind allowlist + envelope kinds (#1716)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 2m57s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
ci-required-drift / drift (push) Successful in 1m18s
Co-authored-by: hongming-ceo-delegated <hongmingwang@moleculesai.app>
Co-committed-by: hongming-ceo-delegated <hongmingwang@moleculesai.app>
2026-05-23 09:15:34 +00:00
fullstack-engineer af3d98e478 Harden display control tab state handling
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 6s
security-review / approved (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
sop-checklist / all-items-acked (pull_request) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 7m23s
CI / all-required (pull_request) Successful in 21m55s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-23 01:46:46 -07:00
fullstack-engineer 321d051c9f Add display control state to Display tab
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
2026-05-23 01:40:49 -07:00
98 changed files with 3341 additions and 2508 deletions
+5
View File
@@ -128,6 +128,7 @@ fi
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
PR_BASE_SHA=$(jq -r '.base.sha // ""' "$PR_JSON")
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
@@ -136,6 +137,10 @@ if [ "$PR_STATE" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
exit 0
fi
if [ "$PR_HEAD_SHA" = "$PR_BASE_SHA" ]; then
echo "::notice::PR ${PR_NUMBER} has no diff (head == base) — exiting 0 (empty PRs do not gate)"
exit 0
fi
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH}${TEAM}-review gate not applicable"
exit 0
@@ -0,0 +1,31 @@
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
def load_workflow(name: str) -> dict:
with (ROOT / "workflows" / name).open() as f:
return yaml.safe_load(f)
def test_all_required_uses_dedicated_meta_runner_lane():
workflow = load_workflow("ci.yml")
all_required = workflow["jobs"]["all-required"]
assert all_required["runs-on"] == "ci-meta"
assert "needs" not in all_required
def test_all_required_reuses_path_filter_before_polling():
workflow = load_workflow("ci.yml")
all_required = workflow["jobs"]["all-required"]
rendered = str(all_required)
assert "--profile ci" in rendered
assert ".gitea/scripts/detect-changes.py" in rendered
assert "REQUIRE_PLATFORM" in rendered
assert "REQUIRE_CANVAS" in rendered
assert "REQUIRE_SCRIPTS" in rendered
+187
View File
@@ -0,0 +1,187 @@
# ci-arm64-advisory — Mac arm64 self-hosted ADVISORY fast-check lane.
#
# === WHY ===
#
# The amd64 Gitea runner pool (molecule-runner-1..20) is queue-contended
# (internal#418). This lane offloads the *genuinely container-independent*
# fast checks (Go build/vet/lint, shellcheck, Python lint) onto the Mac
# arm64 self-hosted runner so developers get a fast arm64 signal WITHOUT
# adding load to the starved amd64 pool — capability-honestly, as an
# additive pilot. Pilot ② of the Mac-CI strategy (CTO-delegated 2026-05-17).
#
# === NON-NEGOTIABLE SAFETY CONTRACT (the prime directive) ===
#
# This lane is **ADVISORY ONLY**. It is provably incapable of hanging a
# merge. Concretely:
#
# 1. It is a SEPARATE workflow file. `ci.yml` is byte-for-byte
# untouched by this PR. The `CI / all-required` aggregator sentinel
# and the five contexts it polls
# (`CI / Detect changes|Platform (Go)|Canvas (Next.js)|
# Shellcheck (E2E scripts)|Python Lint & Test (pull_request)`)
# are unchanged. The canonical required gate stays 100% on the
# existing amd64 pool.
#
# 2. The context this workflow emits is
# `ci-arm64-advisory / fast-checks (pull_request)`. That string is
# DELIBERATELY NOT present in, and this PR does NOT add it to:
# - branch_protections/{main,staging}.status_check_contexts
# (DB-verified pb 86/75 = exactly
# ["CI / all-required (pull_request)",
# "sop-checklist / all-items-acked (pull_request)"])
# - audit-force-merge.yml REQUIRED_CHECKS env
# - ci.yml `all-required` sentinel's hardcoded `required[]` list
# Branch protection therefore never waits on this context. If the
# Mac runner is absent / offline / removed, this workflow's status
# simply never appears — and because nothing requires it, every
# merge proceeds exactly as it does today. There is no path by
# which a missing/red arm64 status blocks a merge.
#
# 3. `continue-on-error: true` on the job — even a genuine arm64-only
# failure (toolchain drift, arch-specific test flake) is surfaced
# as information, never as a merge blocker, for the duration of
# the pilot.
#
# 4. The job carries a `github.event_name` `if:` gate. Beyond its
# functional purpose this also keeps the job OUT of
# `ci-required-drift.py:ci_job_names()` (which excludes
# `github.event_name`/`github.ref`-gated jobs), so the hourly
# ci-required-drift sentinel's F1 ("job not under sentinel needs")
# cannot ever flag this advisory job. F2/F3 are untouched because
# this context is absent from BP and from REQUIRED_CHECKS.
# `lint-bp-context-emit-match` only fails on BP→emitter gaps; an
# emitter without a BP context is explicitly informational there.
#
# === RUNNER TARGETING ===
#
# The Mac runner is `hongming-pc-runner-1`. The bare `self-hosted`
# label is POLLUTED in this Gitea instance: molecule-runner-1..20
# (the contended amd64 pool) also advertise `self-hosted`. Targeting
# bare `self-hosted` would route back onto the very pool we are trying
# to relieve — and onto amd64 hardware. We therefore require an
# AND-set of labels that ONLY the Mac satisfies. `macos-self-hosted`
# is Mac-exclusive (the amd64 pool does not carry it). Until the
# label-install burst (a10862b2) lands `self-hosted`+`macos-self-hosted`
# on the Mac, the runner's current unique label `hongming-pc-laptop`
# is also listed; AND-semantics over the labels a runner advertises
# means a job requiring [self-hosted, macos-self-hosted] can ONLY be
# claimed once the Mac advertises both. If neither label set is yet
# present on the Mac, the workflow stays queued harmlessly and is
# garbage-collected by the normal stale-run reaper — it blocks nothing
# (see safety contract point 2).
#
# === ROLLBACK ===
#
# Delete this single file (`git rm .gitea/workflows/ci-arm64-advisory.yml`)
# and merge. No branch-protection edit, no ci.yml edit, no
# REQUIRED_CHECKS edit is required to roll back, because none were made
# to roll forward. Zero blast radius either direction.
name: ci-arm64-advisory
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
# Per-ref cancel: a newer commit on the same ref supersedes the older
# advisory run. Distinct from ci.yml's `ci-${ref}` group so this lane
# never cancels (or is cancelled by) the canonical required CI.
concurrency:
group: ci-arm64-advisory-${{ github.ref }}
cancel-in-progress: true
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
fast-checks:
name: fast-checks
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
runs-on: [self-hosted, macos-self-hosted]
# ADVISORY: never blocks. See safety contract point 3. mc#774
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
continue-on-error: true
# event_name gate: functional (only meaningful on push/PR) AND keeps
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
# flag it. See safety contract point 4.
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
timeout-minutes: 20
steps:
- name: Provenance — advisory lane, non-gating
run: |
echo "This is the arm64 ADVISORY fast-check lane."
echo "It does NOT gate merges. Canonical required CI is ci.yml"
echo "on the amd64 pool. Arch: $(uname -m) on $(uname -s)."
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ---- Go: build + vet + lint (container-independent: needs only the
# Go toolchain; no amd64 ECR image, no docker-in-job). Race-detector
# unit-test + coverage gates are deliberately NOT duplicated here —
# those stay authoritative on amd64 ci.yml `Platform (Go)`. This lane
# is fast-feedback for the compile/vet/lint surface only. ----
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- name: Go build + vet (workspace-server)
working-directory: workspace-server
run: |
go mod download
go build ./cmd/server
go vet ./...
- name: golangci-lint (workspace-server)
working-directory: workspace-server
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
"$(go env GOPATH)/bin/golangci-lint" run --timeout 3m ./...
# ---- Shellcheck (container-independent: shellcheck binary only).
# Mirrors ci.yml `Shellcheck (E2E scripts)` bulk pass scope. ----
- name: Install shellcheck (arm64)
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck not preinstalled on this self-hosted runner."
echo "Attempting Homebrew install (Mac arm64)."
brew install shellcheck || {
echo "::warning::shellcheck unavailable on runner; advisory shellcheck skipped."
exit 0
}
fi
shellcheck --version
- name: Shellcheck tests/e2e + infra/scripts
run: |
command -v shellcheck >/dev/null 2>&1 || { echo "skip"; exit 0; }
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
# ---- Python lint/compile (container-independent: CPython only).
# Lint + import-compile surface; the authoritative pytest + coverage
# floors stay on amd64 ci.yml `Python Lint & Test`. ----
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
- name: Python byte-compile (workspace)
working-directory: workspace
run: |
python -m pip install --quiet ruff || true
python -m compileall -q .
if command -v ruff >/dev/null 2>&1; then
ruff check . || echo "::warning::ruff findings (advisory only)"
fi
- name: Advisory summary
if: always()
run: |
{
echo "## arm64 advisory fast-checks complete"
echo ""
echo "This lane is **advisory** — it does not gate merges."
echo "Authoritative required CI remains \`CI / all-required\`"
echo "on the amd64 pool (\`ci.yml\`, unchanged by this PR)."
} >> "$GITHUB_STEP_SUMMARY"
+30 -5
View File
@@ -476,7 +476,11 @@ jobs:
# jobs settle, leaving branch protection with a permanent pending
# `CI / all-required` context. Instead, this independent sentinel polls the
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
# or never emit. It runs the same path detector as `changes` and only waits
# for path-relevant jobs; Gitea can otherwise leave needs/output-skipped
# jobs permanently pending with "Blocked by required conditions". It runs on
# the dedicated `ci-meta` lane so the poller does not occupy the same
# general runner pool as the jobs it is waiting for.
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
@@ -484,9 +488,24 @@ jobs:
# sentinel before the `always()` guard can emit a branch-protection status.
#
continue-on-error: false
runs-on: ubuntu-latest
runs-on: ci-meta
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: check
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
python3 .gitea/scripts/detect-changes.py \
--profile ci \
--event-name "${{ github.event_name }}" \
--pr-base-sha "$PR_BASE_SHA" \
--base-ref "$PR_BASE_REF" \
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
- name: Wait for required CI contexts
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -494,6 +513,9 @@ jobs:
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
REQUIRE_PLATFORM: ${{ steps.check.outputs.platform }}
REQUIRE_CANVAS: ${{ steps.check.outputs.canvas }}
REQUIRE_SCRIPTS: ${{ steps.check.outputs.scripts }}
run: |
set -euo pipefail
python3 - <<'PY'
@@ -511,11 +533,14 @@ jobs:
event = os.environ["EVENT_NAME"]
required = [
f"CI / Detect changes ({event})",
f"CI / Platform (Go) ({event})",
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
]
if os.environ.get("REQUIRE_PLATFORM") == "true":
required.append(f"CI / Platform (Go) ({event})")
if os.environ.get("REQUIRE_CANVAS") == "true":
required.append(f"CI / Canvas (Next.js) ({event})")
if os.environ.get("REQUIRE_SCRIPTS") == "true":
required.append(f"CI / Shellcheck (E2E scripts) ({event})")
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
last_summary = None
@@ -25,7 +25,7 @@ permissions:
jobs:
shellcheck-arm64:
name: shellcheck-arm64 (pilot)
runs-on: [self-hosted, arm64]
runs-on: [self-hosted, arm64-darwin]
# NOT a required check; safe to sit pending until Mac runner is up.
# If the Mac runner has trouble pulling actions/checkout we fall
# back to a plain git clone (see step 'fallback clone').
@@ -52,6 +52,7 @@ jobs:
fetch-depth: 1
- name: Install shellcheck (arm64)
continue-on-error: true
run: |
set -eu
if command -v shellcheck >/dev/null 2>&1; then
@@ -71,11 +72,16 @@ jobs:
shellcheck --version | head -2
- name: Run shellcheck on .gitea/scripts/*.sh
continue-on-error: true
run: |
set -eu
# Only the scripts we control under .gitea/scripts. Pilot
# scope is intentionally narrow — broaden in a follow-up
# once the lane is proven.
if ! command -v shellcheck >/dev/null 2>&1; then
echo "WARN: shellcheck binary not found — skipping (pilot mode)"
exit 0
fi
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
if [ "${#TARGETS[@]}" -eq 0 ]; then
echo "No .sh files found under .gitea/scripts — nothing to check"
+11
View File
@@ -73,6 +73,17 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Keep Docker auth/buildx state inside the job temp dir. Publish
# runners can inherit a HOME/DOCKER_CONFIG path that is host-owned
# and not writable from the job container; docker login otherwise
# fails before the image build starts.
- name: Prepare writable Docker config
run: |
set -euo pipefail
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
mkdir -p "$DOCKER_CONFIG/buildx/certs"
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
- name: Log in to ECR
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
@@ -234,6 +234,8 @@ jobs:
name: Production auto-deploy
needs: build-and-push
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Side-effect deploy only; image publish success is the durable artifact. mc#774
continue-on-error: true
# Publish/release lane (internal#462) — production deploy of a merged
# fix; reserved capacity, never queued behind PR-CI.
runs-on: publish
+7
View File
@@ -8,6 +8,7 @@
"name": "molecule-monorepo-canvas",
"version": "0.1.0",
"dependencies": {
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-tabs": "^1.1.12",
@@ -1110,6 +1111,12 @@
"node": ">= 10"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-tabs": "^1.1.12",
@@ -33,6 +33,8 @@ interface HermesProvider {
models: string[];
}
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
// All providers supported by Hermes runtime via providers.resolve_provider().
// `defaultModel` is the slug injected into the workspace provision request
// when the user picks this provider — template-hermes's derive-provider.sh
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
const [displayEnabled, setDisplayEnabled] = useState(false);
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
const [displayRootGB, setDisplayRootGB] = useState("80");
const [displayResolution, setDisplayResolution] = useState("1920x1080");
// Templates fetched from /api/templates — drives the dynamic provider
// filter below. Same data source ConfigTab uses (PR #2454). When the
// selected template declares `runtime_config.providers` in its
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
setParentId("");
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType("t3.xlarge");
setDisplayRootGB("80");
setDisplayResolution("1920x1080");
setHermesProvider("anthropic");
setExternalRuntime("external");
setHermesApiKey("");
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
const parsedBudget = budgetLimit.trim()
? parseFloat(budgetLimit)
: null;
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
const parsedRootGB = parseInt(displayRootGB, 10);
const createResp = await api.post<{
id: string;
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
...(displayEnabled
? {
compute: {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
},
}
: {}),
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
</div>
</div>
{!isExternal && (
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
<div className="mb-2 text-[11px] font-medium text-ink-mid">
Container Config
</div>
<label className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-ink">Display</span>
<input
type="checkbox"
checked={displayEnabled}
onChange={(e) => setDisplayEnabled(e.target.checked)}
aria-label="Enable display"
className="h-4 w-4"
/>
</label>
{displayEnabled && (
<div className="mt-3 grid grid-cols-2 gap-2">
<div>
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
Instance
</label>
<select
id="display-instance-type"
value={displayInstanceType}
onChange={(e) => setDisplayInstanceType(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="t3.large">t3.large</option>
<option value="t3.xlarge">t3.xlarge</option>
<option value="m6i.xlarge">m6i.xlarge</option>
<option value="c6i.xlarge">c6i.xlarge</option>
</select>
</div>
<div>
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
Disk GB
</label>
<input
id="display-root-gb"
type="number"
min="30"
max="500"
value={displayRootGB}
onChange={(e) => setDisplayRootGB(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
/>
</div>
<div className="col-span-2">
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
Resolution
</label>
<select
id="display-resolution"
value={displayResolution}
onChange={(e) => setDisplayResolution(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="1920x1080">1920 x 1080</option>
<option value="1600x900">1600 x 900</option>
<option value="1280x720">1280 x 720</option>
</select>
</div>
</div>
)}
</div>
)}
<div>
<label className="text-[11px] text-ink-mid block mb-1">
Parent Workspace
+56 -1
View File
@@ -24,9 +24,10 @@
* "no memories yet".
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useSocketEvent } from '@/hooks/useSocketEvent';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -246,6 +247,60 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
loadEntries();
}, [loadEntries]);
// Live-refresh on ACTIVITY_LOGGED events that look like memory writes
// for this workspace (#1734). Without this, the user sees a stale
// empty state after an agent commits — agent says "wrote memory",
// panel keeps showing nothing until they hit Refresh.
//
// What actually broadcasts ACTIVITY_LOGGED on the server today
// (workspace-server/internal/handlers/activity.go LogActivity /
// LogActivityTx — those are the only emitters):
//
// - `memory_write_global` — `POST /workspaces/:id/memories` for GLOBAL scope
// - `memory_edit_global` — `PATCH /workspaces/:id/memories/:id` for GLOBAL scope
// - `memory_delete_global` — `DELETE /workspaces/:id/memories/:id` for GLOBAL scope
// - `agent_log` — generic catch-all an agent emits via
// `POST /workspaces/:id/activity`
//
// The MCP-tool path (`commit_memory`, `commit_memory_v2`,
// `commit_summary`) does NOT broadcast on the wire today; it inserts
// into agent_memories (pre-A1) or calls the v2 plugin (post-A1) and
// never round-trips through LogActivity. Server-side follow-up is
// tracked in **#1754** — once the MCP handlers emit `memory_write`
// via LogActivity, the `agent_log` arm of the filter below can be
// dropped. `memory_write` is included pre-emptively so this code
// lights up the moment #1754 lands. Until then, `agent_log` catches
// MCP commits over-inclusively; the 300ms debounce bounds the
// refetch rate. Issue #1734 review finding.
//
// The 300ms debounce coalesces bursts so a chatty agent (e.g. an
// agent in a long task emitting agent_log every few hundred ms)
// doesn't hammer /v2/memories on every keystroke-equivalent.
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
}, []);
useSocketEvent((msg) => {
if (msg.event !== 'ACTIVITY_LOGGED') return;
if (msg.workspace_id !== workspaceId) return;
const p = (msg.payload || {}) as Record<string, unknown>;
const activityType = (p.activity_type as string) || '';
switch (activityType) {
case 'memory_write':
case 'memory_write_global':
case 'memory_edit_global':
case 'memory_delete_global':
case 'agent_log':
break;
default:
return;
}
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
refetchTimerRef.current = setTimeout(() => {
loadEntries();
}, 300);
});
// ── Delete handlers ─────────────────────────────────────────────────────────
const confirmDelete = useCallback(async () => {
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
expect(body.parent_id).toBeUndefined();
});
it("omits compute config by default", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Plain Agent" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toBeUndefined();
expect(body.model).toBe("anthropic:claude-opus-4-7");
});
it("sends display compute profile when desktop display is enabled", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Desktop Agent" },
});
fireEvent.click(screen.getByLabelText("Enable display"));
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("anthropic:claude-opus-4-7");
expect(body.compute).toEqual({
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
},
});
});
it("renders gracefully when GET /workspaces fails", async () => {
mockGet.mockRejectedValueOnce(new Error("Network error"));
await openDialog();
@@ -16,7 +16,7 @@
* - handleDeployed fires after 500ms delay
*
* Uses vi.hoisted + vi.mock to fully isolate the api module, matching
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
* the pattern established in ApprovalBanner and ScheduleTab tests.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
@@ -1,93 +0,0 @@
// @vitest-environment jsdom
/**
* Unit tests for pure helpers from MemoryInspectorPanel:
* isPluginUnavailableError, formatRelativeTime, formatTTL
*
* These are the three exported non-component functions. The component
* itself (MemoryInspectorPanel) requires full API + store mocking and
* is exercised by the existing MemoryTab.test.tsx.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
describe("isPluginUnavailableError", () => {
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
expect(isPluginUnavailableError(err)).toBe(true);
});
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
});
it("returns false for unrelated error messages", () => {
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
});
it("returns false for null", () => {
expect(isPluginUnavailableError(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isPluginUnavailableError(undefined)).toBe(false);
});
it("returns false for plain objects without message", () => {
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
});
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
const lowerErr = new Error("memory_plugin_url missing");
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
expect(isPluginUnavailableError(lowerErr)).toBe(false);
expect(isPluginUnavailableError(upperErr)).toBe(true);
});
});
describe("formatTTL", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("returns '' for null", () => {
expect(formatTTL(null)).toBe("");
});
it("returns '' for undefined", () => {
expect(formatTTL(undefined)).toBe("");
});
it('returns "expired" when expiresAt is in the past', () => {
const past = new Date(Date.now() - 60_000).toISOString();
expect(formatTTL(past)).toBe("expired");
});
it('returns "Xs" for less than a minute', () => {
const soon = new Date(Date.now() + 30_000).toISOString();
expect(formatTTL(soon)).toBe("30s");
});
it('returns "Xm" for less than an hour', () => {
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
expect(formatTTL(soon)).toBe("5m");
});
it('returns "Xh" for less than a day', () => {
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
expect(formatTTL(soon)).toBe("3h");
});
it('returns "Xd" for more than a day', () => {
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
expect(formatTTL(soon)).toBe("2d");
});
it("returns '' for invalid date string", () => {
expect(formatTTL("not-a-date")).toBe("");
});
it("returns '' for empty string", () => {
expect(formatTTL("")).toBe("");
});
});
@@ -31,6 +31,17 @@ vi.mock('@/lib/api', () => ({
},
}));
// Capture the socket-event handler the panel registers so individual
// tests can replay an ACTIVITY_LOGGED message without spinning up a
// real WebSocket. One handler at a time is fine — the panel mounts
// exactly one useSocketEvent subscriber.
let __socketHandler: ((msg: unknown) => void) | null = null;
vi.mock('@/hooks/useSocketEvent', () => ({
useSocketEvent: (handler: (msg: unknown) => void) => {
__socketHandler = handler;
},
}));
vi.mock('@/components/ConfirmDialog', () => ({
ConfirmDialog: ({
open,
@@ -516,3 +527,156 @@ describe('MemoryInspectorPanel — refresh', () => {
});
});
});
// Live-refresh subscription wired in #1734 so the panel reacts to
// ACTIVITY_LOGGED events for memory writes on this workspace without
// the user clicking Refresh. The hook is mocked at the top of the
// file to capture the registered handler in __socketHandler.
describe('MemoryInspectorPanel — live refresh on activity', () => {
it('refetches memories when ACTIVITY_LOGGED arrives with activity_type=memory_write for the same workspace', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubFetch([MEM_BASIC]);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByLabelText('Refresh memories'));
expect(__socketHandler).toBeTruthy();
const before = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
__socketHandler!({
event: 'ACTIVITY_LOGGED',
workspace_id: 'ws-1',
payload: { activity_type: 'memory_write' },
});
// 300ms debounce inside the panel — advance the fake timer so the
// queued refetch fires.
await vi.advanceTimersByTimeAsync(350);
await waitFor(() => {
const after = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
expect(after).toBe(before + 1);
});
vi.useRealTimers();
});
it('ignores ACTIVITY_LOGGED events from other workspaces', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubFetch([MEM_BASIC]);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByLabelText('Refresh memories'));
const before = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
__socketHandler!({
event: 'ACTIVITY_LOGGED',
workspace_id: 'ws-OTHER',
payload: { activity_type: 'memory_write' },
});
await vi.advanceTimersByTimeAsync(500);
const after = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
expect(after).toBe(before);
vi.useRealTimers();
});
it('ignores activity types that are not memory-related', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubFetch([MEM_BASIC]);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByLabelText('Refresh memories'));
const before = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
__socketHandler!({
event: 'ACTIVITY_LOGGED',
workspace_id: 'ws-1',
payload: { activity_type: 'a2a_send' },
});
await vi.advanceTimersByTimeAsync(500);
const after = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
expect(after).toBe(before);
vi.useRealTimers();
});
// Server-side emitters confirmed via grep of workspace-server/internal/handlers
// are `memory_write_global`, `memory_edit_global`, `memory_delete_global`
// (memories.go `LogActivity` calls for GLOBAL-scope writes). Pin each
// so a future filter narrow-down can't silently drop one and let the
// panel go stale on its actual production trigger.
it.each([
'memory_write', // pre-emptive: not yet emitted by server, see component comment
'memory_write_global', // memories.go:218 (Commit)
'memory_edit_global', // memories.go:617 (Update)
'memory_delete_global', // memories.go (Delete) — paired with the above two
'agent_log', // generic catch-all
])('refetches on activity_type=%s', async (activityType) => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubFetch([MEM_BASIC]);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByLabelText('Refresh memories'));
const before = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
__socketHandler!({
event: 'ACTIVITY_LOGGED',
workspace_id: 'ws-1',
payload: { activity_type: activityType },
});
await vi.advanceTimersByTimeAsync(350);
await waitFor(() => {
const after = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
expect(after).toBe(before + 1);
});
vi.useRealTimers();
});
it('coalesces a burst of memory_write events into one refetch', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
stubFetch([MEM_BASIC]);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByLabelText('Refresh memories'));
const before = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
for (let i = 0; i < 5; i++) {
__socketHandler!({
event: 'ACTIVITY_LOGGED',
workspace_id: 'ws-1',
payload: { activity_type: 'memory_write' },
});
}
await vi.advanceTimersByTimeAsync(350);
await waitFor(() => {
const after = mockGet.mock.calls.filter((c) =>
(c[0] as string).includes('/v2/memories'),
).length;
expect(after).toBe(before + 1);
});
vi.useRealTimers();
});
});
+1 -1
View File
@@ -369,7 +369,7 @@ export function ChannelsTab({ workspaceId }: Props) {
onClick={handleCreate}
// Was bg-accent-strong hover:bg-accent — accent is the
// LIGHTER variant; same AA contrast trap fixed in
// ScheduleTab/MemoryTab/OnboardingWizard.
// ScheduleTab/OnboardingWizard.
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Connect Channel
+292 -16
View File
@@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type RFB from "@novnc/novnc";
interface DisplayStatus {
available: boolean;
@@ -13,31 +14,116 @@ interface DisplayStatus {
height?: number;
}
interface DisplayControlStatus {
controller: "none" | "user" | "agent";
controlled_by?: string;
expires_at?: string;
session_url?: string;
}
interface Props {
workspaceId: string;
}
export function DisplayTab({ workspaceId }: Props) {
const [status, setStatus] = useState<DisplayStatus | null>(null);
const [control, setControl] = useState<DisplayControlStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [controlError, setControlError] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
const requestGeneration = useRef(0);
useEffect(() => {
const generation = requestGeneration.current + 1;
requestGeneration.current = generation;
let cancelled = false;
setStatus(null);
setControl(null);
setSessionUrl(null);
setError(null);
api
.get<DisplayStatus>(`/workspaces/${workspaceId}/display`)
.then((data) => {
if (!cancelled) setStatus(data);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable");
});
setControlError(null);
setControlBusy(false);
async function load() {
try {
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
if (cancelled || requestGeneration.current !== generation) return;
setStatus(displayStatus);
if (displayStatus.reason === "display_not_enabled") return;
try {
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
} catch (err) {
if (!cancelled && requestGeneration.current === generation) {
setControl(null);
setControlError("Display control unavailable");
}
}
} catch (err) {
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
}
}
load();
return () => {
cancelled = true;
};
}, [workspaceId]);
const acquireControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
controller: "user",
ttl_seconds: 300,
});
if (requestGeneration.current !== generation) return;
setControl(next);
setSessionUrl(next.session_url || null);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to take control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
const releaseControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
if (requestGeneration.current !== generation) return;
setControl(next);
setSessionUrl(null);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to release control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
if (error) {
return (
<div className="p-5">
@@ -81,16 +167,206 @@ export function DisplayTab({ workspaceId }: Props) {
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
</p>
{!isNotEnabled && (
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
<>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
{control ? (
<div className="flex items-center justify-between gap-3 text-left">
<div className="min-w-0">
<p className="text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
{control.controller === "none" && (
<button
type="button"
onClick={acquireControl}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
</div>
) : (
<div className="text-left">
{!controlError && (
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
)}
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
)}
</div>
</>
)}
</div>
);
}
return null;
return (
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
<div className="min-w-0">
<h3 className="text-sm font-medium text-ink">Desktop</h3>
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
{status.mode || "desktop-control"} · {status.protocol || "display"}
</p>
</div>
<DisplayControlBar
control={control}
controlBusy={controlBusy}
controlError={controlError}
hasSession={!!sessionUrl}
onAcquire={acquireControl}
onRelease={releaseControl}
/>
</div>
{sessionUrl ? (
<DesktopStream sessionUrl={sessionUrl} />
) : (
<div className="flex flex-1 items-center justify-center p-8 text-center">
<div>
<h3 className="mb-1.5 text-sm font-medium text-ink">Take control to open the desktop.</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
The display service is ready. Control access opens a short-lived desktop stream.
</p>
</div>
</div>
)}
</div>
);
}
function DisplayControlBar({
control,
controlBusy,
controlError,
hasSession,
onAcquire,
onRelease,
}: {
control: DisplayControlStatus | null;
controlBusy: boolean;
controlError: string | null;
hasSession: boolean;
onAcquire: () => void;
onRelease: () => void;
}) {
return (
<div className="flex min-w-0 items-center gap-3">
{control && (
<div className="min-w-0 text-right">
<p className="truncate text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
</div>
)}
{(control?.controller === "none" ||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
<button
type="button"
onClick={onAcquire}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
{control?.controller === "user" && control.controlled_by === "admin-token" && (
<button
type="button"
onClick={onRelease}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Release
</button>
)}
</div>
);
}
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let rfb: RFB | null = null;
async function connect() {
setStreamError(null);
try {
const mod = await import("@novnc/novnc");
if (cancelled || !containerRef.current) return;
const stream = displayWebSocketConnection(sessionUrl);
rfb = new mod.default(containerRef.current, stream.url, {
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.focusOnClick = true;
rfb.addEventListener("disconnect", (event: Event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
});
} catch {
if (!cancelled) setStreamError("Desktop stream could not be opened.");
}
}
connect();
return () => {
cancelled = true;
rfb?.disconnect();
};
}, [sessionUrl]);
return (
<div className="relative min-h-0 flex-1 bg-black">
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
{streamError && (
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
{streamError}
</div>
)}
</div>
);
}
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
const url = new URL(sessionUrl, window.location.href);
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
if (!token) throw new Error("display session token missing");
url.hash = "";
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return { url: url.toString(), token };
}
function displayControlActorLabel(control: DisplayControlStatus): string {
if (control.controller === "agent") return "Agent";
if (control.controlled_by === "admin-token") return "Admin";
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
return "User";
}
-471
View File
@@ -1,471 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
interface Props {
workspaceId: string;
}
interface MemoryEntry {
key: string;
value: unknown;
version?: number;
expires_at: string | null;
updated_at: string;
}
const AWARENESS_BASE_URL =
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
export function MemoryTab({ workspaceId }: Props) {
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [showAwareness, setShowAwareness] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const [newTTL, setNewTTL] = useState("");
const [error, setError] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editTTL, setEditTTL] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const awarenessUrl = useMemo(() => {
try {
const url = new URL(AWARENESS_BASE_URL);
url.searchParams.set("workspaceId", workspaceId);
return url.toString();
} catch {
return AWARENESS_BASE_URL;
}
}, [workspaceId]);
const awarenessStatus = useMemo(() => {
try {
const url = new URL(AWARENESS_BASE_URL);
return url.origin.includes("localhost") ? "local" : url.hostname;
} catch {
return "unavailable";
}
}, []);
const loadMemory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
setEntries(data);
} catch (e) {
setEntries([]);
setError(e instanceof Error ? e.message : "Failed to load memory");
} finally {
setLoading(false);
}
}, [workspaceId]);
useEffect(() => {
loadMemory();
}, [loadMemory]);
const handleAdd = async () => {
setError(null);
if (!newKey.trim()) {
setError("Key is required");
return;
}
let parsedValue: unknown;
try {
parsedValue = JSON.parse(newValue);
} catch {
parsedValue = newValue;
}
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
if (newTTL) {
const ttl = parseInt(newTTL);
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
}
try {
await api.post(`/workspaces/${workspaceId}/memory`, body);
setNewKey("");
setNewValue("");
setNewTTL("");
setShowAdd(false);
loadMemory();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add");
}
};
const handleDelete = async (key: string) => {
setError(null);
try {
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
setEntries((prev) => prev.filter((e) => e.key !== key));
if (expanded === key) setExpanded(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete entry");
}
};
const beginEdit = (entry: MemoryEntry) => {
setEditError(null);
setEditingKey(entry.key);
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
// editor doesn't surprise users with surrounding quotes.
setEditValue(
typeof entry.value === "string"
? entry.value
: JSON.stringify(entry.value, null, 2),
);
if (entry.expires_at) {
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
setEditTTL(ttl > 0 ? String(ttl) : "");
} else {
setEditTTL("");
}
};
const cancelEdit = () => {
setEditingKey(null);
setEditValue("");
setEditTTL("");
setEditError(null);
};
const handleEditSave = async (entry: MemoryEntry) => {
setEditError(null);
let parsedValue: unknown;
try {
parsedValue = JSON.parse(editValue);
} catch {
parsedValue = editValue;
}
// if_match_version closes the silent-overwrite hole when two writers
// race. The handler returns 409 with the current version on mismatch
// — surface that as a retry hint and reload to pick up the new state.
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
if (typeof entry.version === "number") {
body.if_match_version = entry.version;
}
if (editTTL) {
const ttl = parseInt(editTTL);
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
}
try {
await api.post(`/workspaces/${workspaceId}/memory`, body);
cancelEdit();
loadMemory();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to save";
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
setEditError("This entry changed since you opened it. Reloading.");
loadMemory();
} else {
setEditError(message);
}
}
};
const openAwareness = () => {
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
};
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
}
return (
<div className="p-4 space-y-4">
{error && !showAdd && (
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{error}
</div>
)}
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
<p className="text-[10px] text-ink-mid">
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Open
</button>
</div>
</div>
{showAwareness ? (
AWARENESS_BASE_URL ? (
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
<iframe
title="Awareness dashboard"
src={awarenessUrl}
className="h-[520px] w-full border-0"
loading="lazy"
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
</div>
)
) : (
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-ink-mid truncate">
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Expand
</button>
</div>
)}
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
<span className="font-medium text-good">Connected</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
<span className="font-medium text-ink">{awarenessStatus}</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
</div>
</div>
</section>
<section className="space-y-3 border-t border-line/60 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
<p className="text-[10px] text-ink-mid">
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
+ Add
</button>
</div>
</div>
{showAdvanced && showAdd && (
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
<input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
aria-label="Memory key"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
<textarea
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder='Value (JSON or plain text)'
rows={3}
aria-label="Memory value (JSON or plain text)"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={newTTL}
onChange={(e) => setNewTTL(e.target.value)}
placeholder="TTL in seconds (optional)"
aria-label="TTL in seconds (optional)"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Save
</button>
<button
type="button"
onClick={() => {
setShowAdd(false);
setError(null);
}}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Cancel
</button>
</div>
</div>
)}
{showAdvanced ? (
entries.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div key={entry.key} className="bg-surface-card rounded border border-line">
<button
type="button"
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
aria-expanded={expanded === entry.key}
>
<span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2">
{entry.expires_at && (
<span className="text-[9px] text-ink-mid">
TTL {new Date(entry.expires_at).toLocaleString()}
</span>
)}
<span className="text-[10px] text-ink-mid">
{expanded === entry.key ? "▼" : "▶"}
</span>
</div>
</button>
{expanded === entry.key && (
<div className="px-3 pb-2 space-y-2">
{editingKey === entry.key ? (
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
rows={4}
aria-label={`Edit value for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={editTTL}
onChange={(e) => setEditTTL(e.target.value)}
placeholder="TTL in seconds (blank = no expiry)"
aria-label={`Edit TTL for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{editError && (
<div role="alert" className="text-[10px] text-bad">
{editError}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditSave(entry)}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Save
</button>
<button
type="button"
onClick={cancelEdit}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Cancel
</button>
</div>
</div>
) : (
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(entry.value, null, 2)}
</pre>
)}
<div className="flex items-center justify-between">
<span className="text-[9px] text-ink-mid">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-2">
{editingKey !== entry.key && (
<button
type="button"
onClick={() => beginEdit(entry)}
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Edit
</button>
)}
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)
) : (
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-ink-mid truncate">
KV entries remain available if you need the raw platform store.
</p>
</div>
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Show
</button>
</div>
)}
</section>
</div>
);
}
@@ -1,12 +1,36 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockRFBConstructor: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
post: mockPost,
},
}));
vi.mock("@novnc/novnc", () => ({
default: class MockRFB extends EventTarget {
scaleViewport = false;
resizeSession = false;
focusOnClick = false;
target: HTMLElement;
url: string;
options?: { wsProtocols?: string[] };
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[] }) {
super();
this.target = target;
this.url = url;
this.options = options;
mockRFBConstructor(target, url, options);
}
disconnect() {}
},
}));
@@ -14,7 +38,10 @@ import { DisplayTab } from "../DisplayTab";
describe("DisplayTab", () => {
beforeEach(() => {
cleanup();
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
@@ -29,5 +56,311 @@ describe("DisplayTab", () => {
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
});
it("renders control acquisition for display-configured workspaces", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("waits for takeover before opening a ready display stream", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Take control to open the desktop.")).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
it("opens the trusted noVNC client after takeover returns a stream URL", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
expect(mockRFBConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.stringContaining("/workspaces/ws-display/display/session/websockify"),
{ wsProtocols: ["binary", "molecule-display-token.signed"] },
);
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
});
it("releases user display control", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockResolvedValueOnce({
controller: "none",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Release" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
});
it("renders active display control locks as observe-only", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(mockPost).not.toHaveBeenCalled();
});
it("labels org-token display control locks as automation", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "org-token:abc123",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
});
expect(screen.queryByText("org-token:abc123")).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
});
it("refreshes display control state after failed acquisition", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.getByText("Failed to take control")).toBeTruthy();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
expect(mockGet).toHaveBeenCalledTimes(3);
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("keeps display status visible without takeover actions when control status fails", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(screen.getByText("Display control unavailable")).toBeTruthy();
});
it("does not render raw display status errors", async () => {
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display status unavailable")).toBeTruthy();
});
expect(screen.queryByText(/secret backend details/)).toBeNull();
});
it("ignores stale acquire responses after workspace changes", async () => {
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockReturnValueOnce(acquire.promise);
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
rerender(<DisplayTab workspaceId="ws-b" />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
acquire.resolve({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
await acquire.promise;
await waitFor(() => {
expect(screen.queryByText("Controlled by Admin")).toBeNull();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
@@ -1,632 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for MemoryTab awareness dashboard + workspace KV memory management.
*
* Coverage:
* - Loading state
* - Error state when GET /memory fails
* - Empty state (no memory entries)
* - Memory list rendering (single + multiple entries)
* - Expand/collapse memory entries
* - Add memory entry (key + value + TTL)
* - Add validates required key
* - Add parses JSON values
* - Delete memory entry
* - Edit memory entry (inline)
* - Edit 409 conflict shows retry hint
* - Advanced toggle shows/hides KV section
* - Awareness dashboard expand/collapse
* - Awareness URL includes workspaceId
* - Refresh button reloads memory
* - Error clears when appropriate actions are taken
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryTab } from "../MemoryTab";
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: mockGet, post: mockPost, del: mockDel },
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const MEMORY_ENTRY = {
key: "user_context",
value: { name: "Alice", role: "engineer" },
version: 3,
expires_at: null,
updated_at: new Date(Date.now() - 60000).toISOString(),
};
function entry(overrides: Partial<typeof MEMORY_ENTRY> = {}): typeof MEMORY_ENTRY {
return { ...MEMORY_ENTRY, ...overrides };
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
function typeIn(el: HTMLElement, value: string) {
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fireEvent.change(el as any, { target: el });
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("MemoryTab", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockDel.mockReset();
vi.useRealTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Loading / Error ──────────────────────────────────────────────────────────
it("shows loading state when memory is being fetched", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<MemoryTab workspaceId="ws-1" />);
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading memory...")).toBeTruthy();
});
it("shows error banner when GET /memory rejects", async () => {
mockGet.mockRejectedValue(new Error("network failure"));
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/network failure/i)).toBeTruthy();
});
it("shows 'Failed to load memory' when GET rejects with non-Error", async () => {
mockGet.mockRejectedValue("unknown error");
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/Failed to load memory/i)).toBeTruthy();
});
// ── Awareness Dashboard ─────────────────────────────────────────────────────
it("shows Awareness dashboard section", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("Awareness dashboard")).toBeTruthy();
});
it("renders an iframe with workspaceId in URL", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-xyz" />);
await flush();
const iframe = screen.getByTitle("Awareness dashboard");
expect(iframe.getAttribute("src")).toContain("workspaceId=ws-xyz");
});
it("shows 'Connected' status", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("Connected")).toBeTruthy();
});
it("shows workspace ID in the status grid", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-test-id" />);
await flush();
// workspaceId appears in two places (description + status grid).
// Target the font-mono span in the status grid specifically.
const spans = Array.from(document.querySelectorAll("span.font-mono"));
expect(spans.some(s => s.textContent === "ws-test-id")).toBeTruthy();
});
it("shows 'Collapse' and 'Open' buttons for awareness (starts visible)", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByRole("button", { name: /collapse/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
});
it("hides awareness iframe when Collapse is clicked", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
await flush();
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
expect(screen.getByText(/awareness dashboard is collapsed/i)).toBeTruthy();
});
it("re-shows awareness iframe when collapsed state Expand is clicked", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
// Start with awareness visible (default) — verify iframe is there
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
// Click Collapse in the awareness header to hide the iframe
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
await flush();
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
// The collapsed awareness state has a different "Expand" button.
// Directly click the button whose text is exactly "Expand".
const allBtns = screen.getAllByRole("button");
const expandInCollapsed = allBtns.find(b => b.textContent?.trim() === "Expand");
expect(expandInCollapsed).toBeTruthy();
act(() => { expandInCollapsed!.click(); });
await flush();
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
});
// ── KV Memory: Empty / Advanced toggle ───────────────────────────────────────
it("shows 'Advanced workspace memory is hidden' when advanced is collapsed", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/advanced workspace memory is hidden/i)).toBeTruthy();
});
it("shows 'Show' button when advanced is collapsed", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
expect(screen.getByRole("button", { name: /show/i })).toBeTruthy();
});
it("shows 'Hide Advanced' after clicking Show", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByRole("button", { name: /hide advanced/i })).toBeTruthy();
});
it("shows empty state 'No memory entries' when advanced is shown and list is empty", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByText("No memory entries")).toBeTruthy();
});
// ── KV Memory: List rendering ───────────────────────────────────────────────
it("renders memory entries when advanced is open", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByText("user_context")).toBeTruthy();
});
it("renders multiple memory entries", async () => {
mockGet.mockResolvedValue([
entry({ key: "key1", value: "value1" }),
entry({ key: "key2", value: "value2" }),
]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByText("key1")).toBeTruthy();
expect(screen.getByText("key2")).toBeTruthy();
});
it("shows chevron pointing right when entry is collapsed", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByText("▶")).toBeTruthy();
});
it("shows chevron pointing down when entry is expanded", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText("▼")).toBeTruthy();
});
it("shows entry value when expanded", async () => {
mockGet.mockResolvedValue([entry({ value: { foo: "bar" } })]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText(/"foo": "bar"/)).toBeTruthy();
});
it("shows updated_at timestamp when entry is expanded", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText(/updated:/i)).toBeTruthy();
});
it("shows Edit and Delete buttons when entry is expanded", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
});
it("shows TTL when entry has expires_at", async () => {
const future = new Date(Date.now() + 3600000).toISOString();
mockGet.mockResolvedValue([entry({ expires_at: future })]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText(/ttl/i)).toBeTruthy();
});
// ── Add Memory Entry ─────────────────────────────────────────────────────────
it("shows + Add button in KV section", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByRole("button", { name: /\+ add/i })).toBeTruthy();
});
it("opens add form when + Add is clicked", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
expect(screen.getByLabelText("Memory key")).toBeTruthy();
expect(screen.getByLabelText("Memory value (JSON or plain text)")).toBeTruthy();
});
it("requires key to be non-empty", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/key is required/i)).toBeTruthy();
});
it("POSTs correct payload when adding a string value", async () => {
mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "my_key");
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "plain text value");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({ key: "my_key", value: "plain text value" }),
);
});
it("POSTs parsed JSON when value is valid JSON", async () => {
mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "config");
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, '{"debug": true}');
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({ key: "config", value: { debug: true } }),
);
});
it("POSTs with ttl_seconds when TTL is provided", async () => {
mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "temp_data");
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "value");
typeIn(screen.getByLabelText("TTL in seconds (optional)") as HTMLElement, "3600");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({ key: "temp_data", value: "value", ttl_seconds: 3600 }),
);
});
it("shows error when add fails", async () => {
mockGet.mockResolvedValue([]);
mockPost.mockRejectedValue(new Error("add failed"));
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "key");
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "val");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/add failed/i)).toBeTruthy();
});
it("closes add form and refreshes after successful add", async () => {
mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "new_key");
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "new_val");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
});
it("closes add form when Cancel is clicked", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
await flush();
expect(screen.getByLabelText("Memory key")).toBeTruthy();
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
});
});
// ── Delete Memory Entry ─────────────────────────────────────────────────────
it("calls DEL when Delete is clicked", async () => {
mockGet.mockResolvedValue([entry()]);
mockDel.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/user_context",
);
});
it("removes entry from list after successful delete", async () => {
mockGet.mockResolvedValue([entry()]);
mockDel.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText("user_context")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
expect(screen.queryByText("user_context")).toBeFalsy();
});
it("collapses entry if it was expanded when deleted", async () => {
mockGet.mockResolvedValue([entry()]);
mockDel.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
// Expand the entry
fireEvent.click(screen.getByText("user_context"));
await flush();
expect(screen.getByText("▼")).toBeTruthy();
// Delete
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
expect(screen.queryByText("user_context")).toBeFalsy();
});
it("shows error when delete fails", async () => {
mockGet.mockResolvedValue([entry()]);
mockDel.mockRejectedValue(new Error("delete failed"));
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
expect(screen.getByText(/delete failed/i)).toBeTruthy();
});
// ── Edit Memory Entry ────────────────────────────────────────────────────────
it("shows edit form when Edit is clicked", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
});
it("pre-fills edit form with existing value", async () => {
mockGet.mockResolvedValue([entry({ value: { name: "Alice" } })]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
const textarea = screen.getByLabelText(/edit value for user_context/i);
expect((textarea as HTMLTextAreaElement).value).toContain("Alice");
});
it("POSTs updated value when Save is clicked", async () => {
mockGet.mockResolvedValue([entry()]);
mockPost.mockResolvedValue({});
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "updated_value");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText(/edit value for user_context/i)).not.toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({ key: "user_context", value: "updated_value", if_match_version: 3 }),
);
});
it("shows retry hint on 409 conflict during edit", async () => {
mockGet.mockResolvedValue([entry()]);
mockPost.mockRejectedValue(new Error("409 Conflict: if_match_version mismatch"));
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "new_val");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
});
it("shows generic error when edit save fails", async () => {
mockGet.mockResolvedValue([entry()]);
mockPost.mockRejectedValue(new Error("save failed"));
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "x");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/save failed/i)).toBeTruthy();
});
it("closes edit form when Cancel is clicked", async () => {
mockGet.mockResolvedValue([entry()]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
fireEvent.click(screen.getByText("user_context"));
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText(/edit value for/i)).not.toBeTruthy();
});
});
// ── Refresh ────────────────────────────────────────────────────────────────
it("Refresh button calls loadMemory", async () => {
mockGet.mockResolvedValue([]);
render(<MemoryTab workspaceId="ws-1" />);
await flush();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
});
});
+9
View File
@@ -0,0 +1,9 @@
declare module "@novnc/novnc" {
export default class RFB extends EventTarget {
scaleViewport: boolean;
resizeSession: boolean;
focusOnClick: boolean;
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
disconnect(): void;
}
}
+1 -3
View File
@@ -28,9 +28,7 @@
{"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"},
{"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"},
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+5 -2
View File
@@ -50,13 +50,16 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
echo ""
echo "--- Create Workspaces ---"
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Pass the same value the deleted DefaultModel("claude-code") returned.
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create root workspace" "-" "$ROOT"
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create child workspace" "-" "$CHILD"
+5 -1
View File
@@ -92,8 +92,12 @@ for _wid in $PRIOR; do
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
# default that the deleted DefaultModel("") would have returned.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1}')
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
echo "Created workspace $WSID"
+51 -22
View File
@@ -24,14 +24,12 @@
#
# Only PROVISIONING differs from staging:
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
# token + each workspace's MCP bearer from create response or an admin
# token-mint fallback.
# token + each workspace's MCP bearer from the POST /workspaces
# create response.
# - local: POST /workspaces directly against the local stack
# (BASE, default http://localhost:8080), MCP bearer minted via
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
# deterministic, gated by MOLECULE_ENV != production). Same model
# every other local E2E (test_priority_runtimes_e2e.sh,
# test_api.sh) already uses; no new credential/provision flow.
# (BASE, default http://localhost:8080), MCP bearer consumed inline
# from the create response (auth_token field). Same model every
# other local E2E uses; no new credential/provision flow.
#
# By default the local backend creates external-mode workspace rows and
# drives the literal MCP path directly. That keeps the local peer-visibility
@@ -81,6 +79,17 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
log() { echo "[$(date +%H:%M:%S)] $*"; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
extract_auth_token() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null
}
CREATED_WSIDS=()
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
ADMIN_AUTH=()
@@ -131,17 +140,6 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
exit 1
fi
# admin/test-token is the local MCP-bearer mint path; it 404s in
# production. If it is off, this gate cannot drive the literal call.
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
exit 1
fi
fi
ok " local stack healthy"
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
@@ -241,9 +239,31 @@ else
fi
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
# required, no platform default). External runtimes are exempt by the
# Create-handler gate — for them the URL is the contract — but we still
# pass model="external:custom" defensively in case a downstream consumer
# of the create body asserts presence.
_model_for_runtime() {
case "$1" in
claude-code) echo "sonnet" ;;
codex) echo "gpt-5.5" ;;
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
minimax) echo "minimax/MiniMax-M2.7" ;;
external) echo "external:custom" ;;
*) echo "anthropic:claude-opus-4-7" ;;
esac
}
PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
# capture in the runtime loop below + reserved for follow-up steps
# that need parent-side auth. Current downstream steps reach the parent
# via admin token, so the variable isn't dereferenced — SC2034.
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
if [ -z "$PARENT_ID" ]; then
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
exit 1
@@ -259,6 +279,8 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
WS_IDS_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
VERDICT_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
WS_TOKENS_MAP=""
_map_set() { # _map_set <mapvarname> <key> <value>
local __m="$1" __k="$2" __v="$3" __cur
eval "__cur=\$$__m"
@@ -291,14 +313,21 @@ for rt in $PV_RUNTIMES; do
CREATE_RUNTIME="$rt"
CREATE_EXTRA=""
fi
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
WTOK=$(echo "$R" | extract_auth_token)
if [ -z "$WID" ]; then
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
exit 1
fi
if [ -z "$WTOK" ]; then
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
exit 1
fi
_map_set WS_IDS_MAP "$rt" "$WID"
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
CREATED_WSIDS+=("$WID")
ALL_WS_IDS="$ALL_WS_IDS $WID"
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
@@ -356,10 +385,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
echo ""
for rt in $ONLINE_RUNTIMES; do
wid="$(_map_get WS_IDS_MAP "$rt")"
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
if [ -z "$WTOK" ]; then
echo "--- $rt (ws=$wid) ---"
echo "$rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
echo "$rt: workspace create did not return an auth_token — cannot drive the literal call"
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
REGRESSED=1
echo ""
+6 -31
View File
@@ -40,10 +40,10 @@
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token drives its
# MCP call. External-like runtimes may return the token in POST
# /workspaces; managed container runtimes usually require the admin token
# mint fallback below.
# tenant workspace creation; each workspace's OWN auth_token is consumed
# inline from the POST /workspaces 201 response to drive its MCP call.
# No dev-only admin token-mint routes are used in this E2E
# (feedback_no_dev_only_routes_in_e2e).
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
@@ -265,44 +265,19 @@ log " PARENT_ID=$PARENT_ID"
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
declare -A WS_IDS WS_TOKENS
ALL_WS_IDS="$PARENT_ID"
TOKEN_ERRORS=0
TOKEN_ERROR_SUMMARY=""
for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# External-like runtimes may return connection.auth_token on create.
# Managed container runtimes usually return only id/status here, then
# receive their bearer through registry/bootstrap; for this literal MCP
# driver we mint through the production-safe admin token route below.
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
TOKEN_DIAG=""
if [ -z "$WTOK" ]; then
TTOK_FILE=$(mktemp)
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
rm -f "$TTOK_FILE"
fi
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
WS_IDS[$rt]="$WID"
if [ -z "$WTOK" ]; then
TOKEN_ERRORS=$((TOKEN_ERRORS + 1))
TOKEN_ERROR_SUMMARY="${TOKEN_ERROR_SUMMARY}
[$rt] workspace did not return or mint an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
log " $rt$WID (token acquisition failed; continuing to classify other runtimes)"
continue
fi
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
if [ "$TOKEN_ERRORS" -gt 0 ]; then
fail "token acquisition failed for $TOKEN_ERRORS runtime(s):$TOKEN_ERROR_SUMMARY"
fi
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
exit 0
+4 -2
View File
@@ -188,8 +188,9 @@ import json, os
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then
fail "create claude-code workspace" "$resp"
@@ -380,8 +381,9 @@ import json, os
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
CREATED_WSIDS+=("$wsid")
@@ -30,7 +30,7 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
t.Errorf("org id header: got %q", got)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app"}`)
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app","DISPLAY_SESSION_SIGNING_SECRET":"display-secret"}`)
}))
defer srv.Close()
@@ -45,6 +45,9 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
if got := os.Getenv("MOLECULE_CP_SHARED_SECRET"); got != "new-secret" {
t.Errorf("SHARED_SECRET: want new-secret, got %q", got)
}
if got := os.Getenv("DISPLAY_SESSION_SIGNING_SECRET"); got != "display-secret" {
t.Errorf("DISPLAY_SESSION_SIGNING_SECRET: want display-secret, got %q", got)
}
}
// TestRefreshEnvFromCP_CPUnreachableDoesNotFailBoot: network errors must
+3 -2
View File
@@ -393,8 +393,9 @@ func main() {
// See molecule-core#7.
bindHost := resolveBindHost()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Start server in goroutine
+14 -5
View File
@@ -23,19 +23,28 @@ CANVAS_PID=$!
# Memory v2 sidecar (built-in postgres plugin). See Dockerfile entrypoint
# comment for rationale.
#
# Spawn-gating: only start the sidecar when the operator has indicated
# they want it (MEMORY_V2_CUTOVER=true OR MEMORY_PLUGIN_URL set).
# Without that signal, the sidecar adds zero value and risks aborting
# tenant boot via the 30s health gate when the tenant Postgres lacks
# Spawn-gating: start the sidecar when MEMORY_PLUGIN_URL is set.
# Without it, the sidecar adds zero value and risks aborting tenant
# boot via the 30s health gate when the tenant Postgres lacks
# pgvector. Caught on staging redeploy 2026-05-05:
# pq: extension "vector" is not available
#
# Defaults (when sidecar IS spawned): MEMORY_PLUGIN_DATABASE_URL
# falls back to the tenant's DATABASE_URL.
#
# MEMORY_V2_CUTOVER is deprecated as of #1747 — the workspace-server
# binary no longer reads it (v2 is unconditional now; the legacy SQL
# fallback in mcp_tools.go is gone). The entrypoint still accepts it
# as a synonym for "operator wants the sidecar" so old CP user-data
# templates keep working through the rollout. When CP user-data drops
# the var, this branch can go.
MEMORY_PLUGIN_PID=""
memory_plugin_wanted=""
if [ "$MEMORY_V2_CUTOVER" = "true" ] || [ -n "$MEMORY_PLUGIN_URL" ]; then
if [ -n "$MEMORY_PLUGIN_URL" ]; then
memory_plugin_wanted=1
elif [ "$MEMORY_V2_CUTOVER" = "true" ]; then
memory_plugin_wanted=1
echo "memory-plugin: ⚠️ MEMORY_V2_CUTOVER is deprecated (#1747) — set MEMORY_PLUGIN_URL instead. Spawning sidecar on the implied default this boot." >&2
fi
if [ -z "$MEMORY_PLUGIN_DISABLE" ] && [ -n "$memory_plugin_wanted" ] && [ -n "$DATABASE_URL" ]; then
: "${MEMORY_PLUGIN_DATABASE_URL:=$DATABASE_URL}"
@@ -116,8 +116,11 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
// would propagate that token into logs and error responses (#659).
return fmt.Errorf("discord: HTTP request failed")
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("discord: read response body: %w", readErr)
}
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+4 -1
View File
@@ -119,7 +119,10 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("lark: read response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+27 -4
View File
@@ -156,6 +156,9 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: pause-pollers rows.Err: %v", err)
}
m.mu.Unlock()
if len(pausedIDs) == 0 {
@@ -204,8 +207,16 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload scan error: %v", err)
continue
}
_ = json.Unmarshal(configJSON, &ch.Config)
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
}
// #319: decrypt at the boundary between DB (ciphertext) and the
// in-memory config adapters consume. A decrypt failure logs and
// skips the channel — downstream getUpdates would fail anyway
@@ -216,6 +227,9 @@ func (m *Manager) Reload(ctx context.Context) {
}
desired[ch.ID] = ch
}
if err := rows.Err(); err != nil {
log.Printf("Channels: reload rows.Err: %v", err)
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -473,6 +487,9 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: broadcast rows.Err: %v", err)
}
}
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
@@ -555,8 +572,14 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
if err != nil {
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
}
json.Unmarshal(configJSON, &ch.Config)
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
}
}
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
// methods downstream read them as plaintext strings.
if err := DecryptSensitiveFields(ch.Config); err != nil {
+13 -3
View File
@@ -171,8 +171,11 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("slack: read response body: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
@@ -208,9 +211,13 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
}
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
@@ -524,8 +531,11 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
if err != nil {
return nil, err
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("slack: read history response: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
+21 -10
View File
@@ -111,12 +111,13 @@ const maxProxyResponseBody = 10 << 20
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
//
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
// 3. Transport.ResponseHeaderTimeout — 5min default. From request-body-end
// to response-headers-start. Configurable via
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// turns and Codex scheduled tasks (big context + internal delegate_task
// round-trips routinely exceed the old 60s/180s ceilings). Body streaming
// after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
//
@@ -131,7 +132,7 @@ var a2aClient = &http.Client{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 5*time.Minute),
TLSHandshakeTimeout: 10 * time.Second,
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
// fan-in is bounded by the platform's broadcaster fan-out, not by
@@ -228,7 +229,7 @@ func (e *proxyA2AError) Error() string {
// cron scheduler and other internal callers that need to send A2A messages
// to workspaces programmatically (not from an HTTP handler).
func (h *WorkspaceHandler) ProxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity, false)
if proxyErr != nil {
return status, resp, proxyErr
}
@@ -307,13 +308,21 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) {
// The bind is strict: the token must match `callerID`, not
// `workspaceID` (the target). A compromised token from workspace A
// must never authenticate calls from A pretending to be B.
if callerID != "" && callerID != workspaceID {
if err := validateCallerToken(ctx, c, callerID); err != nil {
//
// Post-RFC#637: canvas users now send X-Workspace-ID (their identity
// workspace). validateCallerToken detects canvas/admin auth on a
// tokenless workspace and returns isCanvasUser=true so the proxy can
// bypass CanCommunicate (human users sit outside the hierarchy).
isCanvasUser := false
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
var err error
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
if err != nil {
return // response already written with 401
}
}
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true)
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true, isCanvasUser)
if proxyErr != nil {
for k, v := range proxyErr.Headers {
c.Header(k, v)
@@ -352,11 +361,13 @@ func (h *WorkspaceHandler) checkWorkspaceBudget(ctx context.Context, workspaceID
return nil
}
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, *proxyA2AError) {
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool, isCanvasUser bool) (int, []byte, *proxyA2AError) {
// Access control: workspace-to-workspace requests must pass CanCommunicate check.
// Canvas requests (callerID == "") and system callers (webhook:*, system:*, test:*)
// are trusted. Self-calls (callerID == workspaceID) are always allowed.
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
// Post-RFC#637: canvas-user identity workspaces also bypass CanCommunicate
// because human users sit outside the org hierarchy.
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ProxyA2A: access denied %s → %s", callerID, workspaceID)
return 0, nil, &proxyA2AError{
@@ -5,17 +5,21 @@ package handlers
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/orgtoken"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
@@ -28,8 +32,8 @@ type proxyDispatchBuildError struct{ err error }
func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }
// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
// runs the reactive container-health check, and (when `logActivity` is true)
// schedules a detached LogActivity goroutine for the failed attempt.
// runs the reactive container-health check, and records the outcome. Busy
// targets that are successfully queued are logged as queued, not failed.
func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int, logActivity bool) (int, []byte, *proxyA2AError) {
// Build-time failure (couldn't even create the http.Request) — return
// a 500 without the reactive-health / busy-retry paths.
@@ -45,10 +49,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
containerDead := h.maybeMarkContainerDead(ctx, workspaceID)
if logActivity {
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
}
if containerDead {
if logActivity {
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
}
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{"error": "workspace agent unreachable — container restart triggered", "restarting": true},
@@ -108,6 +112,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
ctx, workspaceID, callerID, PriorityTask, body, a2aMethod, idempotencyKey, expiresAt,
); qerr == nil {
log.Printf("ProxyA2A: target %s busy — enqueued as %s (depth=%d)", workspaceID, qid, depth)
if logActivity {
h.logA2ABusyQueued(ctx, workspaceID, callerID, body, a2aMethod, durationMs)
}
respBody, _ := json.Marshal(gin.H{
"queued": true,
"queue_id": qid,
@@ -121,6 +128,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// make delegation silently disappear.
log.Printf("ProxyA2A: enqueue for %s failed (%v) — falling back to 503", workspaceID, qerr)
}
if logActivity {
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
}
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
@@ -131,6 +141,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
},
}
}
if logActivity {
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
}
return 0, nil, &proxyA2AError{
Status: http.StatusBadGateway,
Response: gin.H{"error": "failed to reach workspace agent"},
@@ -311,6 +324,33 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
})
}
// logA2ABusyQueued records that a push attempt reached a live but busy
// workspace and was durably queued for heartbeat drain.
func (h *WorkspaceHandler) logA2ABusyQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, durationMs int) {
var wsName string
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName == "" {
wsName = workspaceID
}
summary := a2aMethod + " → " + wsName + " (queued: target busy)"
parent := ctx
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
SourceID: nilIfEmpty(callerID),
TargetID: &workspaceID,
Method: &a2aMethod,
Summary: &summary,
RequestBody: json.RawMessage(body),
DurationMs: &durationMs,
Status: "ok",
})
})
}
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
// 2xx/3xx responses) broadcasts an A2A_RESPONSE event so the frontend can
// receive the reply without polling.
@@ -383,31 +423,53 @@ func nilIfEmpty(s string) *string {
// (their next /registry/register will mint their first token, after
// which this branch never fires again for them).
//
// Post-RFC#637 addition: when the tokenless workspace is accompanied by
// canvas or admin auth (same-origin request, admin bearer, or org-level
// token), the caller is identified as a canvas-user identity rather than
// a legacy peer agent. The returned isCanvasUser flag lets the A2A proxy
// bypass CanCommunicate for human users, who sit outside the workspace
// hierarchy.
//
// On auth failure this writes the 401 via c and returns an error so the
// handler aborts without running the proxy.
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
if err != nil {
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (isCanvasUser bool, err error) {
hasLive, dbErr := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
if dbErr != nil {
// Fail-open here matches the heartbeat path — A2A caller auth is
// defense-in-depth on top of access-control hierarchy, not the
// sole gate on the secret material. A DB hiccup shouldn't take
// the whole A2A path down.
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, err)
return nil
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, dbErr)
return false, nil
}
if !hasLive {
return nil // legacy / pre-upgrade caller
// Tokenless workspace — could be legacy/pre-upgrade caller or
// canvas-user identity. Distinguish by request auth signals.
if middleware.IsSameOriginCanvas(c) {
return true, nil
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok != "" {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret != "" && subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
return true, nil
}
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
return true, nil
}
}
return false, nil // legacy / pre-upgrade caller
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
return errInvalidCallerToken
return false, errInvalidCallerToken
}
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
return err
return false, err
}
return nil
return false, nil
}
// errInvalidCallerToken is a sentinel for validateCallerToken's "missing
@@ -1112,9 +1112,13 @@ func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
if err := validateCallerToken(context.Background(), c, "ws-legacy"); err != nil {
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-legacy")
if err != nil {
t.Errorf("legacy caller should grandfather through; got %v", err)
}
if isCanvasUser {
t.Errorf("legacy caller should NOT be identified as canvas user")
}
if w.Code != 200 {
// gin default before c.JSON is 200; we want no error response written
if w.Body.Len() != 0 {
@@ -1136,10 +1140,13 @@ func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
// No Authorization header set
err := validateCallerToken(context.Background(), c, "ws-authed")
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
if err == nil {
t.Fatal("expected error for missing token")
}
if isCanvasUser {
t.Errorf("authed workspace with missing token should NOT be canvas user")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
@@ -1164,9 +1171,13 @@ func TestValidateCallerToken_InvalidToken(t *testing.T) {
req.Header.Set("Authorization", "Bearer wrong")
c.Request = req
if err := validateCallerToken(context.Background(), c, "ws-authed"); err == nil {
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
if err == nil {
t.Fatal("expected error for bad token")
}
if isCanvasUser {
t.Errorf("authed workspace with bad token should NOT be canvas user")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
@@ -1192,9 +1203,13 @@ func TestValidateCallerToken_ValidToken(t *testing.T) {
req.Header.Set("Authorization", "Bearer goodtok")
c.Request = req
if err := validateCallerToken(context.Background(), c, "ws-authed"); err != nil {
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
if err != nil {
t.Errorf("valid token should pass; got %v", err)
}
if isCanvasUser {
t.Errorf("authed workspace with valid token should NOT be canvas user")
}
}
func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
@@ -1216,14 +1231,86 @@ func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
req.Header.Set("Authorization", "Bearer tok-for-A")
c.Request = req
if err := validateCallerToken(context.Background(), c, "ws-b-attacker"); err == nil {
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-b-attacker")
if err == nil {
t.Fatal("token from A must not authenticate caller B")
}
if isCanvasUser {
t.Errorf("cross-workspace token replay should NOT be identified as canvas user")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestValidateCallerToken_CanvasUser_AdminToken(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
// Tokenless workspace
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("ws-canvas-admin").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
t.Setenv("ADMIN_TOKEN", "admin-secret-42")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
req.Header.Set("Authorization", "Bearer admin-secret-42")
c.Request = req
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-admin")
if err != nil {
t.Errorf("admin token should identify canvas user; got error: %v", err)
}
if !isCanvasUser {
t.Errorf("admin token bearer should be identified as canvas user")
}
if w.Code != 200 || w.Body.Len() != 0 {
t.Errorf("admin token path should not write a response body; got %d: %s", w.Code, w.Body.String())
}
}
func TestValidateCallerToken_CanvasUser_OrgToken(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
// Tokenless workspace
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("ws-canvas-org").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
// orgtoken.Validate lookup
mock.ExpectQuery(`SELECT id, prefix, org_id FROM org_api_tokens WHERE token_hash = .* AND revoked_at IS NULL`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).AddRow("orgtok-1", "pref1234", "org-1"))
mock.ExpectExec(`UPDATE org_api_tokens SET last_used_at`).
WithArgs("orgtok-1").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
req.Header.Set("Authorization", "Bearer org-token-plaintext-xyz")
c.Request = req
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-org")
if err != nil {
t.Errorf("org token should identify canvas user; got error: %v", err)
}
if !isCanvasUser {
t.Errorf("org token bearer should be identified as canvas user")
}
if w.Code != 200 || w.Body.Len() != 0 {
t.Errorf("org token path should not write a response body; got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// --- Direct unit tests for normalizeA2APayload (extracted from proxyA2ARequest) ---
func TestNormalizeA2APayload_InvalidJSON(t *testing.T) {
@@ -1779,6 +1866,58 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
}
}
func TestHandleA2ADispatchError_BusyEnqueueLogsQueuedNotFailure(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
mock.ExpectQuery(`INSERT INTO a2a_queue`).
WithArgs("ws-busy", nil, PriorityTask, "{}", "message/send", nil, nil).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11111111-1111-1111-1111-111111111111"))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue`).
WithArgs("ws-busy").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
WithArgs("ws-busy").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Busy Target"))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(
"ws-busy",
"a2a_receive",
nil,
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
nil,
nil,
sqlmock.AnyArg(),
"ok",
nil,
).
WillReturnResult(sqlmock.NewResult(0, 1))
status, body, perr := handler.handleA2ADispatchError(
context.Background(), "ws-busy", "", []byte("{}"), "message/send",
context.DeadlineExceeded, 180002, true,
)
if perr != nil {
t.Fatalf("expected busy enqueue success, got proxy error: %+v", perr)
}
if status != http.StatusAccepted {
t.Fatalf("got status %d, want 202", status)
}
if !bytes.Contains(body, []byte(`"queued":true`)) {
t.Fatalf("expected queued response body, got %s", string(body))
}
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations; busy enqueue must log status=ok, not error: %v", err)
}
}
func TestHandleA2ADispatchError_BuildError(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -2354,7 +2493,7 @@ func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
const defaultTimeout = 180 * time.Second
const defaultTimeout = 5 * time.Minute
// Default (unset env) — a2aClient was initialised at package load time.
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
@@ -2378,7 +2517,7 @@ func TestA2AClientResponseHeaderTimeout(t *testing.T) {
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
// Simulate what envx.Duration does with an invalid value.
var fallback = 180 * time.Second
var fallback = 5 * time.Minute
override := fallback
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -333,7 +333,7 @@ func (h *WorkspaceHandler) DrainQueueForWorkspace(ctx context.Context, workspace
}
// logActivity=false: the original EnqueueA2A callsite already logged
// the dispatch attempt; re-logging here would double-count events.
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false)
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false, false)
// 202 Accepted = the dispatch was itself queued again (target still busy).
// That's not a failure — the queued item just stays queued naturally on
@@ -5,7 +5,6 @@ import (
"database/sql"
"log"
"net/http"
"os"
"strings"
"time"
@@ -16,19 +15,12 @@ import (
"github.com/gin-gonic/gin"
)
// envMemoryV2Cutover gates whether admin export/import routes through
// the v2 plugin (PR-8 / RFC #2728). When unset, the legacy direct-DB
// path runs unchanged so operators who haven't enabled the plugin
// keep working.
const envMemoryV2Cutover = "MEMORY_V2_CUTOVER"
// AdminMemoriesHandler provides bulk export/import of agent memories for
// backup and restore across Docker rebuilds (issue #1051).
//
// PR-8 (RFC #2728): when wired with the v2 plugin via WithMemoryV2 AND
// MEMORY_V2_CUTOVER is true, export reads from the plugin's namespaces
// and import writes through the plugin. Both paths preserve the
// SAFE-T1201 redaction shipped in F1084 + F1085.
// Issue #1733: the v2 plugin is the only supported backend. Export
// reads from the plugin's namespaces; import writes through the plugin.
// Both paths preserve the SAFE-T1201 redaction shipped in F1084 + F1085.
type AdminMemoriesHandler struct {
plugin adminMemoriesPlugin
resolver adminMemoriesResolver
@@ -69,12 +61,12 @@ func (h *AdminMemoriesHandler) withMemoryV2APIs(plugin adminMemoriesPlugin, reso
return h
}
// cutoverActive reports whether the export/import path should route
// through the v2 plugin.
func (h *AdminMemoriesHandler) cutoverActive() bool {
if os.Getenv(envMemoryV2Cutover) != "true" {
return false
}
// memoryV2Wired reports whether the v2 plugin + resolver are attached.
// Issue #1733: v2 is now the only path; this replaces the prior
// cutoverActive() gate (which also checked MEMORY_V2_CUTOVER=true) —
// the env-flag double-check is gone since there's no legacy fallback
// to choose against.
func (h *AdminMemoriesHandler) memoryV2Wired() bool {
return h.plugin != nil && h.resolver != nil
}
@@ -97,48 +89,19 @@ type memoryExportEntry struct {
// before returning so that any credentials stored before SAFE-T1201 (#838)
// was applied do not leak out via the admin export endpoint.
//
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
// plugin is wired, reads from the plugin instead of agent_memories.
// Issue #1733: reads exclusively from the v2 plugin. The legacy direct
// agent_memories scan is gone — operators without a configured plugin
// get a 503 explaining the required setup.
func (h *AdminMemoriesHandler) Export(c *gin.Context) {
ctx := c.Request.Context()
if h.cutoverActive() {
h.exportViaPlugin(c, ctx)
if !h.memoryV2Wired() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
})
return
}
rows, err := db.DB.QueryContext(ctx, `
SELECT am.id, am.content, am.scope, am.namespace, am.created_at,
w.name AS workspace_name
FROM agent_memories am
JOIN workspaces w ON am.workspace_id = w.id
ORDER BY am.created_at
`)
if err != nil {
log.Printf("admin/memories/export: query error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export query failed"})
return
}
defer rows.Close()
memories := make([]memoryExportEntry, 0)
for rows.Next() {
var m memoryExportEntry
if err := rows.Scan(&m.ID, &m.Content, &m.Scope, &m.Namespace, &m.CreatedAt, &m.WorkspaceName); err != nil {
log.Printf("admin/memories/export: scan error: %v", err)
continue
}
// F1084 / #1131: redact secrets before returning so pre-SAFE-T1201
// memories (stored before redactSecrets was mandatory) don't leak.
redacted, _ := redactSecrets(m.WorkspaceName, m.Content)
m.Content = redacted
memories = append(memories, m)
}
if err := rows.Err(); err != nil {
log.Printf("admin/memories/export: rows error: %v", err)
}
c.JSON(http.StatusOK, memories)
h.exportViaPlugin(c, ctx)
}
// memoryImportEntry is the JSON shape accepted on import. Matches export format.
@@ -160,8 +123,9 @@ type memoryImportEntry struct {
// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201
// parity with the commit_memory MCP bridge path).
//
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
// plugin is wired, writes through the plugin instead of agent_memories.
// Issue #1733: writes exclusively through the v2 plugin. The legacy
// direct agent_memories insert path is gone — operators without a
// configured plugin get a 503 explaining the required setup.
func (h *AdminMemoriesHandler) Import(c *gin.Context) {
ctx := c.Request.Context()
@@ -171,85 +135,13 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
return
}
if h.cutoverActive() {
h.importViaPlugin(c, ctx, entries)
if !h.memoryV2Wired() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
})
return
}
imported := 0
skipped := 0
errors := 0
for _, entry := range entries {
// 1. Resolve workspace by name
var workspaceID string
err := db.DB.QueryRowContext(ctx,
`SELECT id FROM workspaces WHERE name = $1 LIMIT 1`,
entry.WorkspaceName,
).Scan(&workspaceID)
if err != nil {
log.Printf("admin/memories/import: workspace %q not found, skipping", entry.WorkspaceName)
skipped++
continue
}
// F1085 / #1132: scrub credential patterns before persistence so that
// imported memories with secrets don't bypass SAFE-T1201 (#838).
// Must run BEFORE the dedup check so the redacted content is what
// gets stored — otherwise re-importing the same backup would produce
// a duplicate with different (original, unredacted) content.
content, _ := redactSecrets(workspaceID, entry.Content)
// 2. Check for duplicate (same workspace + content + scope) using
// the redacted content so that two backups with the same original
// secret (same placeholder output) are treated as duplicates.
var exists bool
err = db.DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
workspaceID, content, entry.Scope,
).Scan(&exists)
if err != nil {
log.Printf("admin/memories/import: duplicate check error for workspace %q: %v", entry.WorkspaceName, err)
errors++
continue
}
if exists {
skipped++
continue
}
// 3. Insert the memory, preserving original created_at if provided
namespace := entry.Namespace
if namespace == "" {
namespace = "general"
}
if entry.CreatedAt != "" {
_, err = db.DB.ExecContext(ctx,
`INSERT INTO agent_memories (workspace_id, content, scope, namespace, created_at) VALUES ($1, $2, $3, $4, $5)`,
workspaceID, content, entry.Scope, namespace, entry.CreatedAt,
)
} else {
_, err = db.DB.ExecContext(ctx,
`INSERT INTO agent_memories (workspace_id, content, scope, namespace) VALUES ($1, $2, $3, $4)`,
workspaceID, content, entry.Scope, namespace,
)
}
if err != nil {
log.Printf("admin/memories/import: insert error for workspace %q: %v", entry.WorkspaceName, err)
errors++
continue
}
imported++
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"errors": errors,
"total": len(entries),
})
h.importViaPlugin(c, ctx, entries)
}
// exportViaPlugin reads memories from the v2 plugin and emits them in
@@ -101,26 +101,24 @@ func installMockDB(t *testing.T) sqlmock.Sqlmock {
return mock
}
// --- cutoverActive ---
// --- memoryV2Wired ---
func TestCutoverActive(t *testing.T) {
func TestMemoryV2Wired(t *testing.T) {
cases := []struct {
name string
envVal string
plugin adminMemoriesPlugin
resolver adminMemoriesResolver
want bool
}{
{"env unset", "", &stubAdminPlugin{}, adminRootResolver(), false},
{"env true but unwired", "true", nil, nil, false},
{"env false", "false", &stubAdminPlugin{}, adminRootResolver(), false},
{"env true wired", "true", &stubAdminPlugin{}, adminRootResolver(), true},
{"both nil", nil, nil, false},
{"plugin only", &stubAdminPlugin{}, nil, false},
{"resolver only", nil, adminRootResolver(), false},
{"both wired", &stubAdminPlugin{}, adminRootResolver(), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(envMemoryV2Cutover, tc.envVal)
h := &AdminMemoriesHandler{plugin: tc.plugin, resolver: tc.resolver}
if got := h.cutoverActive(); got != tc.want {
if got := h.memoryV2Wired(); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
@@ -147,7 +145,6 @@ func TestWithMemoryV2APIs_AttachesDeps(t *testing.T) {
// --- Export via plugin ---
func TestExport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
@@ -191,7 +188,6 @@ func TestExport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
}
func TestExport_DeduplicatesByMemoryID(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
// Two workspaces, both will see the same team-shared memory.
@@ -222,7 +218,6 @@ func TestExport_DeduplicatesByMemoryID(t *testing.T) {
}
func TestExport_SkipsWorkspaceWhenResolverFails(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
@@ -244,7 +239,6 @@ func TestExport_SkipsWorkspaceWhenResolverFails(t *testing.T) {
}
func TestExport_SkipsWorkspaceWhenPluginSearchFails(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
@@ -268,7 +262,6 @@ func TestExport_SkipsWorkspaceWhenPluginSearchFails(t *testing.T) {
}
func TestExport_WorkspacesQueryFails(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnError(errors.New("db dead"))
@@ -287,7 +280,6 @@ func TestExport_WorkspacesQueryFails(t *testing.T) {
}
func TestExport_EmptyReadable(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
@@ -309,7 +301,6 @@ func TestExport_EmptyReadable(t *testing.T) {
}
func TestExport_RedactsSecretsInPluginPath(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
@@ -337,7 +328,6 @@ func TestExport_RedactsSecretsInPluginPath(t *testing.T) {
// --- Import via plugin ---
func TestImport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WithArgs("alpha").
@@ -368,7 +358,6 @@ func TestImport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
}
func TestImport_SkipsUnknownWorkspace(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WithArgs("ghost").
@@ -395,7 +384,6 @@ func TestImport_SkipsUnknownWorkspace(t *testing.T) {
}
func TestImport_PluginUpsertNamespaceError(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
@@ -425,7 +413,6 @@ func TestImport_PluginUpsertNamespaceError(t *testing.T) {
}
func TestImport_PluginCommitError(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
@@ -455,7 +442,6 @@ func TestImport_PluginCommitError(t *testing.T) {
}
func TestImport_RedactsBeforePluginSeesContent(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
@@ -482,7 +468,6 @@ func TestImport_RedactsBeforePluginSeesContent(t *testing.T) {
}
func TestImport_SkipsUnknownScope(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
@@ -508,7 +493,6 @@ func TestImport_SkipsUnknownScope(t *testing.T) {
}
func TestImport_SkipsWhenResolverErrors(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("SELECT id::text FROM workspaces").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
@@ -545,7 +529,6 @@ func TestImport_SkipsWhenResolverErrors(t *testing.T) {
// + org:root-1. (Children's workspace:<id> namespaces must be
// included or admin export silently drops their private memories.)
func TestExport_BatchesPluginCallsByRoot(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
@@ -605,7 +588,6 @@ func (r perWorkspaceResolver) WritableNamespaces(_ context.Context, ws string) (
// workspace:rootID + team:rootID + org:rootID — every child workspace's
// private memories were silently dropped from admin export.
func TestExport_IncludesEveryMembersPrivateNamespace(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "true")
mock := installMockDB(t)
mock.ExpectQuery("WITH RECURSIVE chain").
@@ -775,25 +757,43 @@ func TestSkipImport_ErrorMessage(t *testing.T) {
}
}
// --- Confirm legacy paths still work when env is unset ---
// --- 503 when plugin is not wired (issue #1733) ---
//
// The legacy SQL-backed Export/Import path was removed; both endpoints
// now respond 503 with a clear hint when v2 isn't configured.
func TestExport_LegacyPathWhenCutoverInactive(t *testing.T) {
t.Setenv(envMemoryV2Cutover, "")
mock := installMockDB(t)
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace").
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}))
h := NewAdminMemoriesHandler().withMemoryV2APIs(&stubAdminPlugin{}, adminRootResolver())
func TestExport_503WhenPluginNotWired(t *testing.T) {
installMockDB(t)
h := NewAdminMemoriesHandler() // no WithMemoryV2 → plugin nil
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/admin/memories/export", nil)
h.Export(c)
if w.Code != http.StatusOK {
t.Errorf("code = %d body=%s", w.Code, w.Body.String())
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
if !strings.Contains(w.Body.String(), "MEMORY_PLUGIN_URL") {
t.Errorf("body must hint at MEMORY_PLUGIN_URL: %s", w.Body.String())
}
}
func TestImport_503WhenPluginNotWired(t *testing.T) {
installMockDB(t)
h := NewAdminMemoriesHandler()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/admin/memories/import",
bytes.NewBufferString(`[]`))
c.Request.Header.Set("Content-Type", "application/json")
h.Import(c)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "MEMORY_PLUGIN_URL") {
t.Errorf("body must hint at MEMORY_PLUGIN_URL: %s", w.Body.String())
}
}
@@ -2,220 +2,46 @@ package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// newAdminMemoriesHandler is a test helper that returns an AdminMemoriesHandler.
func newAdminMemoriesHandler() *AdminMemoriesHandler {
return NewAdminMemoriesHandler()
}
// adminPost builds a POST /admin/memories/import request.
func adminPost(t *testing.T, h *AdminMemoriesHandler, body interface{}) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/admin/memories/import", bytes.NewReader(b))
c.Request.Header.Set("Content-Type", "application/json")
h.Import(c)
return w
}
// adminGet builds a GET /admin/memories/export request.
func adminGet(t *testing.T, h *AdminMemoriesHandler) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/admin/memories/export", nil)
h.Export(c)
return w
}
// ─────────────────────────────────────────────────────────────────────────────
// Export tests
// ─────────────────────────────────────────────────────────────────────────────
func TestAdminMemories_Export_Success(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
now := time.Now().UTC().Truncate(time.Second)
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}).
AddRow("mem-1", "hello world", "LOCAL", "ws-1", now, "my-workspace").
AddRow("mem-2", "another fact", "TEAM", "ws-1", now, "my-workspace")
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
WillReturnRows(rows)
w := adminGet(t, h)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var memories []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(memories) != 2 {
t.Errorf("expected 2 memories, got %d", len(memories))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestAdminMemories_Export_Empty(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"})
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
WillReturnRows(rows)
w := adminGet(t, h)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var memories []interface{}
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(memories) != 0 {
t.Errorf("expected 0 memories, got %d", len(memories))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestAdminMemories_Export_QueryError(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
WillReturnError(sql.ErrConnDone)
w := adminGet(t, h)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestAdminMemories_Export_RedactsSecrets(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Content with a secret pattern. Export must call redactSecrets and return
// the redacted form, not the raw credential.
secretContent := "Remember to use OPENAI_API_KEY=sk-1234567890abcdefgh for the model"
redacted, _ := redactSecrets("my-workspace", secretContent)
now := time.Now().UTC().Truncate(time.Second)
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}).
AddRow("mem-secret", secretContent, "LOCAL", "my-workspace", now, "my-workspace")
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
WillReturnRows(rows)
w := adminGet(t, h)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var memories []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(memories) != 1 {
t.Fatalf("expected 1 memory, got %d", len(memories))
}
// The exported content must be the REDACTED version, not the raw secret.
if content, ok := memories[0]["content"].(string); ok {
if content == secretContent {
t.Errorf("Export returned raw secret %q — F1084 regression: redactSecrets not called", secretContent)
}
if content != redacted {
t.Errorf("Export content = %q, want redacted %q", content, redacted)
}
// Confirm the redacted version doesn't contain the raw key fragment.
if len(content) > 10 && content == "OPENAI_API_KEY=[REDACTED:" {
t.Errorf("redaction appears incomplete: %q", content)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Import tests
// ─────────────────────────────────────────────────────────────────────────────
func TestAdminMemories_Import_Success(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Workspace lookup returns one row.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Duplicate check returns false.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
// Insert succeeds. Handler uses 4-arg INSERT when created_at is absent.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL", "general").
WillReturnResult(sqlmock.NewResult(1, 1))
w := adminPost(t, h, []map[string]interface{}{
{
"content": "important fact",
"scope": "LOCAL",
"workspace_name": "my-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["imported"].(float64) != 1 {
t.Errorf("imported = %v, want 1", resp["imported"])
}
if resp["skipped"].(float64) != 0 {
t.Errorf("skipped = %v, want 0", resp["skipped"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// Issue #1733: every legacy SQL-path test in this file was removed when
// the v1 fallback was deleted from AdminMemoriesHandler. The v2-plugin
// coverage (the only path now) lives in admin_memories_cutover_test.go:
//
// - TestExport_RoutesThroughPluginWhenCutoverActive
// - TestExport_DeduplicatesByMemoryID
// - TestExport_SkipsWorkspaceWhenResolverFails
// - TestExport_SkipsWorkspaceWhenPluginSearchFails
// - TestExport_WorkspacesQueryFails
// - TestExport_EmptyReadable
// - TestExport_RedactsSecretsInPluginPath
// - TestExport_BatchesPluginCallsByRoot
// - TestExport_IncludesEveryMembersPrivateNamespace
// - TestImport_RoutesThroughPluginWhenCutoverActive
// - TestImport_SkipsUnknownWorkspace
// - TestImport_PluginUpsertNamespaceError
// - TestImport_PluginCommitError
// - TestImport_RedactsBeforePluginSeesContent
// - TestImport_SkipsUnknownScope
// - TestImport_SkipsWhenResolverErrors
// - TestExport_503WhenPluginNotWired (new in A1)
// - TestImport_503WhenPluginNotWired (new in A1)
//
// Only the JSON-envelope rejection test stays here because it runs
// before the plugin gate.
// TestAdminMemories_Import_InvalidJSON verifies that a malformed
// payload is rejected with HTTP 400 before any plugin or DB call is
// attempted. This guards the request-decode path independent of the
// memory backend choice.
func TestAdminMemories_Import_InvalidJSON(t *testing.T) {
_ = setupTestDB(t)
h := newAdminMemoriesHandler()
h := NewAdminMemoriesHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -227,175 +53,3 @@ func TestAdminMemories_Import_InvalidJSON(t *testing.T) {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminMemories_Import_WorkspaceNotFound_SkipsEntry(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Workspace lookup returns no rows.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("ghost-workspace").
WillReturnError(sql.ErrNoRows)
w := adminPost(t, h, []map[string]interface{}{
{
"content": "some fact",
"scope": "LOCAL",
"workspace_name": "ghost-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["skipped"].(float64) != 1 {
t.Errorf("skipped = %v, want 1 (workspace not found)", resp["skipped"])
}
if resp["imported"].(float64) != 0 {
t.Errorf("imported = %v, want 0", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestAdminMemories_Import_DuplicateSkipped(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Workspace lookup succeeds.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Duplicate check returns true → entry is skipped.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
w := adminPost(t, h, []map[string]interface{}{
{
"content": "already stored fact",
"scope": "LOCAL",
"workspace_name": "my-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["skipped"].(float64) != 1 {
t.Errorf("skipped = %v, want 1 (duplicate)", resp["skipped"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAdminMemories_Import_RedactsSecretsBeforeDedup verifies F1085 (#1132):
// redactSecrets is called BEFORE the deduplication check so that two backups
// with the same original secret each get the same placeholder and dedup works.
// The DB dedup query must receive the REDACTED content, not the raw credential.
func TestAdminMemories_Import_RedactsSecretsBeforeDedup(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
rawContent := "the key is OPENAI_API_KEY=sk-1234567890abcdefgh"
redacted, changed := redactSecrets("my-workspace", rawContent)
if !changed {
t.Fatalf("precondition: redactSecrets must change the test content")
}
// Workspace lookup.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Dedup check — the sqlmock must be set up for the REDACTED content,
// because Import calls redactSecrets before running the dedup query.
// If redactSecrets is not called, the mock would match on rawContent instead.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", redacted, "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
// Insert — receives the redacted content (not raw). Handler uses the
// 4-arg INSERT when created_at is absent from the payload.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs("ws-uuid-1", redacted, "LOCAL", "general").
WillReturnResult(sqlmock.NewResult(1, 1))
w := adminPost(t, h, []map[string]interface{}{
{
"content": rawContent,
"scope": "LOCAL",
"workspace_name": "my-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["imported"].(float64) != 1 {
t.Errorf("imported = %v, want 1", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v (F1085 regression: redactSecrets not called before dedup)", err)
}
}
func TestAdminMemories_Import_PreservesCreatedAt(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
origTime := "2026-01-15T10:30:00Z"
// Workspace lookup.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Dedup check.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
// Insert with created_at — must use the 5-arg INSERT.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL", "general", origTime).
WillReturnResult(sqlmock.NewResult(1, 1))
w := adminPost(t, h, []map[string]interface{}{
{
"content": "a fact",
"scope": "LOCAL",
"workspace_name": "my-workspace",
"created_at": origTime,
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["imported"].(float64) != 1 {
t.Errorf("imported = %v, want 1", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -39,6 +39,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -58,6 +59,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
@@ -75,6 +77,7 @@ func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -44,8 +44,7 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
// Update both when a new template is added.
var AllRuntimes = []string{
"claude-code", "langgraph", "autogen",
"hermes", "openclaw",
"claude-code", "codex", "hermes", "openclaw",
}
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
@@ -104,6 +104,9 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels: list rows.Err: %v", err)
}
c.JSON(http.StatusOK, result)
}
@@ -514,6 +517,9 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: telegram webhook rows.Err: %v", err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -389,7 +389,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
})
log.Printf("Delegation %s: step=proxying_a2a_request", delegationID)
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true, false)
log.Printf("Delegation %s: step=proxy_done status=%d bodyLen=%d err=%v", delegationID, status, len(respBody), proxyErr)
// When proxyA2ARequest returns an error but we have a non-empty response body
@@ -418,7 +418,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
case <-ctx.Done():
// outer timeout hit before retry window elapsed
case <-time.After(delegationRetryDelay):
status, respBody, proxyErr = h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
status, respBody, proxyErr = h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true, false)
}
}
@@ -393,6 +393,9 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
result = append(result, peer)
}
if err := rows.Err(); err != nil {
log.Printf("queryPeerMaps rows.Err: %v", err)
}
return result, nil
}
@@ -49,6 +49,9 @@ func (h *EventsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -87,5 +90,8 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("WorkspaceEvents list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -159,7 +159,8 @@ func generateAppInstallationToken() (string, time.Time, error) {
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
req.Header.Set("Authorization", "Bearer "+signed)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, err
}
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -80,7 +80,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","canvas":{"x":10,"y":20}}`
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -777,6 +777,103 @@ func TestCreate_FieldValidation_Returns400(t *testing.T) {
}
}
// TestCreate_ModelRequired_Returns422 pins the CTO 2026-05-22 SSOT
// directive (feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is required user input; the platform must not supply a default,
// the runtime must not fall back. Empirical trigger: Code Reviewer
// 5ba15d7e was created with `{"name":..., "runtime":"codex", ...}` (no
// model). The legacy DefaultModel fallback returned "anthropic:claude-opus-4-7"
// and codex adapter wedged forever — `picks provider='anthropic' but it
// is not in the providers registry`. The gate at the Create boundary
// turns that silent stuck-workspace failure into an immediate 422 the
// caller can react to.
//
// Three shapes covered:
// 1. bare name (no template, no runtime, no model) — formerly defaulted
// to langgraph + anthropic; now 422 because model is unspecified.
// 2. explicit runtime, no model — the Code Reviewer repro shape.
// 3. explicit runtime+template path, but template (when missing on
// disk or unreadable) would leave model empty — exercised here by
// pointing at a non-existent template under /tmp/configs.
func TestCreate_ModelRequired_Returns422(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")
cases := []struct{ label, body string }{
{"bare_name_no_runtime_no_model", `{"name":"x"}`},
{"explicit_codex_no_model", `{"name":"Code Reviewer","role":"code reviewer","runtime":"codex","tier":4,"max_concurrent_tasks":1}`},
{"explicit_hermes_no_model", `{"name":"researcher","runtime":"hermes"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Create(%s): want 422 MODEL_REQUIRED, got %d: %s", tc.label, w.Code, w.Body.String())
return
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("Create(%s): want body containing code=MODEL_REQUIRED, got %s", tc.label, w.Body.String())
}
})
}
}
// TestCreate_ExternalRuntime_NoModel_OK pins the external-runtime
// exemption from the MODEL_REQUIRED gate. External workspaces
// intentionally do not spawn a Docker container or run an adapter;
// they delegate to a registered URL (workspace_provision.go:497-498:
// "external is a first-class runtime that intentionally does NOT
// spawn a Docker container"). The model field has no meaning for
// them — the URL is the contract, and the gate would 422 every
// legitimate "register my agent at https://..." flow.
//
// Both spellings count as external:
// 1. payload.External == true (the canonical flag, e.g. with any runtime)
// 2. payload.Runtime == "external" (legacy shape some E2E scripts still use)
//
// The isExternalLikeRuntime() helper catches both "external" and any
// future external-like runtime alias.
func TestCreate_ExternalRuntime_NoModel_OK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// External=true with explicit runtime — the test_api.sh / Echo Agent shape.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Echo Agent","tier":1,"runtime":"external","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("external workspace without model: want 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
func TestUpdate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -386,7 +386,13 @@ func TestWorkspaceCreate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
// Note: model is now required at the Create boundary (CTO 2026-05-22
// SSOT directive — see feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422). This test happens to take
// the bare-defaults path (no template, no runtime → langgraph), so
// the body must declare an explicit model. Using a langgraph-compatible
// id; the test doesn't exercise model semantics beyond presence.
body := `{"name":"Test Agent","model":"anthropic:claude-opus-4-7","canvas":{"x":100,"y":200}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
+115 -168
View File
@@ -16,6 +16,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
"github.com/gin-gonic/gin"
)
@@ -485,65 +486,45 @@ func TestMCPHandler_ListPeers_ReturnsSiblings(t *testing.T) {
// tools/call — commit_memory
// ─────────────────────────────────────────────────────────────────────────────
func TestMCPHandler_CommitMemory_LocalScope_Success(t *testing.T) {
// Issue #1733: the legacy SQL success-path tests for commit_memory and
// recall_memory have been removed — the v2 plugin is the only backend
// and its success paths are covered by:
// - TestToolCommitMemory_RoutesThroughV2WhenWired (legacy-shim test)
// - TestToolRecallMemory_RoutesThroughV2WhenWired (legacy-shim test)
// - Every test in mcp_tools_memory_v2_test.go
// The unwired-path tests live in mcp_tools_memory_legacy_shim_test.go
// (TestToolCommitMemory_ErrorsWhenV2Unwired and its recall sibling).
//
// The two scope-blocked tests below remain because they validate the
// OFFSEC-001 JSON-RPC scrub layer (mcp.go dispatchRPC), which is
// orthogonal to the memory backend. After A1 the underlying error
// shifts from "GLOBAL scope is not permitted" to "memory plugin is
// not configured" — but the client-visible message stays "tool call
// failed", which is what the scrub assertion actually proves.
// TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError verifies the
// OFFSEC-001 / #259 scrub contract on the commit_memory tool: the GLOBAL
// scope block at scopeToWritableNamespace produces an internal error
// containing the tokens "GLOBAL", "scope", "permitted", "bridge",
// "LOCAL", "TEAM" — every one of those MUST be scrubbed to the constant
// "tool call failed" + code -32000 before reaching the JSON-RPC wire.
//
// Issue #1747 review fixed the test setup: the handler is now wired
// with a v2 plugin + resolver stub so the request actually reaches
// the GLOBAL-block path in commitMemoryLegacyShim →
// scopeToWritableNamespace. Without that wiring, the handler errors
// earlier in `memoryV2Available()` with "memory plugin is not
// configured", and the leaked-tokens assertion below becomes
// vacuously true — passes even if the entire scrub layer in
// mcp.go:dispatchRPC is deleted. The wired path is the only one
// that actually pins the OFFSEC-001 contract.
func TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
h, mock := newMCPHandler(t)
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs(sqlmock.AnyArg(), "ws-1", "important fact", "LOCAL", "ws-1").
WillReturnResult(sqlmock.NewResult(1, 1))
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": map[string]interface{}{
"name": "commit_memory",
"arguments": map[string]interface{}{
"content": "important fact",
"scope": "LOCAL",
},
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp mcpResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error != nil {
t.Fatalf("unexpected error: %+v", resp.Error)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError verifies
// two contracts at once on the GLOBAL-scope-blocked path:
//
// 1. C3 invariant (commit_memory with scope=GLOBAL aborts on the MCP bridge
// before touching the DB), AND
// 2. OFFSEC-001 / #259 scrub contract (commit 7d1a189f): the JSON-RPC error
// returned to the client is a CONSTANT — code=-32000, message="tool call
// failed" — with the production-internal err.Error() text logged
// server-side, never reflected back to the caller.
//
// Prior to this rename the test asserted that the client-visible message
// CONTAINED the substring "GLOBAL", which was the human-readable internal
// error from toolCommitMemory. mc#664 Class 2 flipped that assertion the
// right way around: now the test FAILS if the scrub regresses (i.e. if the
// internal string is ever reflected back to the wire), and PASSES iff the
// scrubbed constant reaches the client.
//
// Coupling note: the constant string "tool call failed" and the code -32000
// are the same values asserted by
// TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage — both are
// the OFFSEC-001 contract for the dispatch-failure branch in mcp.go (the
// third err.Error() leak that 7d1a189f scrubbed). If those constants ever
// change, both tests must move together.
func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *testing.T) {
h, mock := newMCPHandler(t)
// No DB expectations — handler must abort before touching the DB (C3).
// Wire v2 stubs so toolCommitMemory → commitMemoryLegacyShim
// actually runs (without v2, it short-circuits with the
// "plugin not configured" error that doesn't contain the
// leaked-token strings we're asserting on).
h.withMemoryV2APIs(&stubMemoryPlugin{}, rootNamespaceResolver())
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
@@ -585,7 +566,7 @@ func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
}
// (3) OFFSEC-001 negative assertions — the internal err.Error() text
// from toolCommitMemory ("GLOBAL scope is not permitted via the MCP
// from scopeToWritableNamespace ("GLOBAL scope is not permitted via the MCP
// bridge — use LOCAL or TEAM") must NOT appear in the client-visible
// message. Each token below is a distinct substring of that internal
// string; if ANY leaks through, the scrub in mcp.go dispatchRPC has
@@ -610,41 +591,43 @@ func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
}
}
// TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert verifies
// the SAFE-T1201 (#838) fix on the MCP bridge path. PR #881 closed the HTTP
// handler but missed this one — an agent tool-call carrying plain-text
// credentials must have them scrubbed before the INSERT reaches the DB.
//
// The test asserts via the sqlmock `WithArgs` matcher that the content column
// binds the REDACTED form, not the raw input. sqlmock verifies the exact arg
// values, so a regression (removing the redactSecrets call) would fail with
// "argument mismatch" rather than silently persisting the secret.
func TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert(t *testing.T) {
h, mock := newMCPHandler(t)
// Issue #1733: the legacy SQL-path redaction tests for commit_memory
// (SecretInContent_IsRedactedBeforeInsert, CleanContent_PassesThrough)
// have been removed. The v2 plugin path performs the same redaction
// (mcp_tools_memory_v2.go:122 + :242); its coverage lives in
// mcp_tools_memory_v2_test.go.
// TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin verifies that
// the LEGACY MCP tool name `commit_memory` (the one most agents
// actually call — `commit_memory_v2` is the underlying handler the
// shim delegates to) still redacts secret-shaped content before the
// payload reaches the v2 plugin. The deleted SQL-path version of this
// test pinned the same contract against `agent_memories` INSERT
// arguments; #1747 review (finding N6) noted the legacy-name path
// had no direct equivalent post-A1. This test fills that gap by
// capturing the MemoryWrite the stub plugin receives.
func TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin(t *testing.T) {
h, _ := newMCPHandler(t)
var captured contract.MemoryWrite
plugin := &stubMemoryPlugin{
commitFn: func(_ context.Context, _ string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
captured = body
return &contract.MemoryWriteResponse{ID: "mem-x", Namespace: "workspace:root-1"}, nil
},
}
h.withMemoryV2APIs(plugin, rootNamespaceResolver())
// Content with three distinct secret patterns covered by redactSecrets:
// - env-var assignment (ANTHROPIC_API_KEY=)
// - Bearer token
// - sk-… prefixed key
rawContent := "key=ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx auth=Bearer ghp_yyyyyyyyyyyyy note=sk-proj-zzzzzzzzzzzzzzzzzzzz"
// Derive what redactSecrets will produce so the sqlmock arg match is
// exact. This keeps the test brittle-on-purpose: if redactSecrets's
// output shape changes, this test must be re-derived, which surfaces
// the change during review.
expected, changed := redactSecrets("ws-1", rawContent)
wantRedacted, changed := redactSecrets("root-1", rawContent)
if !changed {
t.Fatalf("precondition failed — redactSecrets must change the test content; got unchanged %q", expected)
t.Fatalf("precondition failed — redactSecrets must change the test content; got %q", wantRedacted)
}
if bytes.Contains([]byte(expected), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", expected)
if bytes.Contains([]byte(wantRedacted), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", wantRedacted)
}
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs(sqlmock.AnyArg(), "ws-1", expected, "LOCAL", "ws-1").
WillReturnResult(sqlmock.NewResult(1, 1))
w := mcpPost(t, h, "ws-1", map[string]interface{}{
w := mcpPost(t, h, "root-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
@@ -656,52 +639,32 @@ func TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert(t *testi
},
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp mcpResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error != nil {
t.Fatalf("unexpected JSON-RPC error: %+v", resp.Error)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock mismatch — content was NOT redacted before insert: %v", err)
// The plugin must have seen the REDACTED content, not the raw
// secret. If this trips, redaction in the legacy-shim → v2 path
// has regressed and credentials are flowing through to the
// plugin's memory_records table.
if captured.Content == "" {
t.Fatal("plugin.CommitMemory was not called — the shim short-circuited before reaching v2")
}
}
// TestMCPHandler_CommitMemory_CleanContent_PassesThrough confirms that the
// redactor is a no-op on content with no credentials — a regression where
// redactSecrets corrupted benign content would be a user-visible bug.
func TestMCPHandler_CommitMemory_CleanContent_PassesThrough(t *testing.T) {
h, mock := newMCPHandler(t)
cleanContent := "the quick brown fox jumps over the lazy dog — no secrets here"
// Bind the exact string — no wildcards — so that any transformation
// (whitespace, case, truncation) would fail the arg match.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs(sqlmock.AnyArg(), "ws-1", cleanContent, "TEAM", "ws-1").
WillReturnResult(sqlmock.NewResult(1, 1))
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 100,
"method": "tools/call",
"params": map[string]interface{}{
"name": "commit_memory",
"arguments": map[string]interface{}{
"content": cleanContent,
"scope": "TEAM",
},
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
if captured.Content == rawContent {
t.Errorf("legacy commit_memory leaked raw secret to plugin: %q", captured.Content)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("clean content should pass through unchanged: %v", err)
if captured.Content != wantRedacted {
t.Errorf("captured.Content = %q, want redacted %q", captured.Content, wantRedacted)
}
if bytes.Contains([]byte(captured.Content), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
t.Errorf("captured.Content still contains raw API key fragment: %s", captured.Content)
}
}
@@ -709,14 +672,17 @@ func TestMCPHandler_CommitMemory_CleanContent_PassesThrough(t *testing.T) {
// tools/call — recall_memory
// ─────────────────────────────────────────────────────────────────────────────
// TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError verifies
// C3 (GLOBAL scope blocked on MCP bridge) is enforced and that the OFFSEC-001
// scrub contract applies: the client-visible error.message is the constant
// "tool call failed", NOT the descriptive internal reason. The internal reason
// ("GLOBAL scope is not permitted via the MCP bridge") is logged server-side
// but must never reach the wire.
func TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError(t *testing.T) {
// TestMCPHandler_RecallMemory_GlobalScope_ScrubsInternalError mirrors the
// commit_memory scrub test on the recall_memory path. Same #1747 review
// fix applied: wire v2 stubs so the request reaches the GLOBAL-block
// path in scopeToReadableNamespaces (which produces the same "GLOBAL
// scope is not permitted via the MCP bridge" internal error that the
// leaked-tokens loop below tests for). Without v2 stubs the handler
// short-circuits on `memoryV2Available()` and the leaked-tokens loop
// becomes vacuously true.
func TestMCPHandler_RecallMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
h, mock := newMCPHandler(t)
h.withMemoryV2APIs(&stubMemoryPlugin{}, rootNamespaceResolver())
// No DB expectations — handler must abort before touching the DB.
w := mcpPost(t, h, "ws-1", map[string]interface{}{
@@ -770,42 +736,11 @@ func TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
}
}
func TestMCPHandler_RecallMemory_LocalScope_Empty(t *testing.T) {
h, mock := newMCPHandler(t)
mock.ExpectQuery("SELECT id, content, scope, created_at").
WithArgs("ws-1", "").
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "created_at"}))
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": map[string]interface{}{
"name": "recall_memory",
"arguments": map[string]interface{}{
"query": "",
"scope": "LOCAL",
},
},
})
var resp mcpResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error != nil {
t.Fatalf("unexpected error: %+v", resp.Error)
}
result, _ := resp.Result.(map[string]interface{})
content, _ := result["content"].([]interface{})
item, _ := content[0].(map[string]interface{})
text, _ := item["text"].(string)
if text != "No memories found." {
t.Errorf("expected 'No memories found.', got %q", text)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// Issue #1733: TestMCPHandler_RecallMemory_LocalScope_Empty removed —
// it asserted on the legacy SQL SELECT path. The v2 empty-result
// rendering is covered by TestToolRecallMemory_RoutesThroughV2WhenWired
// (mcp_tools_memory_legacy_shim_test.go) which uses a stub plugin that
// returns an empty SearchResponse.
// ─────────────────────────────────────────────────────────────────────────────
// tools/call — send_message_to_user
@@ -1141,6 +1076,8 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
}
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://10.0.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
@@ -1148,6 +1085,8 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
}
func TestIsSafeURL_Blocks172Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://172.16.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
@@ -1155,6 +1094,8 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
}
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://192.168.1.100/agent")
if err == nil {
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
@@ -1178,6 +1119,8 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1187,6 +1130,8 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1196,6 +1141,8 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
+25 -120
View File
@@ -95,14 +95,18 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
} else {
rows, err := h.database.QueryContext(ctx,
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
}
@@ -112,7 +116,9 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
}
}
}
@@ -122,7 +128,9 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`,
parentID.String)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
}
}
}
@@ -355,127 +363,24 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
}
func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired
// (MEMORY_PLUGIN_URL set), translate legacy scope→namespace and
// delegate. Otherwise fall through to the legacy DB path so
// operators who haven't enabled the plugin yet keep working.
if h.memoryV2Available() == nil {
return h.commitMemoryLegacyShim(ctx, workspaceID, args)
// Issue #1733 — v2 memory plugin is now the only path. The legacy
// SQL fallback on `agent_memories` is gone; an unconfigured plugin
// returns a clear error to the agent rather than silently writing
// into a stale table no one reads.
if err := h.memoryV2Available(); err != nil {
return "", err
}
content, _ := args["content"].(string)
scope, _ := args["scope"].(string)
if content == "" {
return "", fmt.Errorf("content is required")
}
if scope == "" {
scope = "LOCAL"
}
// C3: GLOBAL scope is blocked on the MCP bridge.
if scope == "GLOBAL" {
return "", fmt.Errorf("GLOBAL scope is not permitted via the MCP bridge — use LOCAL or TEAM")
}
if scope != "LOCAL" && scope != "TEAM" {
return "", fmt.Errorf("scope must be LOCAL or TEAM")
}
memoryID := uuid.New().String()
// SAFE-T1201 (#838): scrub known credential patterns before persistence so
// plain-text API keys pulled in via tool responses can't land in the
// memories table (and leak into shared TEAM scope). Reuses redactSecrets
// already shipped for the HTTP path in PR #881 — this was the MCP-bridge
// sibling the original fix missed. Runs on every write regardless of scope.
content, _ = redactSecrets(workspaceID, content)
_, err := h.database.ExecContext(ctx, `
INSERT INTO agent_memories (id, workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4, $5)
`, memoryID, workspaceID, content, scope, workspaceID)
if err != nil {
log.Printf("MCPHandler.commit_memory workspace=%s: %v", workspaceID, err)
return "", fmt.Errorf("failed to save memory")
}
return fmt.Sprintf(`{"id":%q,"scope":%q}`, memoryID, scope), nil
return h.commitMemoryLegacyShim(ctx, workspaceID, args)
}
func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired,
// route through it. Otherwise fall through to legacy DB path.
if h.memoryV2Available() == nil {
return h.recallMemoryLegacyShim(ctx, workspaceID, args)
// Issue #1733 — v2 memory plugin is now the only path. Same shape
// as toolCommitMemory: an unconfigured plugin is an error, not a
// quiet read from a frozen v1 table.
if err := h.memoryV2Available(); err != nil {
return "", err
}
query, _ := args["query"].(string)
scope, _ := args["scope"].(string)
// C3: GLOBAL scope is blocked on the MCP bridge.
if scope == "GLOBAL" {
return "", fmt.Errorf("GLOBAL scope is not permitted via the MCP bridge — use LOCAL, TEAM, or empty")
}
var rows *sql.Rows
var err error
switch scope {
case "LOCAL":
rows, err = h.database.QueryContext(ctx, `
SELECT id, content, scope, created_at
FROM agent_memories
WHERE workspace_id = $1 AND scope = 'LOCAL'
AND ($2 = '' OR content ILIKE '%' || $2 || '%')
ORDER BY created_at DESC LIMIT 50
`, workspaceID, query)
case "TEAM":
// Team scope: parent + all siblings.
rows, err = h.database.QueryContext(ctx, `
SELECT m.id, m.content, m.scope, m.created_at
FROM agent_memories m
JOIN workspaces w ON w.id = m.workspace_id
WHERE m.scope = 'TEAM'
AND w.status != 'removed'
AND (w.id = $1 OR w.parent_id = (SELECT parent_id FROM workspaces WHERE id = $1 AND parent_id IS NOT NULL))
AND ($2 = '' OR m.content ILIKE '%' || $2 || '%')
ORDER BY m.created_at DESC LIMIT 50
`, workspaceID, query)
default:
// Empty scope → LOCAL only for the MCP bridge (GLOBAL excluded per C3).
rows, err = h.database.QueryContext(ctx, `
SELECT id, content, scope, created_at
FROM agent_memories
WHERE workspace_id = $1 AND scope IN ('LOCAL', 'TEAM')
AND ($2 = '' OR content ILIKE '%' || $2 || '%')
ORDER BY created_at DESC LIMIT 50
`, workspaceID, query)
}
if err != nil {
return "", fmt.Errorf("memory search failed: %w", err)
}
defer rows.Close()
type memEntry struct {
ID string `json:"id"`
Content string `json:"content"`
Scope string `json:"scope"`
CreatedAt string `json:"created_at"`
}
var results []memEntry
for rows.Next() {
var e memEntry
if err := rows.Scan(&e.ID, &e.Content, &e.Scope, &e.CreatedAt); err != nil {
continue
}
results = append(results, e)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("memory scan error: %w", err)
}
if len(results) == 0 {
return "No memories found.", nil
}
b, _ := json.MarshalIndent(results, "", " ")
return string(b), nil
return h.recallMemoryLegacyShim(ctx, workspaceID, args)
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,14 +2,13 @@ package handlers
// mcp_tools_memory_legacy_shim.go — translates legacy commit_memory /
// recall_memory calls (scope-based) into the v2 plugin path
// (namespace-based) when the v2 plugin is wired.
// (namespace-based).
//
// Behavior:
// - If h.memv2 is wired (MEMORY_PLUGIN_URL set + plugin reachable),
// legacy tools translate scope→namespace and delegate to v2.
// - If h.memv2 is NOT wired, legacy tools fall through to the
// original DB-backed path in mcp_tools.go (zero behavior change
// for operators who haven't enabled the plugin yet).
// Issue #1733: v2 is now the only memory backend. Callers in
// mcp_tools.go MUST verify h.memv2 is wired before invoking these
// helpers (toolCommitMemory / toolRecallMemory both check
// memoryV2Available and short-circuit with an error when not wired).
// The previous "fall through to direct SQL" branch is gone.
//
// Translation:
// commit: LOCAL → workspace:<self>
@@ -512,41 +512,38 @@ func TestToolRecallMemory_RoutesThroughV2WhenWired(t *testing.T) {
}
}
func TestToolCommitMemory_FallsThroughToLegacyWhenV2Unwired(t *testing.T) {
// V2 NOT wired (no withMemoryV2APIs call). Should hit the legacy
// SQL path and write to agent_memories directly.
db, mock, _ := sqlmock.New()
// Issue #1733: v2 is the only path; commit/recall return a clear error
// (not a silent SQL fallback) when MEMORY_PLUGIN_URL is unset.
func TestToolCommitMemory_ErrorsWhenV2Unwired(t *testing.T) {
db, _, _ := sqlmock.New()
defer db.Close()
mock.ExpectExec("INSERT INTO agent_memories").
WillReturnResult(sqlmock.NewResult(0, 1))
h := &MCPHandler{database: db}
h := &MCPHandler{database: db} // no withMemoryV2APIs → memv2 nil
_, err := h.toolCommitMemory(context.Background(), "root-1", map[string]interface{}{
"content": "x",
"scope": "LOCAL",
})
if err != nil {
t.Fatalf("err: %v", err)
if err == nil {
t.Fatal("expected error when v2 unwired, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
}
}
func TestToolRecallMemory_FallsThroughToLegacyWhenV2Unwired(t *testing.T) {
db, mock, _ := sqlmock.New()
func TestToolRecallMemory_ErrorsWhenV2Unwired(t *testing.T) {
db, _, _ := sqlmock.New()
defer db.Close()
mock.ExpectQuery("SELECT id, content, scope, created_at").
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "created_at"}))
h := &MCPHandler{database: db}
_, err := h.toolRecallMemory(context.Background(), "root-1", map[string]interface{}{
"scope": "LOCAL",
})
if err != nil {
t.Fatalf("err: %v", err)
if err == nil {
t.Fatal("expected error when v2 unwired, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
}
}
@@ -54,6 +54,11 @@ func (h *MemoryHandler) List(c *gin.Context) {
entry.Value = json.RawMessage(value)
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Memory list iteration error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
return
}
c.JSON(http.StatusOK, entries)
}
@@ -4,6 +4,7 @@ import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -74,6 +75,34 @@ func TestMemoryList_DBError(t *testing.T) {
}
}
// TestMemoryList_RowsErr_Returns500 verifies that a rows.Err() set during
// iteration causes the handler to return 500 rather than partial results.
func TestMemoryList_RowsErr_Returns500(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewMemoryHandler()
cols := []string{"key", "value", "version", "expires_at", "updated_at"}
mock.ExpectQuery("SELECT key, value, version, expires_at, updated_at").
WithArgs("ws-rowerr").
WillReturnRows(sqlmock.NewRows(cols).
AddRow("ok-key", []byte(`"val"`), int64(1), nil, time.Now()).
RowError(0, errors.New("storage engine fault")))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-rowerr"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-rowerr/memory", nil)
handler.List(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("rows.Err() must yield 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== GET /workspaces/:id/memory/:key (Get) ====================
func TestMemoryGet_Success(t *testing.T) {
@@ -69,10 +69,15 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
model = defaults.Model
}
if model == "" {
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Consolidated from a duplicate of the same
// branch in workspace_provision.go.
model = models.DefaultModel(runtime)
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED. The org-import template MUST declare a
// model — either per-workspace (`ws.Model`) or via the org
// defaults block (`defaults.Model`). If neither is present
// the template is malformed and the import must fail-closed
// rather than silently provisioning a workspace with a
// runtime-incompatible default (the prior `anthropic:claude-opus-4-7`
// fallback wedged every codex workspace at adapter init).
return fmt.Errorf("org import: workspace %q has no model and the org defaults block does not provide one (runtime=%s) — model is a required field per the workspace-creation contract; either set `model:` on the workspace or under `defaults:`", ws.Name, runtime)
}
tier := ws.Tier
if tier == 0 {
@@ -712,6 +712,8 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
// ------------------------------------------------------------
func TestValidateAgentURL(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
cases := []struct {
name string
url string
@@ -133,24 +133,30 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
// message bus.
keySet := map[string]struct{}{}
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
}
}
if rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
}
}
for k := range keySet {
d.EnvKeys = append(d.EnvKeys, k)
@@ -22,7 +22,7 @@ func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
err := os.WriteFile(path, []byte(`{
"workspace_templates": [
{"name": "claude-code-default", "repo": "org/t-cc"},
{"name": "langgraph", "repo": "org/t-lg"},
{"name": "codex", "repo": "org/t-codex"},
{"name": "hermes", "repo": "org/t-hermes"}
]
}`), 0600)
@@ -33,7 +33,7 @@ func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
if err != nil {
t.Fatalf("load: %v", err)
}
want := []string{"claude-code", "langgraph", "hermes", "external", "kimi", "kimi-cli"}
want := []string{"claude-code", "codex", "hermes", "external", "kimi", "kimi-cli"}
for _, w := range want {
if _, ok := got[w]; !ok {
t.Errorf("want runtime %q in set, missing. got=%v", w, keys(got))
@@ -53,7 +53,7 @@ func TestLoadRuntimesFromManifest_ExternalAlwaysInjected(t *testing.T) {
// in the set, because it's the BYO-compute meta-runtime.
dir := t.TempDir()
path := filepath.Join(dir, "manifest.json")
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"langgraph","repo":"org/t"}]}`), 0600)
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"codex","repo":"org/t"}]}`), 0600)
got, err := loadRuntimesFromManifest(path)
if err != nil {
@@ -97,11 +97,16 @@ func TestRealManifestParses(t *testing.T) {
t.Fatalf("real manifest load: %v", err)
}
// Core runtimes we always expect to ship.
for _, must := range []string{"langgraph", "hermes", "claude-code", "external", "kimi", "kimi-cli"} {
for _, must := range []string{"codex", "hermes", "openclaw", "claude-code", "external", "kimi", "kimi-cli"} {
if _, ok := got[must]; !ok {
t.Errorf("real manifest missing runtime %q — got=%v", must, keys(got))
}
}
for _, removed := range []string{"autogen", "langgraph"} {
if _, ok := got[removed]; ok {
t.Errorf("real manifest should not expose unsupported runtime %q — got=%v", removed, keys(got))
}
}
}
func keys(m map[string]struct{}) []string {
@@ -417,6 +417,9 @@ func (h *ScheduleHandler) History(c *gin.Context) {
e.Request = json.RawMessage(reqStr)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
log.Printf("ScheduleHistory: rows error: %v", err)
}
c.JSON(http.StatusOK, entries)
}
@@ -467,14 +470,19 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
// Validate the caller's own bearer token (Phase 30.5 contract).
// Skip for system callers and self-calls, same as the A2A proxy.
// Post-RFC#637: canvas users may read schedule health too.
isCanvasUser := false
if !isSystemCaller(callerID) && callerID != workspaceID {
if err := validateCallerToken(ctx, c, callerID); err != nil {
var err error
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
if err != nil {
return // response already written with 401
}
}
// CanCommunicate gate — only peers in the org hierarchy may read health.
if callerID != workspaceID && !isSystemCaller(callerID) {
// Canvas users (human operators) bypass this gate.
if callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ScheduleHealth: access denied %s → %s", callerID, workspaceID)
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
@@ -95,6 +95,7 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -152,6 +153,7 @@ func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -203,6 +203,11 @@ func (h *TemplatesHandler) List(c *gin.Context) {
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
return
}
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
if _, ok := knownRuntimes[runtime]; !ok {
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
return
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
model := raw.Model
+5 -1
View File
@@ -51,6 +51,10 @@ func (h *TracesHandler) List(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
return
}
c.Data(resp.StatusCode, "application/json", body)
}
@@ -107,6 +107,7 @@ func (h *WebhookHandler) GitHub(c *gin.Context) {
forwardBody,
"webhook:github",
true,
false,
)
if proxyErr != nil {
c.JSON(proxyErr.Status, proxyErr.Response)
@@ -321,6 +321,51 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Runtime = "langgraph"
}
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED user input for SPAWNED-runtime workspaces. The
// platform must not provide a default; the runtime must not fall back.
// The decision belongs to the user (or to the agent acting on the
// user's behalf), never to the platform.
//
// Empirical trigger: Code Reviewer 5ba15d7e was created with
// `{"name":"Code Reviewer","role":"...","runtime":"codex",...}` (no
// model). The legacy `DefaultModel(runtime)` fallback in
// provisionWorkspace returned `"anthropic:claude-opus-4-7"`. Codex
// adapter only supports openai-* providers — it wedged forever with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. PATCH /workspaces/:id
// explicitly disallows updating model (the comment literally reads
// `model not patchable`), so the only recovery path was SQL UPDATE
// or delete+recreate.
//
// External workspaces are EXEMPT — they intentionally do not spawn
// a Docker container or run an adapter; they delegate to a registered
// URL (see provision.go: "external is a first-class runtime that
// intentionally does NOT spawn a Docker container"). The MODEL_REQUIRED
// gate is meaningful for spawned-runtime workspaces where the model
// id drives provider selection at adapter init. For external workspaces
// the contract is the URL, not the model — requiring it would be
// ceremony with no payoff, and would 422 every legitimate "register
// my agent at https://..." flow. The SSOT directive concerns
// platform-side defaults; an external workspace genuinely has no
// "model decision" for the user to make.
//
// Fail-closed at the Create boundary so the caller learns the
// contract immediately — same shape as the controlplane#188
// runtime-unresolved gate above. Caller fixes the request, no
// EC2 launched, no stuck workspace, no operator paging.
isExternal := payload.External || isExternalLikeRuntime(payload.Runtime)
if payload.Model == "" && !isExternal {
log.Printf("Create: FAIL-CLOSED — model is required (runtime=%q template=%q); refusing the silent DefaultModel fallback per CTO 2026-05-22 SSOT directive", payload.Runtime, payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "model is required and has no platform-side default — pass an explicit \"model\" in the request body, or use a \"template\" whose config.yaml declares one. See feedback_workspace_model_required_no_platform_default_dynamic_credential_intake for the contract.",
"runtime": payload.Runtime,
"template": payload.Template,
"code": "MODEL_REQUIRED",
})
return
}
ctx := c.Request.Context()
// Convert empty role to NULL
@@ -170,7 +170,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Budgeted Agent","budget_limit":1000}`
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
@@ -15,6 +15,10 @@ import (
const (
workspaceComputeDiskFloorGB = 30
workspaceComputeDiskCeilingGB = 500
workspaceDisplayMinWidth = 800
workspaceDisplayMaxWidth = 3840
workspaceDisplayMinHeight = 600
workspaceDisplayMaxHeight = 2160
)
type workspaceDisplayResponse struct {
@@ -54,12 +58,12 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
return fmt.Errorf("unsupported compute.display.mode")
}
switch compute.Display.Protocol {
case "", "dcv":
case "", "dcv", "novnc":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if compute.Display.Width < 0 || compute.Display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
return err
}
return nil
}
@@ -71,13 +75,26 @@ func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) erro
return fmt.Errorf("unsupported compute.display.mode")
}
switch display.Protocol {
case "", "dcv":
case "", "dcv", "novnc":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if display.Width < 0 || display.Height < 0 {
if err := validateWorkspaceDisplayDimensions(display.Width, display.Height); err != nil {
return err
}
return nil
}
func validateWorkspaceDisplayDimensions(width, height int) error {
if width < 0 || height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
if width != 0 && (width < workspaceDisplayMinWidth || width > workspaceDisplayMaxWidth) {
return fmt.Errorf("compute.display.width must be between %d and %d", workspaceDisplayMinWidth, workspaceDisplayMaxWidth)
}
if height != 0 && (height < workspaceDisplayMinHeight || height > workspaceDisplayMaxHeight) {
return fmt.Errorf("compute.display.height must be between %d and %d", workspaceDisplayMinHeight, workspaceDisplayMaxHeight)
}
return nil
}
@@ -156,17 +173,13 @@ func withStoredCompute(ctx context.Context, workspaceID string, payload models.C
}
// Display handles GET /workspaces/:id/display.
//
// Phase 1 only exposes the product contract and the non-display unavailable
// state. Future desktop-control work will replace the display-enabled branch
// with short-lived proxied DCV session details.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
var raw string
var raw, instanceID string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
`SELECT COALESCE(compute, '{}'::jsonb), COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
).Scan(&raw, &instanceID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "workspace not found"})
@@ -196,6 +209,17 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
})
return
}
if instanceID != "" {
c.JSON(200, workspaceDisplayResponse{
Available: true,
Mode: compute.Display.Mode,
Protocol: compute.Display.Protocol,
Width: compute.Display.Width,
Height: compute.Display.Height,
Status: "ready",
})
return
}
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_session_unavailable",
@@ -6,8 +6,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
@@ -43,6 +45,20 @@ func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeDisplayDimensions(t *testing.T) {
for _, display := range []models.WorkspaceComputeDisplay{
{Mode: "desktop-control", Protocol: "novnc", Width: 799, Height: 1080},
{Mode: "desktop-control", Protocol: "novnc", Width: 3841, Height: 1080},
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 599},
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 2161},
} {
compute := models.WorkspaceCompute{Display: display}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatalf("validateWorkspaceCompute accepted display size %dx%d", display.Width, display.Height)
}
}
}
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
got, err := workspaceComputeJSON(models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
@@ -110,6 +126,7 @@ func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Oversized Agent",
"model":"gpt-4",
"compute":{"instance_type":"p4d.24xlarge"}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
@@ -140,6 +157,7 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
Compute: models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
},
},
nil,
@@ -153,6 +171,12 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
if cfg.DiskGB != 100 {
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
}
if cfg.Display.Mode != "desktop-control" || cfg.Display.Protocol != "novnc" {
t.Errorf("cfg.Display mode/protocol = %q/%q, want desktop-control/novnc", cfg.Display.Mode, cfg.Display.Protocol)
}
if cfg.Display.Width != 1920 || cfg.Display.Height != 1080 {
t.Errorf("cfg.Display size = %dx%d, want 1920x1080", cfg.Display.Width, cfg.Display.Height)
}
}
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
@@ -180,9 +204,9 @@ func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -214,9 +238,9 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -241,8 +265,8 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
if resp["status"] != "not_configured" {
t.Fatalf("status = %v, want not_configured", resp["status"])
}
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
if resp["mode"] != "desktop-control" || resp["protocol"] != "novnc" {
t.Fatalf("mode/protocol = %v/%v, want desktop-control/novnc", resp["mode"], resp["protocol"])
}
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
@@ -255,14 +279,89 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
}
}
func TestWorkspaceDisplay_DisplayConfiguredWithInstanceReturnsAvailableSession(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, "i-display123"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != true {
t.Fatalf("available = %v, want true", resp["available"])
}
if resp["viewer_url"] != nil {
t.Fatalf("viewer_url = %v, want omitted; stream URL is minted by Take control", resp["viewer_url"])
}
if resp["reason"] != nil {
t.Fatalf("reason = %v, want omitted", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_DisplayConfiguredWithoutInstanceReturnsUnavailable(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
workspaceID := "ws-display"
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs(workspaceID).
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["viewer_url"] != nil {
t.Fatalf("viewer_url = %v, want omitted for invalid viewer base", resp["viewer_url"])
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display-sizing-drift").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -291,9 +390,9 @@ func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testin
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-invalid-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -316,3 +415,113 @@ func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testin
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplaySession_ProxiesThroughDisplayForward(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
var upstreamAuth, upstreamCookie, upstreamProtocol, gotInstanceID string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/websockify" {
t.Errorf("upstream path = %q, want /websockify", r.URL.Path)
}
if r.URL.RawQuery != "" {
t.Errorf("upstream raw query = %q, want stripped", r.URL.RawQuery)
}
upstreamAuth = r.Header.Get("Authorization")
upstreamCookie = r.Header.Get("Cookie")
upstreamProtocol = r.Header.Get("Sec-WebSocket-Protocol")
_, _ = w.Write([]byte("websockify"))
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream URL: %v", err)
}
prevForward := displayForward
displayForward = func(_ context.Context, instanceID string, fn func(target *url.URL) error) error {
gotInstanceID = instanceID
return fn(upstreamURL)
}
t.Cleanup(func() { displayForward = prevForward })
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(
`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`,
"i-display123",
))
expiresAt := time.Now().Add(5 * time.Minute).UTC()
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).AddRow("user", "admin-token", expiresAt))
token := signDisplaySessionToken("ws-display", "admin-token", expiresAt)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-display"},
{Key: "proxyPath", Value: "/websockify"},
}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/session/websockify", nil)
c.Request.Header.Set("Authorization", "Bearer should-not-reach-upstream")
c.Request.Header.Set("Cookie", "session=should-not-reach-upstream")
c.Request.Header.Set("Sec-WebSocket-Protocol", "binary, molecule-display-token."+token)
handler.DisplaySession(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if gotInstanceID != "i-display123" {
t.Fatalf("displayForward instanceID = %q, want i-display123", gotInstanceID)
}
if w.Body.String() != "websockify" {
t.Fatalf("body = %q, want websockify", w.Body.String())
}
if upstreamAuth != "" || upstreamCookie != "" {
t.Fatalf("proxied credentials leaked upstream: auth=%q cookie=%q", upstreamAuth, upstreamCookie)
}
if upstreamProtocol != "binary" {
t.Fatalf("upstream websocket protocol = %q, want binary without display token", upstreamProtocol)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplaySession_NonDisplayWorkspaceDoesNotProxy(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
prevForward := displayForward
displayForward = func(_ context.Context, _ string, _ func(target *url.URL) error) error {
t.Fatal("displayForward must not run for non-display workspaces")
return nil
}
t.Cleanup(func() { displayForward = prevForward })
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{}`, "i-display123"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-no-display"},
{Key: "proxyPath", Value: "/websockify"},
}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display/session/websockify", nil)
handler.DisplaySession(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -435,13 +435,16 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
if err != nil {
return nil, nil, fmt.Errorf("descendant query: %w", err)
}
defer descRows.Close()
for descRows.Next() {
var descID string
if descRows.Scan(&descID) == nil {
descendantIDs = append(descendantIDs, descID)
}
}
descRows.Close()
if err := descRows.Err(); err != nil {
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
}
allIDs := append([]string{id}, descendantIDs...)
@@ -503,6 +503,32 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
// sqlmock verifies all expected queries were executed
}
func TestCascadeDelete_DescendantRowsError(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
// RowError(0, ...) requires a real row at index 0 to be reachable —
// sqlmock only invokes nextErr[N] when r.pos-1 == N and the row exists.
// AddRow ensures Next() attempts the first row, triggers the error,
// and rows.Err() returns the injected error.
h := &WorkspaceHandler{}
rows := sqlmock.NewRows([]string{"id"}).AddRow("desc-1").RowError(0, sql.ErrConnDone)
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
WithArgs(wsID).
WillReturnRows(rows)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
if err == nil {
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
}
if deleted != nil {
t.Errorf("deleted = %v; want nil", deleted)
}
if stopErrs != nil {
t.Errorf("stopErrs = %v; want nil", stopErrs)
}
}
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
// and provisioner calls — covered in integration tests. Unit tests here focus on
// the validation and pre-condition paths.
@@ -2,13 +2,19 @@ package handlers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
@@ -27,6 +33,7 @@ type workspaceDisplayControlResponse struct {
Controller string `json:"controller"`
ControlledBy string `json:"controlled_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
SessionURL string `json:"session_url,omitempty"`
}
type workspaceDisplayControlNoneResponse struct {
@@ -89,6 +96,10 @@ func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
if displaySessionSigningSecret() == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "display session signing secret is not configured"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
@@ -113,6 +124,7 @@ RETURNING controller, controlled_by, expires_at`,
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
lock.SessionURL = signedDisplaySessionURL(workspaceID, lock.ControlledBy, lock.ExpiresAt)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
"controller": lock.Controller,
"controlled_by": lock.ControlledBy,
@@ -358,3 +370,50 @@ func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
}
}
func signedDisplaySessionURL(workspaceID, controlledBy string, expiresAt time.Time) string {
token := signDisplaySessionToken(workspaceID, controlledBy, expiresAt)
if token == "" {
return ""
}
return fmt.Sprintf("/workspaces/%s/display/session/websockify#token=%s", url.PathEscape(workspaceID), token)
}
func signDisplaySessionToken(workspaceID, controlledBy string, expiresAt time.Time) string {
secret := displaySessionSigningSecret()
if secret == "" || workspaceID == "" || controlledBy == "" || expiresAt.IsZero() {
return ""
}
payload := strings.Join([]string{workspaceID, controlledBy, strconv.FormatInt(expiresAt.Unix(), 10)}, "|")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func validateDisplaySessionToken(token, workspaceID, controlledBy string, expiresAt time.Time) bool {
secret := displaySessionSigningSecret()
parts := strings.Split(token, ".")
if secret == "" || len(parts) != 2 || workspaceID == "" || controlledBy == "" || expiresAt.IsZero() || time.Now().After(expiresAt) {
return false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
payload := string(payloadBytes)
wantPayload := strings.Join([]string{workspaceID, controlledBy, strconv.FormatInt(expiresAt.Unix(), 10)}, "|")
if subtle.ConstantTimeCompare([]byte(payload), []byte(wantPayload)) != 1 {
return false
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return hmac.Equal(sig, mac.Sum(nil))
}
func displaySessionSigningSecret() string {
return os.Getenv("DISPLAY_SESSION_SIGNING_SECRET")
}
@@ -2,10 +2,15 @@ package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -54,6 +59,7 @@ func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
@@ -87,13 +93,39 @@ func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
if resp["expires_at"] == "" {
t.Fatalf("expires_at missing in response: %#v", resp)
}
sessionURL, ok := resp["session_url"].(string)
if !ok || !strings.HasPrefix(sessionURL, "/workspaces/ws-display/display/session/websockify#token=") {
t.Fatalf("session_url = %#v, want signed websockify URL fragment", resp["session_url"])
}
if strings.Contains(sessionURL, "?token=") {
t.Fatalf("session_url must not put display token in logged query string: %q", sessionURL)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestDisplaySessionToken_RequiresDedicatedSigningSecret(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "client-exposed-admin-token")
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "")
expiresAt := time.Now().Add(5 * time.Minute)
if token := signDisplaySessionToken("ws-display", "admin-token", expiresAt); token != "" {
t.Fatalf("signDisplaySessionToken minted token with no dedicated signing secret: %q", token)
}
payload := "ws-display|admin-token|" + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, []byte(""))
_, _ = mac.Write([]byte(payload))
forged := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if validateDisplaySessionToken(forged, "ws-display", "admin-token", expiresAt) {
t.Fatal("validateDisplaySessionToken accepted empty-secret forged token")
}
}
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
@@ -136,6 +168,32 @@ func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsMissingSessionSigningSecret(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "")
handler.AcquireDisplayControl(c)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status 503, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsDisplayDisabledWorkspace(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -0,0 +1,168 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
const workspaceDisplaySessionTimeout = 12 * time.Hour
const displaySessionTokenProtocolPrefix = "molecule-display-token."
var displayForward = realDisplayForward
// DisplaySession proxies noVNC/websockify requests for a display-enabled EC2
// workspace through the existing EIC SSH path. The EC2 :6080 listener stays
// private to the VPC; the browser only sees this same-origin route.
func (h *WorkspaceHandler) DisplaySession(c *gin.Context) {
workspaceID := c.Param("id")
display, instanceID, err := loadWorkspaceDisplaySessionTarget(c.Request.Context(), workspaceID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
log.Printf("DisplaySession: load target for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display session"})
return
}
if display.Mode == "" || display.Mode == "none" {
c.JSON(http.StatusNotFound, gin.H{"error": "display not enabled"})
return
}
if instanceID == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "display session unavailable"})
return
}
proxyPath := c.Param("proxyPath")
if proxyPath != "/websockify" {
c.JSON(http.StatusNotFound, gin.H{"error": "display session path not found"})
return
}
lock, found, err := h.loadActiveDisplayControl(c, workspaceID)
if err != nil {
log.Printf("DisplaySession: load active lock for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found || !validateDisplaySessionToken(displaySessionTokenFromRequest(c.Request), workspaceID, lock.ControlledBy, lock.ExpiresAt) {
c.JSON(http.StatusForbidden, gin.H{"error": "display control required"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), workspaceDisplaySessionTimeout)
defer cancel()
err = displayForward(ctx, instanceID, func(target *url.URL) error {
proxy := newDisplaySessionReverseProxy(target)
proxy.ServeHTTP(c.Writer, c.Request.WithContext(ctx))
return nil
})
if err != nil {
log.Printf("DisplaySession: proxy for %s instance=%s failed: %v", workspaceID, instanceID, err)
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": "display session proxy failed"})
}
}
}
func loadWorkspaceDisplaySessionTarget(ctx context.Context, workspaceID string) (models.WorkspaceComputeDisplay, string, error) {
var raw, instanceID string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(compute, '{}'::jsonb), COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw, &instanceID)
if err != nil {
return models.WorkspaceComputeDisplay{}, "", err
}
var compute models.WorkspaceCompute
if raw != "" && raw != "{}" {
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
return models.WorkspaceComputeDisplay{}, "", fmt.Errorf("invalid compute JSON: %w", err)
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
return models.WorkspaceComputeDisplay{}, "", err
}
}
return compute.Display, instanceID, nil
}
func newDisplaySessionReverseProxy(target *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = "/websockify"
req.URL.RawPath = ""
req.URL.RawQuery = ""
req.Host = target.Host
req.Header.Del("Authorization")
req.Header.Del("Cookie")
req.Header.Set("Sec-WebSocket-Protocol", "binary")
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
log.Printf("DisplaySession: upstream proxy error: %v", err)
http.Error(w, "display session proxy failed", http.StatusBadGateway)
},
}
}
func displaySessionTokenFromRequest(r *http.Request) string {
for _, part := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
protocol := strings.TrimSpace(part)
if strings.HasPrefix(protocol, displaySessionTokenProtocolPrefix) {
return strings.TrimPrefix(protocol, displaySessionTokenProtocolPrefix)
}
}
return ""
}
func realDisplayForward(ctx context.Context, instanceID string, fn func(target *url.URL) error) error {
if instanceID == "" {
return fmt.Errorf("workspace has no instance_id")
}
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
localPort, err := pickFreePort()
if err != nil {
return fmt.Errorf("pick display forward port: %w", err)
}
cmd := exec.CommandContext(ctx, "ssh",
"-i", s.keyPath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "ExitOnForwardFailure=yes",
"-N",
"-L", fmt.Sprintf("%d:127.0.0.1:6080", localPort),
"-p", fmt.Sprintf("%d", s.localPort),
fmt.Sprintf("%s@127.0.0.1", s.osUser),
)
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return fmt.Errorf("display forward start: %w", err)
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
}()
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
return fmt.Errorf("display forward never listened: %w", err)
}
return fn(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", localPort)})
})
}
@@ -288,16 +288,22 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
}
return provisioner.WorkspaceConfig{
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
Display: provisioner.WorkspaceDisplayConfig{
Mode: payload.Compute.Display.Mode,
Width: payload.Compute.Display.Width,
Height: payload.Compute.Display.Height,
Protocol: payload.Compute.Display.Protocol,
},
EnvVars: envVars,
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
@@ -550,13 +556,22 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// via a crafted runtime string (#241).
runtime := sanitizeRuntime(payload.Runtime)
// Generate a minimal config.yaml
// Generate a minimal config.yaml.
//
// SSOT (CTO 2026-05-22): model is REQUIRED user input. The platform
// must not provide a default; the runtime must not fall back. The
// Create handler is responsible for rejecting empty model BEFORE
// reaching provisionWorkspace; this is a defence-in-depth assertion.
// If we hit here with an empty model the YAML below would still
// render a `model: ""` line — which renders all downstream provider
// derivation undefined. Log loudly and let the workspace boot into
// not_configured rather than masking the contract violation with a
// silently-broken default (the prior `anthropic:claude-opus-4-7`
// fallback was the canonical example — every codex workspace
// created without an explicit model wedged).
model := payload.Model
if model == "" {
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Was previously duplicated here AND in
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
log.Printf("ensureDefaultConfig: workspace %s reached provisioning with empty model — Create handler should have rejected this; rendering empty model: \"\" in config.yaml (workspace will boot not_configured)", workspaceID)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
@@ -756,47 +756,55 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
}
}
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
// written. Important: the canvas can omit `model` (template inherits
// the runtime default later); we must not poison workspace_secrets with
// empty rows in that case.
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_NoModel_Returns422 inverts the prior
// premise (CTO 2026-05-22 SSOT directive — see
// feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422 in handlers_extended_test.go).
//
// Pre-2026-05-22 the canvas was allowed to omit `model` and the workspace
// would 201 with no workspace_secrets rows for MODEL/LLM_PROVIDER (the
// thinking being that templates inherit the runtime default later). That
// "soft fallback" was the load-bearing bug magnet — `DefaultModel(runtime)`
// would later return `anthropic:claude-opus-4-7`, and codex workspaces
// wedged forever at adapter init.
//
// New contract: empty model is a 422 MODEL_REQUIRED, with NO DB writes
// at all. The gate fires at the Create boundary before INSERT INTO
// workspaces. The follow-on workspace_secrets gate (which the original
// test pinned) is therefore unreachable on the empty-model path — there
// is no row to mint secrets for.
func TestWorkspaceCreate_FirstDeploy_NoModel_Returns422(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// NO INSERT INTO workspace_secrets here — the gate is payload.Model != "".
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// NO mock.ExpectBegin / INSERT INTO workspaces — the Create gate
// MUST fire before any DB write. If the gate fires late, sqlmock
// will surface "call to ExecQuery 'INSERT INTO workspaces' was not
// expected" — which is exactly the failure mode we want to flag.
// Body: hermes runtime WITHOUT external:true (the external-runtime
// exemption — see TestCreate_ExternalRuntime_NoModel_OK — does NOT
// apply here; hermes spawns a real adapter and model selection
// matters at adapter init). This is exactly the shape the old
// "no-model-no-secret-write" test pinned, minus the external flag.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Model Agent","runtime":"hermes","external":true}`
body := `{"name":"No Model Agent","runtime":"hermes"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 MODEL_REQUIRED for empty model, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
}
}
@@ -193,10 +193,17 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is required user input;
// ensureDefaultConfig no longer fills in a runtime default. The Create
// handler gates on empty model and 422s before reaching here, so this
// test now passes the model explicitly to exercise the YAML rendering
// path — same model value the prior implicit DefaultModel("hermes")
// returned.
payload := models.CreateWorkspacePayload{
Name: "Test Agent",
Tier: 1,
Runtime: "hermes",
Model: "anthropic:claude-opus-4-7",
}
files := handler.ensureDefaultConfig("ws-test-123", payload)
@@ -219,7 +226,7 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
t.Errorf("config.yaml missing tier, got:\n%s", content)
}
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
}
}
@@ -227,10 +234,14 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is supplied explicitly
// instead of relying on the deleted DefaultModel("claude-code") =
// "sonnet" fallback. The Create handler 422s on empty model upstream.
payload := models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 2,
Runtime: "claude-code",
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-code-123", payload)
@@ -407,9 +418,16 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): ensureDefaultConfig is no
// longer the source of the model default — it just renders whatever
// the Create handler decided. The "empty runtime → claude-code"
// fallback inside sanitizeRuntime() is still in effect; this test
// continues to pin that behaviour by supplying the explicit
// claude-code model that the Create handler would have required.
payload := models.CreateWorkspacePayload{
Name: "Default Agent",
Tier: 1,
Name: "Default Agent",
Tier: 1,
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
@@ -418,7 +436,7 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "sonnet"`) {
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
}
}
@@ -788,6 +806,8 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace UPDATE (0 rows — no prior tokens, still succeeds)
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -825,6 +845,8 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace: 1 existing token revoked
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -891,6 +913,8 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-issue-fail").
@@ -917,6 +941,8 @@ func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-nil-cfg").
@@ -858,6 +858,9 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
toPause = append(toPause, struct{ id, name string }{cid, cname})
}
}
if err := rows.Err(); err != nil {
log.Printf("Pause: descendant query rows.Err: %v", err)
}
}
// Stop containers and mark all as paused. StopWorkspaceAuto routes
@@ -939,6 +942,9 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
toResume = append(toResume, ws)
}
}
if err := rows.Err(); err != nil {
log.Printf("Resume: descendant query rows.Err: %v", err)
}
}
// Re-provision all
@@ -349,7 +349,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Failing Agent"}`
body := `{"name":"Failing Agent","model":"anthropic:claude-opus-4-7"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -391,7 +391,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Default Agent"}`
body := `{"name":"Default Agent","model":"anthropic:claude-opus-4-7"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -438,7 +438,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
body := `{"name":"SaaS External Agent","runtime":"external","model":"external:custom","external":true,"url":"https://example.com/agent","tier":2}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -479,7 +479,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Hermes Agent","runtime":"hermes","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
body := `{"name":"Hermes Agent","runtime":"hermes","model":"anthropic:claude-opus-4-7","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -513,7 +513,7 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Rollback Agent","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
body := `{"name":"Rollback Agent","model":"anthropic:claude-opus-4-7","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -548,7 +548,7 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Secrets Agent","external":true,"secrets":{}}`
body := `{"name":"No Secrets Agent","model":"anthropic:claude-opus-4-7","external":true,"secrets":{}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -587,7 +587,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
body := `{"name":"Ext Agent","runtime":"external","model":"external:custom","external":true,"url":"http://localhost:8000"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -629,7 +629,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
body := `{"name":"Kimi Agent","runtime":"kimi","model":"kimi-coding/kimi-k2-coding-6","tier":3,"canvas":{"x":100,"y":100}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -659,7 +659,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Agent","runtime":"external","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
body := `{"name":"Bad Agent","runtime":"external","model":"external:custom","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -690,7 +690,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Loopback","runtime":"external","external":true,"url":"http://127.0.0.1:9000/a2a"}`
body := `{"name":"Bad Loopback","runtime":"external","model":"external:custom","external":true,"url":"http://127.0.0.1:9000/a2a"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -1844,39 +1844,43 @@ func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T
}
}
// Regression guard: the legitimate default path (no template, no runtime —
// bare {"name":...}) MUST still default to langgraph and return 201. The
// #188 fix must not break this.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
mock := setupTestDB(t)
// Pre-2026-05-22 this test guarded "bare {name} → langgraph 201" — the
// regression check for controlplane#188 (where an explicit runtime that
// failed to resolve must NOT silently substitute langgraph) had a sibling
// to ensure the LEGITIMATE bare default still landed on langgraph.
//
// Post-CTO-SSOT-directive (2026-05-22) bare body is 422 MODEL_REQUIRED
// before reaching the langgraph branch — the gate runs AFTER the
// langgraph-default assignment so the error body still surfaces
// runtime=langgraph (helps the caller see "ok, langgraph WOULD have
// been the runtime, but you still owe me a model"). The bare-body
// langgraph 201 path no longer exists; what we guard now is the
// 422-shape diagnostic.
//
// Bare-body-with-explicit-model 201 (the new "legitimate default" path)
// is covered by TestWorkspaceCreate in handlers_test.go — no need to
// duplicate the mock dance here.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_NowMODEL_REQUIRED(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Plain Default"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("bare-body create: expected 422 MODEL_REQUIRED, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("bare-body create: expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"runtime":"langgraph"`)) {
t.Errorf("bare-body create: expected runtime=\"langgraph\" in 422 body (the gate runs AFTER the langgraph-default assignment so the diagnostic surfaces what runtime WOULD have been used), got %s", w.Body.String())
}
}
@@ -1901,7 +1905,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Explicit Codex","runtime":"codex"}`
body := `{"name":"Explicit Codex","runtime":"codex","model":"gpt-5.5"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -329,7 +329,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody interf
func decodeError(resp *http.Response) error {
var e contract.Error
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
Message: fmt.Sprintf("status %d (read body failed: %v)", resp.StatusCode, readErr),
}
}
if len(body) == 0 {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
@@ -3,6 +3,7 @@ package pgplugin
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
@@ -246,7 +247,9 @@ func (h *Handler) forget(w http.ResponseWriter, r *http.Request, id string) {
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("pgplugin: JSON encode error: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
@@ -39,41 +39,30 @@ type Bundle struct {
// Build returns a wired Bundle if MEMORY_PLUGIN_URL is set, else nil.
//
// It probes /v1/health at boot — when the plugin is unreachable, we
// log a warning but STILL return the bundle. The MCP layer's
// circuit breaker handles ongoing unavailability; we don't want to
// block workspace-server boot just because the memory plugin is
// briefly down.
// log a warning but STILL return the bundle. The MCP layer's circuit
// breaker handles ongoing unavailability.
//
// Silent-misconfig guard: if MEMORY_V2_CUTOVER=true is set without
// MEMORY_PLUGIN_URL, the cutoverActive() check in handlers silently
// returns false and the legacy SQL path serves every request. The
// operator sees no errors, no warnings, and assumes the cutover is
// live. Log a LOUD WARN at boot when the env is half-configured so
// the misconfig is visible in the boot log, not detectable only by
// observing that the legacy table is still being written to.
// Issue #1733: when MEMORY_PLUGIN_URL is unset the bundle is nil and
// every memory MCP tool returns a clear "plugin not configured" error
// (mcp_tools.go). There is no longer a silent SQL fallback to
// agent_memories, so the previous half-configured-cutover guard is
// gone — a missing URL fails loudly on first memory call instead of
// quietly serving stale legacy data.
//
// MEMORY_V2_CUTOVER is left intact as a deployment marker (CP user-data
// reads it before spawning the sidecar in entrypoint-tenant.sh); we no
// longer branch on it inside the platform binary.
func Build(db *sql.DB) *Bundle {
cutover := os.Getenv("MEMORY_V2_CUTOVER") == "true"
pluginURL := os.Getenv("MEMORY_PLUGIN_URL")
if pluginURL == "" {
if cutover {
log.Printf("memory-plugin: ⚠️ MEMORY_V2_CUTOVER=true but MEMORY_PLUGIN_URL is unset — cutover is INACTIVE, legacy SQL path is serving every request. Either unset MEMORY_V2_CUTOVER or point MEMORY_PLUGIN_URL at a reachable plugin server.")
}
log.Printf("memory-plugin: MEMORY_PLUGIN_URL is unset — v2 memory MCP tools (commit_memory, recall_memory, admin export/import) will return 'plugin not configured' to callers. Set MEMORY_PLUGIN_URL to activate.")
return nil
}
plugin := mclient.New(mclient.Config{})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if hr, err := plugin.Boot(ctx); err != nil {
// Log even louder when cutover is on — an unreachable plugin
// during cutover means writes that the operator THINKS are
// going to v2 will silently fall back to legacy via the
// circuit breaker on each request. Make it impossible to miss.
if cutover {
log.Printf("memory-plugin: ⚠️ MEMORY_V2_CUTOVER=true and MEMORY_PLUGIN_URL=%s but /v1/health probe failed (%v). Cutover writes will fall back to legacy via circuit breaker. Verify the plugin server is reachable.", pluginURL, err)
} else {
log.Printf("memory-plugin: /v1/health probe failed (will retry per-request): %v", err)
}
log.Printf("memory-plugin: ⚠️ /v1/health probe failed at boot (%v). MCP memory calls will error until the plugin becomes reachable. Verify MEMORY_PLUGIN_URL=%s.", err, pluginURL)
} else {
log.Printf("memory-plugin: ok, capabilities=%v", hr.Capabilities)
}
@@ -166,69 +166,43 @@ func captureLogs(t *testing.T, fn func()) string {
return buf.String()
}
// TestBuild_WarnsWhenCutoverWithoutPluginURL pins the silent-misconfig
// guard: an operator who flips MEMORY_V2_CUTOVER=true without also
// pointing MEMORY_PLUGIN_URL at a plugin server has just disabled the
// cutover with no error visible. Without this WARN, the only signal
// is "the legacy table is still being written to" — invisible to
// every operator who doesn't explicitly check.
func TestBuild_WarnsWhenCutoverWithoutPluginURL(t *testing.T) {
t.Setenv("MEMORY_V2_CUTOVER", "true")
// Issue #1733: the old "cutover-without-URL" and "cutover-with-failing-
// probe" loud-warning tests are gone — workspace-server no longer
// branches on MEMORY_V2_CUTOVER (v2 is unconditional now), so the
// half-configured-cutover failure mode they guarded against can no
// longer occur. The two surviving tests below pin the new shape:
// one log line when URL is unset, one when the boot-time probe fails.
// TestBuild_LogsWhenURLUnset confirms the operator-visible boot log
// line that fires when MEMORY_PLUGIN_URL is unset — every memory MCP
// tool will then return "plugin not configured" to callers.
func TestBuild_LogsWhenURLUnset(t *testing.T) {
t.Setenv("MEMORY_PLUGIN_URL", "")
out := captureLogs(t, func() {
if got := Build(nil); got != nil {
t.Errorf("expected nil bundle, got %+v", got)
}
})
if !strings.Contains(out, "MEMORY_V2_CUTOVER=true") || !strings.Contains(out, "MEMORY_PLUGIN_URL is unset") {
t.Errorf("expected loud WARN about half-configured cutover; got log:\n%s", out)
if !strings.Contains(out, "MEMORY_PLUGIN_URL is unset") {
t.Errorf("expected boot log to mention MEMORY_PLUGIN_URL is unset; got:\n%s", out)
}
}
// TestBuild_NoWarnWhenNeitherSet pins the happy default: an operator
// running without the v2 plugin should not see scary warnings.
func TestBuild_NoWarnWhenNeitherSet(t *testing.T) {
t.Setenv("MEMORY_V2_CUTOVER", "")
t.Setenv("MEMORY_PLUGIN_URL", "")
out := captureLogs(t, func() { _ = Build(nil) })
if strings.Contains(out, "MEMORY_V2_CUTOVER") {
t.Errorf("expected no MEMORY_V2_CUTOVER warning when env is unset; got log:\n%s", out)
}
}
// TestBuild_LoudWarnWhenCutoverAndProbeFails pins the second
// half-config case: cutover is on AND plugin URL is set, but the
// /v1/health probe fails (server down or wrong URL). Without this
// loud WARN, the operator sees only the generic "probe failed" line
// that gets emitted even when cutover is OFF — hiding the fact that
// real cutover writes will quietly fall back via circuit breaker.
func TestBuild_LoudWarnWhenCutoverAndProbeFails(t *testing.T) {
t.Setenv("MEMORY_V2_CUTOVER", "true")
// TestBuild_LogsWhenProbeFails confirms the boot log line that fires
// when MEMORY_PLUGIN_URL is set but the plugin is unreachable. The
// bundle is still returned (per the comment on Build) so the platform
// keeps booting — MCP calls error per-request until the plugin recovers.
func TestBuild_LogsWhenProbeFails(t *testing.T) {
t.Setenv("MEMORY_PLUGIN_URL", "http://127.0.0.1:1") // bogus port
db, _, _ := sqlmock.New()
defer db.Close()
out := captureLogs(t, func() { _ = Build(db) })
if !strings.Contains(out, "MEMORY_V2_CUTOVER=true") || !strings.Contains(out, "probe failed") {
t.Errorf("expected loud WARN about cutover-with-failing-probe; got log:\n%s", out)
}
}
// TestBuild_QuietProbeFailWhenCutoverOff: the operator is in PRE-cutover
// mode (plugin URL set, cutover off — they're warming up the plugin).
// A failing probe in this state is not a misconfig — it should log the
// generic message, NOT the loud cutover-specific one (so log noise
// doesn't drown out real cutover misconfigs in dashboards).
func TestBuild_QuietProbeFailWhenCutoverOff(t *testing.T) {
t.Setenv("MEMORY_V2_CUTOVER", "")
t.Setenv("MEMORY_PLUGIN_URL", "http://127.0.0.1:1")
db, _, _ := sqlmock.New()
defer db.Close()
out := captureLogs(t, func() { _ = Build(db) })
if strings.Contains(out, "MEMORY_V2_CUTOVER=true") {
t.Errorf("expected no cutover-specific warning when cutover is off; got log:\n%s", out)
}
if !strings.Contains(out, "probe failed") {
t.Errorf("expected generic probe-failed log; got log:\n%s", out)
out := captureLogs(t, func() {
if got := Build(db); got == nil {
t.Error("expected non-nil bundle when URL is set, got nil")
}
})
if !strings.Contains(out, "/v1/health probe failed") {
t.Errorf("expected boot log to mention probe failure; got:\n%s", out)
}
}
@@ -256,6 +256,7 @@ func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) {
// live tokens anywhere) the middleware must let the request through so existing
// deployments keep working during the Phase-30 rollout.
func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -375,6 +376,7 @@ func TestAdminAuth_C11_DeleteNoBearer_Returns401(t *testing.T) {
// TestAdminAuth_ValidBearer_Passes — a valid bearer token (from any workspace)
// must be accepted for admin routes.
func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -418,6 +420,7 @@ func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
// TestAdminAuth_InvalidBearer_Returns401 — wrong token must not grant admin access.
func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -700,6 +703,7 @@ func TestAdminAuth_Issue180_ApprovalsListing_NoBearer_Returns401(t *testing.T) {
// fail-open contract: on a fresh install (no tokens anywhere), the middleware
// must not block the canvas from polling /approvals/pending.
func TestAdminAuth_Issue180_ApprovalsListing_FailOpen_NoTokens(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -1098,6 +1102,7 @@ func TestCanvasOrBearer_TokensExist_CanvasOrigin_Passes(t *testing.T) {
// issuing workspace has status='removed' must not grant admin access.
// The JOIN in ValidateAnyToken filters the row out, resulting in ErrNoRows.
func TestAdminAuth_RemovedWorkspaceToken_Returns401(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -1251,6 +1256,7 @@ func TestAdminAuth_623_ForgedCORSOrigin_Returns401(t *testing.T) {
// TestAdminAuth_623_ValidBearer_WithOrigin_Passes — bearer + matching Origin
// should still work (the Origin is irrelevant once the bearer validates).
func TestAdminAuth_623_ValidBearer_WithOrigin_Passes(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
@@ -1,39 +1,31 @@
package models
// runtime_defaults.go — single source of truth for per-runtime defaults
// the platform applies when the operator/agent didn't supply a value.
// runtime_defaults.go — DELETED helper. Intentionally empty.
//
// Why this lives in models/ (not handlers/): default selection is a
// pure data fact about the runtime, not handler logic. Multiple
// callers (Create-workspace handler, org-import handler, future
// auto-provision paths) need the same answer; concentrating the
// rule here means one edit when a runtime's default changes.
// Previously held `DefaultModel(runtime string) string` which returned
// "sonnet" for claude-code and "anthropic:claude-opus-4-7" for everything
// else. That function was a SOFT-FALLBACK bug magnet:
//
// Related work (RFC #2873): this is the seed for a future
// `RuntimeConfig` interface that will also expose `ProvisioningTimeout()`,
// `CapabilitiesSupported()`, and other per-runtime facts. For now the
// surface is one helper — extracted from the duplicate branch in
// workspace_provision.go:537 and org_import.go:54 that diverged silently
// during refactors before this consolidation.
// DefaultModel returns the model slug to use when a workspace is
// created without an explicit model and the runtime can't infer one
// from its own config.
// - codex workspaces created without an explicit `model` silently
// received `anthropic:claude-opus-4-7`. Codex adapter only supports
// openai-* providers, so they wedged in `not_configured` with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. The fallback never matched
// a runtime that could actually use it (only langgraph + hermes
// could even partially execute anthropic:claude-opus-4-7 without
// extra credential plumbing). It existed as a "must return
// something" placeholder that turned every silent miss into a
// prod incident.
//
// - claude-code: "sonnet" — Anthropic's CLI accepts the short
// name and resolves it via the operator's anthropic-oauth or
// ANTHROPIC_API_KEY chain.
// - everything else (hermes, langgraph, autogen, codex, openclaw,
// external, ""): a fully-qualified
// vendor:model slug that the universal MODEL_PROVIDER chain in
// molecule-core PR #247 can route via per-vendor required_env.
// - The fallback hid the contract bug at every callsite: Create
// handler, org_import, anywhere a stale CreateWorkspacePayload
// bubbled through to provisionWorkspace.
//
// The function never returns an empty string; an unknown runtime
// gets the universal default rather than failing closed (matches the
// pre-refactor behavior — both call sites used the same fallback).
func DefaultModel(runtime string) string {
if runtime == "claude-code" {
return "sonnet"
}
return "anthropic:claude-opus-4-7"
}
// SSOT principle (CTO 2026-05-22T03:42Z, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model / provider / provider-credential are REQUIRED user input at
// create time. The platform must not provide a default. The runtime
// must not fall back. Decision belongs to the user (or to the agent
// acting on the user's behalf), never to the platform.
//
// Callers that previously fell back to DefaultModel must now fail-closed
// when model is empty after template-resolution.
@@ -1,59 +1,11 @@
package models
import "testing"
// TestDefaultModel pins the contract: known runtimes return their
// expected default; unknowns and the empty string fall through to the
// universal default. Add new runtimes here as `case` entries — pre-fix
// adding a runtime required two source edits + an audit; post-SSOT it
// requires one entry in DefaultModel + one assertion here.
func TestDefaultModel(t *testing.T) {
cases := []struct {
runtime string
want string
}{
// Known runtimes.
{"claude-code", "sonnet"},
// Universal fallback for everything else. Each runtime is named
// explicitly so a future drift (e.g., adding a hermes-specific
// branch) shows up as a failure on the runtime that drifted, not
// as a generic "unknown" failure.
{"hermes", "anthropic:claude-opus-4-7"},
{"langgraph", "anthropic:claude-opus-4-7"},
{"autogen", "anthropic:claude-opus-4-7"},
{"codex", "anthropic:claude-opus-4-7"},
{"openclaw", "anthropic:claude-opus-4-7"},
{"external", "anthropic:claude-opus-4-7"},
// Unknown / empty — fall through to universal default rather
// than failing closed. Pre-refactor both call sites also fell
// through; pinning the existing behavior, not changing it.
{"", "anthropic:claude-opus-4-7"},
{"some-future-runtime", "anthropic:claude-opus-4-7"},
{"CLAUDE-CODE", "anthropic:claude-opus-4-7"}, // case-sensitive — matches prior behavior
}
for _, tc := range cases {
t.Run(tc.runtime, func(t *testing.T) {
got := DefaultModel(tc.runtime)
if got != tc.want {
t.Errorf("DefaultModel(%q) = %q, want %q", tc.runtime, got, tc.want)
}
})
}
}
// TestDefaultModel_NeverEmpty — invariant: no input produces an empty
// string. The handlers that consume this would write empty into
// config.yaml, which the runtime then can't dispatch — pinning the
// non-empty contract here protects against a future "return early on
// unknown runtime" change that would silently break workspace creation.
func TestDefaultModel_NeverEmpty(t *testing.T) {
for _, runtime := range []string{
"", "claude-code", "hermes", "unknown-runtime",
} {
if got := DefaultModel(runtime); got == "" {
t.Errorf("DefaultModel(%q) returned empty string", runtime)
}
}
}
// runtime_defaults_test.go — previously pinned DefaultModel's contract
// (claude-code → "sonnet", everything else → "anthropic:claude-opus-4-7").
//
// DefaultModel was removed as a soft-fallback bug magnet (CTO 2026-05-22):
// model is REQUIRED user input; the platform must not provide a default.
// See runtime_defaults.go for the deletion rationale, and the new
// fail-closed gate in `handlers.WorkspaceHandler.Create` for the boundary
// enforcement. No test stub here — the contract is "this function does
// not exist", which the type-checker enforces at compile time.
@@ -152,14 +152,15 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
}
type cpProvisionRequest struct {
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
Display WorkspaceDisplayConfig `json:"display,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
// ConfigFiles are template + generated config files to write into the
// EC2 instance's /configs directory. OFFSEC-010: collected by
// collectCPConfigFiles which rejects symlinks and non-regular files
@@ -214,6 +215,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
Tier: cfg.Tier,
InstanceType: cfg.InstanceType,
DiskGB: cfg.DiskGB,
Display: cfg.Display,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
@@ -241,9 +243,12 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
// Cap body read at 64 KiB — the CP only ever returns small JSON
// responses; an unbounded read could be weaponized into log-flood
// DoS by a compromised upstream.
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr != nil {
return "", fmt.Errorf("cp provisioner: read response body: %w", readErr)
}
var result cpProvisionResponse
json.Unmarshal(respBody, &result)
unmarshalErr := json.Unmarshal(respBody, &result)
if resp.StatusCode != http.StatusCreated {
// Prefer the structured {"error":"..."} field. Do NOT fall back
@@ -257,6 +262,10 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
return "", fmt.Errorf("cp provisioner: provision failed (%d): %s", resp.StatusCode, errMsg)
}
if unmarshalErr != nil {
return "", fmt.Errorf("cp provisioner: decode 201 response: %w", unmarshalErr)
}
log.Printf("CP provisioner: workspace %s → EC2 instance %s (%s)", cfg.WorkspaceID, result.InstanceID, result.State)
provlog.Event("provision.ec2_started", map[string]any{
"workspace_id": cfg.WorkspaceID,
@@ -409,7 +418,11 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
// Read a bounded slice of the body so the error message gives ops
// enough to triage without risking a multi-MB log line on a
// pathological response. 512 bytes covers any sane error envelope.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
if readErr != nil {
return fmt.Errorf("cp provisioner: stop %s: unexpected %d (read body failed: %w)",
workspaceID, resp.StatusCode, readErr)
}
return fmt.Errorf("cp provisioner: stop %s: unexpected %d: %s",
workspaceID, resp.StatusCode, strings.TrimSpace(string(body)))
}
@@ -197,6 +197,12 @@ func TestStart_HappyPath(t *testing.T) {
if body.DiskGB != 100 {
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
}
if body.Display.Mode != "desktop-control" || body.Display.Protocol != "novnc" {
t.Errorf("display mode/protocol = %q/%q, want desktop-control/novnc", body.Display.Mode, body.Display.Protocol)
}
if body.Display.Width != 1920 || body.Display.Height != 1080 {
t.Errorf("display size = %dx%d, want 1920x1080", body.Display.Width, body.Display.Height)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
@@ -212,6 +218,7 @@ func TestStart_HappyPath(t *testing.T) {
id, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
InstanceType: "m6i.xlarge", DiskGB: 100,
Display: WorkspaceDisplayConfig{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
})
if err != nil {
t.Fatalf("Start: %v", err)
@@ -442,6 +449,26 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
}
}
// TestStart_Malformed201SurfacesError — when CP returns 201 Created with
// unparseable JSON, Start must return an error instead of silently
// returning an empty instance_id. CR2 blocker from review #5552.
func TestStart_Malformed201SurfacesError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id": broken-json`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{WorkspaceID: "ws-1", Runtime: "py"})
if err == nil {
t.Fatal("expected error on malformed 201, got nil")
}
if !strings.Contains(err.Error(), "decode 201 response") {
t.Errorf("error should mention decode 201 response, got %q", err.Error())
}
}
// TestStop_SendsBothAuthHeaders — verify #118/#130 compliance on the
// teardown path. Any call to /cp/workspaces/:id must carry both the
// platform-wide shared secret AND the per-tenant admin token, or the
@@ -97,9 +97,10 @@ type WorkspaceConfig struct {
PluginsPath string // Host path to plugins directory (mounted at /plugins)
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
Tier int
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
InstanceType string // Optional CP EC2 instance type override (SaaS only)
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
InstanceType string // Optional CP EC2 instance type override (SaaS only)
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
Display WorkspaceDisplayConfig
EnvVars map[string]string // Additional env vars (API keys, etc.)
PlatformURL string
AwarenessURL string
@@ -122,6 +123,13 @@ type WorkspaceConfig struct {
Image string
}
type WorkspaceDisplayConfig struct {
Mode string `json:"mode,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Protocol string `json:"protocol,omitempty"`
}
// selectImage resolves the final Docker image ref for a workspace. The handler
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
@@ -710,7 +718,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace))
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
}
// #1687: track explicit GH_TOKEN / GITHUB_TOKEN so they win over GH_PAT
// alias. These are normally stripped by the SCM-write guard below, but
// when a user explicitly sets them we preserve the value.
var explicitGHToken, explicitGitHubToken string
for k, v := range cfg.EnvVars {
if k == "GH_TOKEN" {
explicitGHToken = v
continue
}
if k == "GITHUB_TOKEN" {
explicitGitHubToken = v
continue
}
// Forensic #145 hardening: tenant workspace containers run
// agent-controlled code and must NEVER receive a Git SCM *write*
// credential. Without merge/approve creds in-container the
@@ -728,6 +748,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
}
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// #1687: alias GH_PAT → GH_TOKEN / GITHUB_TOKEN on the READ side
// (container env assembly). Explicit values win: only alias when the
// key was not set in workspace secrets.
if explicitGHToken != "" {
env = append(env, fmt.Sprintf("GH_TOKEN=%s", explicitGHToken))
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
env = append(env, fmt.Sprintf("GH_TOKEN=%s", pat))
}
if explicitGitHubToken != "" {
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", explicitGitHubToken))
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", pat))
}
// Inject ADMIN_TOKEN from the platform server's environment so workspace
// containers can call /admin/liveness and other admin-gated endpoints
// (core#831). cp_provisioner.go handles this separately for SaaS tenants.
@@ -770,9 +770,12 @@ func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
// place — i.e. the guard is proven by construction, not by environment
// accident.
func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
// GH_TOKEN and GITHUB_TOKEN are preserved when explicitly set (#1687)
// because they win over the GH_PAT alias. The unconditional strip list
// therefore excludes them; see TestBuildContainerEnv_GHPATAliasPrecedence
// for the positive assertion.
scmTokens := []string{
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
"GITEA_TOKEN", "GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
}
t.Run("normal path — SCM tokens explicitly set in EnvVars", func(t *testing.T) {
@@ -780,6 +783,9 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
for _, k := range scmTokens {
envVars[k] = "leaked-write-credential-" + k
}
// Explicit GH_TOKEN / GITHUB_TOKEN are now preserved (#1687).
envVars["GH_TOKEN"] = "explicit-gh-token"
envVars["GITHUB_TOKEN"] = "explicit-github-token"
cfg := WorkspaceConfig{
WorkspaceID: "ws-tenant",
PlatformURL: "http://localhost:8080",
@@ -795,6 +801,13 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
if !envContains(buildContainerEnv(cfg), "ANTHROPIC_API_KEY=sk-keep") {
t.Errorf("filter must not strip non-SCM API keys")
}
// Explicit GH tokens must be preserved (not stripped).
if !envContains(buildContainerEnv(cfg), "GH_TOKEN=explicit-gh-token") {
t.Errorf("explicit GH_TOKEN must be preserved")
}
if !envContains(buildContainerEnv(cfg), "GITHUB_TOKEN=explicit-github-token") {
t.Errorf("explicit GITHUB_TOKEN must be preserved")
}
})
t.Run("persona-file path — simulates loadPersonaEnvFile merge", func(t *testing.T) {
@@ -855,6 +868,106 @@ func TestCPProvisionerEnv_StripsSCMWriteTokens(t *testing.T) {
}
}
// TestBuildContainerEnv_GHPATAliasPrecedence asserts that explicit GH_TOKEN /
// GITHUB_TOKEN in workspace secrets win over the GH_PAT alias (#1687 CR2
// review_id=5646). The alias must only inject a key when it was NOT explicitly
// set.
func TestBuildContainerEnv_GHPATAliasPrecedence(t *testing.T) {
pat := "ghp_pat_from_secrets"
explicitGH := "gh_explicit_token"
explicitGitHub := "github_explicit_token"
t.Run("GH_PAT alone → alias both", func(t *testing.T) {
cfg := WorkspaceConfig{
WorkspaceID: "ws-x",
PlatformURL: "http://localhost:8080",
EnvVars: map[string]string{"GH_PAT": pat},
}
env := buildContainerEnv(cfg)
if !envContains(env, "GH_TOKEN="+pat) {
t.Errorf("GH_PAT alias must set GH_TOKEN, got %v", env)
}
if !envContains(env, "GITHUB_TOKEN="+pat) {
t.Errorf("GH_PAT alias must set GITHUB_TOKEN, got %v", env)
}
})
t.Run("explicit GH_TOKEN wins over GH_PAT alias", func(t *testing.T) {
cfg := WorkspaceConfig{
WorkspaceID: "ws-x",
PlatformURL: "http://localhost:8080",
EnvVars: map[string]string{
"GH_PAT": pat,
"GH_TOKEN": explicitGH,
},
}
env := buildContainerEnv(cfg)
if envContains(env, "GH_TOKEN="+pat) {
t.Errorf("explicit GH_TOKEN must win over GH_PAT alias, got GH_TOKEN=%q", pat)
}
if !envContains(env, "GH_TOKEN="+explicitGH) {
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
}
})
t.Run("explicit GITHUB_TOKEN wins over GH_PAT alias", func(t *testing.T) {
cfg := WorkspaceConfig{
WorkspaceID: "ws-x",
PlatformURL: "http://localhost:8080",
EnvVars: map[string]string{
"GH_PAT": pat,
"GITHUB_TOKEN": explicitGitHub,
},
}
env := buildContainerEnv(cfg)
if envContains(env, "GITHUB_TOKEN="+pat) {
t.Errorf("explicit GITHUB_TOKEN must win over GH_PAT alias, got GITHUB_TOKEN=%q", pat)
}
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
}
})
t.Run("explicit both → both preserved, no alias", func(t *testing.T) {
cfg := WorkspaceConfig{
WorkspaceID: "ws-x",
PlatformURL: "http://localhost:8080",
EnvVars: map[string]string{
"GH_PAT": pat,
"GH_TOKEN": explicitGH,
"GITHUB_TOKEN": explicitGitHub,
},
}
env := buildContainerEnv(cfg)
if envContains(env, "GH_TOKEN="+pat) {
t.Errorf("explicit GH_TOKEN must win, got alias value %q", pat)
}
if envContains(env, "GITHUB_TOKEN="+pat) {
t.Errorf("explicit GITHUB_TOKEN must win, got alias value %q", pat)
}
if !envContains(env, "GH_TOKEN="+explicitGH) {
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
}
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
}
})
t.Run("no GH_PAT → no alias injected", func(t *testing.T) {
cfg := WorkspaceConfig{
WorkspaceID: "ws-x",
PlatformURL: "http://localhost:8080",
EnvVars: map[string]string{"OTHER": "ok"},
}
env := buildContainerEnv(cfg)
for _, e := range env {
if strings.HasPrefix(e, "GH_TOKEN=") || strings.HasPrefix(e, "GITHUB_TOKEN=") {
t.Errorf("no GH_PAT present → no alias should be injected, got %q", e)
}
}
})
}
func assertNoSCMWriteToken(t *testing.T, env []string, scmTokens []string) {
t.Helper()
for _, e := range env {
@@ -93,6 +93,9 @@ func sweepOnlineWorkspaces(ctx context.Context, checker ContainerChecker, onOffl
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Health sweep: rows error: %v", err)
}
for _, id := range ids {
running, err := checker.IsRunning(ctx, id)
@@ -159,6 +162,9 @@ func sweepStaleRemoteWorkspaces(ctx context.Context, onOffline OfflineHandler) {
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Health sweep: rows error: %v", err)
}
for _, id := range ids {
// External workspaces flip to 'awaiting_agent' (re-registrable
@@ -166,6 +166,9 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
ids = append(ids, c)
}
}
if err := rows.Err(); err != nil {
log.Printf("Provision-timeout sweep: rows error: %v", err)
}
for _, c := range ids {
timeout := provisioningTimeoutFor(c.runtime, lookup)
@@ -81,6 +81,7 @@ func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) {
// bootstrap path still works before the first workspace has registered.
func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) {
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock := setupRouterTestDB(t)
// HasAnyLiveTokenGlobal: no tokens yet — fresh install.
@@ -182,6 +182,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// URLs, so keep the endpoint admin-gated from the first unavailable
// state rather than widening it later.
wsAdmin.GET("/workspaces/:id/display", wh.Display)
wsAdmin.GET("/workspaces/:id/display/session/*proxyPath", wh.DisplaySession)
wsAdmin.GET("/workspaces/:id/display/control", wh.DisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/acquire", wh.AcquireDisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/release", wh.ReleaseDisplayControl)
@@ -18,6 +18,7 @@ func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
r := gin.New()
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
r.GET("/workspaces/:id/display/session/*proxyPath", middleware.AdminAuth(db.DB), wh.DisplaySession)
r.POST("/workspaces/:id/display/control/acquire", middleware.AdminAuth(db.DB), wh.AcquireDisplayControl)
return r
}
@@ -59,3 +60,22 @@ func TestWorkspaceDisplayControlRoute_RequiresAdminAuth(t *testing.T) {
t.Errorf("sqlmock unmet: %v", err)
}
}
func TestWorkspaceDisplaySessionRoute_RequiresAdminAuth(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
mock := setupRouterTestDB(t)
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
r := buildWorkspaceDisplayEngine(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display/session/vnc.html", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock unmet: %v", err)
}
}
@@ -832,9 +832,18 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
// exhaustion, SDK internal errors) — the error surfaces only in the response
// body under result.kind or result.result_kind.
//
// Returns an empty string when the response is clean (result_kind is "ok" or
// absent). Returns the result_kind value when it is a non-ok signal, so callers
// can propagate it as the schedule's last_status.
// Returns an empty string when the response is clean (result_kind is "ok",
// "message" — the A2A-SDK canonical successful Message envelope — or absent).
// Returns the result_kind value when it is a non-ok signal, so callers can
// propagate it as the schedule's last_status.
//
// Known successful (= treat-as-ok) kinds (resultOKKinds):
// - "ok" — explicit success signal
// - "message" — A2A-SDK Message envelope (`{"result":{"kind":"message","parts":[...]}}`),
// emitted by every successful agent reply. Fix: #1696 originally allow-listed only
// "ok" / empty, which mis-flagged every successful agent response as an SDK error
// (PM scheduler observed 21 consecutive false-failure ticks before auto-disable;
// screenshot 2026-05-23). See [#1696 follow-up].
//
// Known non-ok kinds:
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
@@ -842,6 +851,59 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
// - "sdk_error" — SDK threw an internal error
//
// See #1696.
//
// resultOKKinds is the allowlist of `result.kind` values that are
// UNCONDITIONALLY successful (no further parsing needed). Anything
// outside this set is treated as a non-ok SDK signal, EXCEPT `task`
// which is gated separately on `result.status.state` (see
// classifyTaskState — A2A Task can be either in-progress or terminally
// failed, depending on its status).
//
// Add to this list when new always-success envelope kinds are introduced
// upstream. NEVER add an envelope that can carry a failure sub-state.
var resultOKKinds = map[string]struct{}{
"": {}, // absent / empty → treat as ok (no signal)
"ok": {}, // explicit success
"message": {}, // A2A-SDK Message envelope (always a successful agent reply)
}
// taskOKStates is the A2A Task `status.state` allowlist for results that
// have `kind: "task"`. Tasks can be in-progress (submitted/working) or
// terminally successful (completed) — those are clean signals to the
// scheduler. Terminal failure states (failed/canceled/rejected) are
// surfaced as the scheduler's last_status so operators can see the real
// state. Cf. CR2 review feedback on #1716.
var taskOKStates = map[string]struct{}{
"": {}, // status.state absent → conservative: don't fire false-failure
"submitted": {}, // task accepted, not yet running
"working": {}, // task in progress
"completed": {}, // task finished successfully
}
// classifyTaskState inspects `result.status.state` (or `result.status_state`
// legacy variant) and returns "" when the state is in taskOKStates (success
// or in-progress) or the state string when it is a terminal failure that
// should propagate as last_status.
func classifyTaskState(result map[string]json.RawMessage) string {
rawStatus, ok := result["status"]
if !ok {
return "" // no status block → no signal, leave clean
}
var status map[string]json.RawMessage
if err := json.Unmarshal(rawStatus, &status); err != nil {
return ""
}
if rawState, ok := status["state"]; ok {
var s string
if json.Unmarshal(rawState, &s) == nil {
if _, isOK := taskOKStates[s]; !isOK {
return s
}
}
}
return ""
}
func detectResultKind(body []byte) string {
if len(body) == 0 {
return ""
@@ -854,18 +916,32 @@ func detectResultKind(body []byte) string {
if rawResult, ok := top["result"]; ok {
var result map[string]json.RawMessage
if err := json.Unmarshal(rawResult, &result); err == nil {
// result.kind (canonical JSON-RPC error envelope field).
// result.kind (canonical JSON-RPC envelope field).
if rawKind, ok := result["kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
return k
if json.Unmarshal(rawKind, &k) == nil {
// Special-case task: success or failure depends on status.state.
if k == "task" {
if bad := classifyTaskState(result); bad != "" {
return bad
}
// task with ok / in-progress state → clean
} else if _, isOK := resultOKKinds[k]; !isOK {
return k
}
}
}
// result.result_kind (legacy / alternative field name).
if rawKind, ok := result["result_kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
return k
if json.Unmarshal(rawKind, &k) == nil {
if k == "task" {
if bad := classifyTaskState(result); bad != "" {
return bad
}
} else if _, isOK := resultOKKinds[k]; !isOK {
return k
}
}
}
}
@@ -702,6 +702,61 @@ func TestDetectResultKind(t *testing.T) {
body: `{"result":{"result_kind":"ok","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
// REGRESSION GUARD: A2A-SDK canonical Message envelope.
// Pre-fix, every successful agent reply was mis-flagged as an SDK
// error (PM scheduler hit 21 consecutive false-failure ticks before
// auto-disable; canvas screenshot 2026-05-23).
name: "clean ok response — result.kind=message (A2A Message envelope)",
body: `{"jsonrpc":"2.0","result":{"kind":"message","parts":[{"kind":"text","text":"hello"}]},"id":"1"}`,
wantKind: "",
},
{
name: "clean ok response — result.result_kind=message",
body: `{"result":{"result_kind":"message","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
// A2A Task envelope, in-progress — `status.state` discriminator is
// `submitted` or `working` → treat as clean (not an SDK error).
name: "clean ok — task envelope state=working",
body: `{"result":{"kind":"task","task_id":"abc","status":{"state":"working"}}}`,
wantKind: "",
},
{
name: "clean ok — task envelope state=submitted",
body: `{"result":{"kind":"task","status":{"state":"submitted"}}}`,
wantKind: "",
},
{
name: "clean ok — task envelope state=completed",
body: `{"result":{"kind":"task","status":{"state":"completed"}}}`,
wantKind: "",
},
{
// Conservative: missing status.state → don't fire false-failure.
name: "clean ok — task envelope no status block",
body: `{"result":{"kind":"task","task_id":"abc"}}`,
wantKind: "",
},
{
// REGRESSION GUARD: terminal failure states MUST propagate as last_status.
// Without taskOKStates gating, the blanket "task" allowlist would have
// masked these — CR2 review feedback on #1716.
name: "SDK error — task envelope state=failed",
body: `{"result":{"kind":"task","status":{"state":"failed"}}}`,
wantKind: "failed",
},
{
name: "SDK error — task envelope state=canceled",
body: `{"result":{"kind":"task","status":{"state":"canceled"}}}`,
wantKind: "canceled",
},
{
name: "SDK error — task envelope state=rejected",
body: `{"result":{"kind":"task","status":{"state":"rejected"}}}`,
wantKind: "rejected",
},
{
name: "SDK error — result.kind=rate_limited",
body: `{"result":{"kind":"rate_limited","parts":[{"text":"error"}]}}`,