Compare commits

...

60 Commits

Author SHA1 Message Date
devops-engineer 491ce1d1f0 chore(ci): retrigger publish-workspace-server-image after op-config#110 deploy (internal#603) (#1591)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Detect changes (push) Successful in 13s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
E2E API Smoke Test / detect-changes (push) Successful in 17s
E2E Chat / detect-changes (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 19s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 14s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6s
E2E Chat / E2E Chat (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m27s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m11s
publish-workspace-server-image / build-and-push (push) Successful in 5m41s
publish-workspace-server-image / Production auto-deploy (push) Failing after 18s
CI / Platform (Go) (push) Successful in 6m17s
CI / Python Lint & Test (push) Successful in 6m58s
CI / Canvas (Next.js) (push) Successful in 7m10s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m15s
ci-required-drift / drift (push) Successful in 1m13s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 36s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m15s
main-red-watchdog / watchdog (push) Successful in 25s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 6m48s
gate-check-v3 / gate-check (push) Successful in 59s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
status-reaper / reap (push) Has started running
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Failing after 2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
gitea-merge-queue / queue (push) Successful in 7s
Retrigger publish-workspace-server-image after PR#110 op-config runner HOME fix. Required for workspace-server 100MB upload cap to reach tenants.

Approvals: core-devops, core-security, core-qa.

Co-Authored-By: hongming (CTO directive 2026-05-19)
2026-05-20 05:12:03 +00:00
devops-engineer a23c0217ae feat(uploads): bump cap to 100MB + correct-reason error messages (#1588)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 15s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
publish-canvas-image / Build & push canvas image (push) Successful in 1m53s
CI / Detect changes (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 22s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 16s
Harness Replays / detect-changes (push) Successful in 13s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 10s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 9s
publish-runtime-autobump / bump-and-tag (push) Successful in 38s
publish-runtime-autobump / pr-validate (push) Successful in 44s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
CI / Platform (Go) (push) Successful in 5m52s
CI / Canvas (Next.js) (push) Successful in 6m59s
CI / Python Lint & Test (push) Successful in 7m16s
Harness Replays / Harness Replays (push) Successful in 19s
CI / all-required (push) Successful in 6m33s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 2m35s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m30s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m29s
E2E Chat / E2E Chat (push) Failing after 6m45s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m31s
CI / Canvas Deploy Reminder (push) Successful in 1s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 53s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m14s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 34s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m15s
main-red-watchdog / watchdog (push) Successful in 32s
gate-check-v3 / gate-check (push) Successful in 20s
gitea-merge-queue / queue (push) Successful in 9s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 8m10s
status-reaper / reap (push) Failing after 53s
Bump chat upload cap 50MB → 100MB across canvas, workspace-server (Go), workspace (Python), and the nginx test harness. Pre-flight gates oversized files BEFORE network I/O so the user gets an immediate 'File too large (got X MB) — limit is 100MB' instead of a downstream timeout. Scaled abort-timeout (60s floor, ~100KB/s rate) replaces the fixed 60s that mis-attributed slow-uplink streams as 'timed out'. Resolves forensic a99ab0a1.

Approvers: core-devops (id=52), core-qa (id=64), core-security (id=68).

Follow-up: SSOT for upload cap (4 mirror sites) — see internal/<issue-tba>.
2026-05-20 03:36:27 +00:00
infra-runtime-be 5c989fef2f feat(uploads): bump cap to 100MB + correct-reason error messages
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
CI / Detect changes (pull_request) Successful in 11s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
E2E Chat / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
publish-runtime-autobump / pr-validate (pull_request) Successful in 34s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 5m5s
CI / Canvas (Next.js) (pull_request) Successful in 6m11s
CI / Python Lint & Test (pull_request) Successful in 7m17s
CI / all-required (pull_request) Successful in 6m33s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 2m27s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m24s
security-review / approved (pull_request) Refired via /security-recheck by unknown
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m56s
E2E Chat / E2E Chat (pull_request) Failing after 6m33s
audit-force-merge / audit (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m21s
CTO 2026-05-19 directive on forensic a99ab0a1 (reno-stars >50MB
upload that surfaced "signal timed out" when the real cause was
file-size + a fixed 60s client timeout):

  "if its file size issue, should have error that instead saying
   timeout which is wrong"

Bundles the cap raise + the wrong-reason fix in ONE PR because the
two are coupled — bumping the server alone would still leak the
fixed-60s timeout for legitimate slow uploads; fixing the client
alone would 413 every >50MB attempt.

Server (push-mode, EC2 workspace):
  - workspace-server/internal/handlers/chat_files.go:
      chatUploadMaxBytes 50→100 MB
      httpClient.Timeout 120→1200 s (matches the new slow-uplink budget)
  - workspace/internal_chat_uploads.py:
      CHAT_UPLOAD_MAX_BYTES 50→100 MB
      CHAT_UPLOAD_MAX_FILE_BYTES 25→100 MB (aligned with total so a
      single legitimate large file succeeds end-to-end)

Canvas:
  - canvas/src/components/tabs/chat/uploads.ts:
      MAX_UPLOAD_BYTES 100 MB constant + FileTooLargeError class
      pre-flight gate: file-size violation throws BEFORE any fetch,
        with the actionable "File too large (got X MB) — limit is 100MB"
      computeUploadTimeoutMs: 60s floor + 100 KB/s scaled deadline
        (was a fixed 60s — the root cause of the forensic)
  - canvas/src/components/tabs/chat/hooks/useChatSend.ts:
      mapUploadErrorToReason: routes each cause to ITS OWN message
        (FileTooLargeError | TimeoutError | server-Error | fallback)
      no conflation between file-size and connection-too-slow

Tests:
  - workspace-server chat_files_test.go: pins 100 MB constant,
    asserts sub-cap forwards + over-cap non-2xx
  - canvas uploads.cap.test.ts (10 cases): pre-flight gate, exact-cap
    edge, scaled-timeout curve, server-413 propagation, AbortSignal
    shape — explicit negative on "TimeoutError ≠ FileTooLargeError"
  - canvas useChatSend.errorReason.test.ts (5 cases): per-cause
    message contract, explicit negatives that guard against the
    wrong-reason conflation

Test harness mirror:
  - tests/harness/cf-proxy/nginx.conf: client_max_body_size 50m→100m
    (this is the harness mirror; the production CF / nginx tier is
    out-of-repo. If prod still caps at 50m, this mirror passes while
    prod 413s — surface to ops.)

Follow-up (SSOT, NOT in this PR):
  The 100 MB constant now lives in THREE mirror sites (canvas TS +
  workspace Python + platform Go). Per feedback_no_single_source_of_truth,
  the proper fix is exposing the cap via GET /uploads/limits so the
  client fetches the live value. Filing as a separate issue.

References:
  - task #295 (internal tracker; CTO-authorized this work)
  - forensic a99ab0a1 (reno-stars 2026-05-19)
  - feedback_surface_actionable_failure_reason_to_user (CTO 2026-05-17)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:23:04 -07:00
infra-sre 5e5e10a8dc ci(workflows): consolidate issue_comment subscribers — sop-checklist + review-refire (issue #1280) (#1333)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 5m35s
publish-workspace-server-image / Production auto-deploy (push) Failing after 17s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 10s
CI / Platform (Go) (push) Successful in 5m26s
CI / Shellcheck (E2E scripts) (push) Successful in 23s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 48s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 6m49s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 13s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 8s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 6s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 3s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Failing after 1m17s
CI / Python Lint & Test (push) Successful in 6m52s
CI / all-required (push) Successful in 6m45s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
E2E Chat / E2E Chat (push) Successful in 6s
CI / Canvas Deploy Reminder (push) Successful in 2s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 15s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m29s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m40s
gate-check-v3 / gate-check (push) Successful in 24s
main-red-watchdog / watchdog (push) Successful in 40s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 43s
ci-required-drift / drift (push) Successful in 1m8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 23s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m11s
gitea-merge-queue / queue (push) Successful in 9s
status-reaper / reap (push) Failing after 1m22s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m29s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m58s
Co-authored-by: Molecule AI Infra-SRE <infra-sre@agents.moleculesai.app>
Co-committed-by: Molecule AI Infra-SRE <infra-sre@agents.moleculesai.app>
2026-05-20 02:29:24 +00:00
core-be 52a31072a3 fix(autobump): trigger on scripts/build_runtime_package.py changes (#1580)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
CI / Detect changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 12s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 53s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Failing after 29s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
E2E Chat / E2E Chat (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
CI / Platform (Go) (push) Successful in 3m18s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m7s
publish-workspace-server-image / build-and-push (push) Successful in 5m53s
publish-workspace-server-image / Production auto-deploy (push) Failing after 20s
CI / Python Lint & Test (push) Successful in 7m9s
CI / Canvas (Next.js) (push) Successful in 7m25s
CI / all-required (push) Successful in 7m22s
CI / Canvas Deploy Reminder (push) Successful in 2s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
main-red-watchdog / watchdog (push) Successful in 29s
gate-check-v3 / gate-check (push) Successful in 20s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 22s
ci-required-drift / drift (push) Successful in 1m3s
gitea-merge-queue / queue (push) Successful in 6s
status-reaper / reap (push) Failing after 1m7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m47s
Co-authored-by: Molecule AI · core-be <core-be@agents.moleculesai.app>
Co-committed-by: Molecule AI · core-be <core-be@agents.moleculesai.app>
2026-05-20 00:27:39 +00:00
hongming 7b40a03c45 Merge pull request 'chore(workspace): trigger autobump for PDF P0 cure cascade' (#1583) from chore/trigger-autobump-2026-05-19 into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Failing after 2s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / all-required (push) Failing after 6s
E2E Chat / detect-changes (push) Successful in 13s
CI / Detect changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 17s
publish-runtime-autobump / pr-validate (push) Successful in 44s
publish-runtime-autobump / bump-and-tag (push) Successful in 45s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m42s
CI / Canvas (Next.js) (push) Successful in 4m4s
CI / Canvas Deploy Reminder (push) Successful in 1s
publish-workspace-server-image / build-and-push (push) Successful in 5m3s
CI / Platform (Go) (push) Successful in 5m19s
publish-workspace-server-image / Production auto-deploy (push) Failing after 17s
CI / Python Lint & Test (push) Successful in 6m22s
publish-runtime / publish (push) Failing after 2m30s
publish-runtime / cascade (push) Has been skipped
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Failing after 15s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 3m45s
main-red-watchdog / watchdog (push) Successful in 29s
gate-check-v3 / gate-check (push) Successful in 59s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Failing after 14s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Failing after 14s
ci-required-drift / drift (push) Successful in 1m7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 3m36s
gitea-merge-queue / queue (push) Successful in 8s
status-reaper / reap (push) Failing after 48s
2026-05-19 22:56:58 +00:00
core-be e9d32c09d3 chore(workspace): trigger autobump for python-multipart pin cascade
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 15s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 9s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
gate-check-v3 / gate-check (pull_request) Successful in 12s
security-review / approved (pull_request) Successful in 10s
qa-review / approved (pull_request) Successful in 10s
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request) Successful in 11s
publish-runtime-autobump / pr-validate (pull_request) Successful in 49s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
CI / Platform (Go) (pull_request) Successful in 3m8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m51s
CI / Canvas (Next.js) (pull_request) Successful in 4m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m37s
CI / all-required (pull_request) compensating status: action_run_job all-required status=1 (Success) on this SHA in gitea DB; emitter-null masked propagation per feedback_gitea_emitter_null_state_blocks_merge + internal#591/#592. Non-author validation per BP intent.
audit-force-merge / audit (pull_request) Successful in 7s
2026-05-19 15:33:54 -07:00
core-be e89f0ce605 fix(workspace-server): rename workspace_secrets MODEL_PROVIDER → MODEL (#1581)
CI / Python Lint & Test (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
CI / Platform (Go) (push) Failing after 2s
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / all-required (push) Failing after 8s
E2E API Smoke Test / detect-changes (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 12s
CI / Detect changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Failing after 13s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Failing after 13s
E2E Staging External Runtime / E2E Staging External Runtime (push) Failing after 23s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 19s
Harness Replays / detect-changes (push) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 11s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 40s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 34s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m47s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m10s
CI / Canvas (Next.js) (push) Successful in 4m54s
CI / Canvas Deploy Reminder (push) Successful in 2s
publish-workspace-server-image / build-and-push (push) Successful in 5m10s
publish-workspace-server-image / Production auto-deploy (push) Failing after 19s
E2E Chat / E2E Chat (push) Failing after 6m19s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Failing after 13s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Failing after 15s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 2m17s
gitea-merge-queue / queue (push) Successful in 3s
status-reaper / reap (push) Failing after 1m0s
3-reviewer relay: core-devops + core-security + core-qa APPROVED. Fixes the MODEL_PROVIDER naming-confusion (abe512c2 finding). Migration 20260519000000 is idempotent. CI/all-required green.
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-19 22:31:23 +00:00
core-be 1278d57c12 fix(workspace/deps): pin python-multipart>=0.0.27 for chat-upload Starlette parser (#1578)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 17s
E2E Chat / detect-changes (push) Successful in 19s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 9s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
CI / Platform (Go) (push) Successful in 2m36s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
E2E Chat / E2E Chat (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 54s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m18s
publish-runtime / publish (push) Failing after 4m10s
publish-runtime / cascade (push) Has been skipped
publish-workspace-server-image / build-and-push (push) Successful in 5m53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m0s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / Python Lint & Test (push) Successful in 6m58s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
publish-workspace-server-image / Production auto-deploy (push) Failing after 15s
CI / Canvas (Next.js) (push) Successful in 5m58s
CI / all-required (push) Successful in 6m59s
main-red-watchdog / watchdog (push) Successful in 48s
gate-check-v3 / gate-check (push) Successful in 1m13s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
ci-required-drift / drift (push) Successful in 29s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 44s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
gitea-merge-queue / queue (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 17s
status-reaper / reap (push) Failing after 1m9s
Closes PDF upload P0 root cause: Starlette Request.form() requires python-multipart at parse time. Without it, chat-uploads surfaced an opaque 400. Mirrors workspace/requirements.txt floor; >=0.0.27 avoids CVE-2024-53981.

Fresh APPROVEs on 940bae15:
- core-devops: review id=4879
- core-security: review id=4878
- core-qa: review id=4880
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-19 21:41:06 +00:00
core-devops 14d91ef032 Merge pull request 'fix(workspace/chat_uploads): surface exception class + detail in 400 response' (#1575) from fix/chat-uploads-surface-exception-in-400 into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 21s
CI / Shellcheck (E2E scripts) (push) Successful in 27s
CI / Detect changes (push) Successful in 40s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 27s
E2E Chat / detect-changes (push) Successful in 28s
E2E API Smoke Test / detect-changes (push) Successful in 28s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 9s
publish-runtime-autobump / pr-validate (push) Successful in 54s
publish-runtime-autobump / bump-and-tag (push) Successful in 52s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 15s
CI / Python Lint & Test (push) Successful in 6m1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m0s
publish-workspace-server-image / build-and-push (push) Successful in 7m37s
publish-workspace-server-image / Production auto-deploy (push) Failing after 26s
CI / Platform (Go) (push) Successful in 7m23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m22s
CI / Canvas (Next.js) (push) Successful in 7m55s
CI / all-required (push) Successful in 7m34s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 10s
ci-required-drift / drift (push) Successful in 1m5s
CI / Canvas Deploy Reminder (push) Successful in 2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 12s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m42s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m55s
gitea-merge-queue / queue (push) Successful in 6s
status-reaper / reap (push) Failing after 1m19s
2026-05-19 21:10:46 +00:00
core-be 5f6aa3da69 fix(workspace/chat_uploads): surface exception class + detail in 400 response
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
publish-runtime-autobump / pr-validate (pull_request) Successful in 40s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
qa-review / approved (pull_request) Failing after 6s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
CI / Platform (Go) (pull_request) Successful in 2m35s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m22s
CI / Canvas (Next.js) (pull_request) Successful in 5m11s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 6m57s
audit-force-merge / audit (pull_request) Successful in 14s
Hermes workspace PDF upload returned opaque 400 'failed to parse multipart
form' (forensic a78762a0 2026-05-19). Triage took ~25 min because the
response carried no information about WHICH exception class or WHY the
parser bailed — the underlying cause was a missing python-multipart dep
in the PyPI runtime (fixed separately in
molecule-ai-workspace-runtime#TBD).

Per feedback_surface_actionable_failure_reason_to_user (CTO 2026-05-17):
user-facing failures MUST tell the user WHY. This patch surfaces
exception class + str(exc) in the 400 JSON body, keeping the top-level
'error' key unchanged so existing canvas / alert rules keep matching.

Salvage note on mc#1524 (the wrong-RCA PR, closed):
mc#1524 attributed the 400 to Starlette's max_part_size limit and
proposed bumping it. That diagnosis was incorrect — Starlette only
enforces max_part_size on form FIELDS (text values), not on file PARTS,
so a 5 MB PDF would not trip that limit regardless of the value. The
useful idea from mc#1524 — surfacing the failure reason to the
caller — is salvaged here as a separate, narrowly-scoped change.

Adds unit test test_malformed_multipart_returns_exception_class_and_detail
which sends a boundary-mismatched body, asserts 400, and pins the
response shape (error/exception/detail keys present).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:40:29 -07:00
core-devops 01226cfc73 audit: phase 1 structured audit-log — emit pkg + secrets wire-in (#1572)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Successful in 9s
E2E Chat / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
Harness Replays / detect-changes (push) Successful in 13s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 11s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 2m28s
publish-workspace-server-image / build-and-push (push) Successful in 6m37s
CI / Python Lint & Test (push) Successful in 6m0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
Harness Replays / Harness Replays (push) Successful in 2s
publish-workspace-server-image / Production auto-deploy (push) Failing after 26s
CI / Platform (Go) (push) Successful in 6m54s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 47s
CI / Canvas (Next.js) (push) Successful in 7m53s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m13s
E2E Chat / E2E Chat (push) Failing after 6m38s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
main-red-watchdog / watchdog (push) Successful in 27s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m35s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m37s
gate-check-v3 / gate-check (push) Successful in 21s
gitea-merge-queue / queue (push) Successful in 11s
status-reaper / reap (push) Failing after 1m23s
2026-05-19 20:30:25 +00:00
core-devops 7054b75650 fix(ci): main-red-watchdog skips cancel-cascade entries (mc#1564) (#1571)
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 forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Detect changes (push) Successful in 13s
publish-workspace-server-image / build-and-push (push) Successful in 5m51s
CI / Shellcheck (E2E scripts) (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 20s
publish-workspace-server-image / Production auto-deploy (push) Failing after 33s
E2E Chat / detect-changes (push) Successful in 11s
CI / Canvas (Next.js) (push) Has been cancelled
CI / all-required (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
Handlers Postgres Integration / detect-changes (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
CI / Platform (Go) (push) Has been cancelled
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 26s
gitea-merge-queue / queue (push) Successful in 14s
status-reaper / reap (push) Failing after 1m20s
2026-05-19 20:23:42 +00:00
core-devops dd4bba8913 Merge branch 'main' into infra-sre/audit-log-phase1-emit-secrets
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 9s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
gate-check-v3 / gate-check (pull_request) Successful in 8s
qa-review / approved (pull_request) Successful in 5s
security-review / approved (pull_request) Successful in 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / Platform (Go) (pull_request) Successful in 5m45s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m56s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 7m8s
CI / Canvas (Next.js) (pull_request) Successful in 8m40s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m53s
CI / all-required (pull_request) Successful in 8m42s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 13s
E2E Chat / E2E Chat (pull_request) Failing after 7m43s
2026-05-19 20:17:28 +00:00
core-devops 876ef122be Merge branch 'main' into fix/main-red-watchdog-skip-cancel-cascade-mc1564
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 25s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 2m35s
sop-tier-check / tier-check (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 5m41s
CI / Python Lint & Test (pull_request) Successful in 7m13s
CI / all-required (pull_request) Successful in 7m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-19 20:07:19 +00:00
core-devops b5b95de19a test(audit): bind HashValuePrefix calls to vars to satisfy staticcheck SA4000
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 20s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 34s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 37s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 26s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m20s
CI / Platform (Go) (pull_request) Successful in 5m2s
CI / Canvas (Next.js) (pull_request) Successful in 6m40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m26s
Harness Replays / Harness Replays (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Failing after 1m20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 7m27s
CI / all-required (pull_request) Successful in 7m29s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m30s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Staticcheck SA4000 flagged the stability assertion as tautological (identical expressions on both sides of !=). Bind both calls to local vars to preserve test intent (call-stability) and silence the linter. No functional change.

Follow-up to mc#1572 review (core-devops lens).
2026-05-19 20:00:26 +00:00
hongming 302235da23 Merge pull request 'build(ws-server): -trimpath -ldflags="-s -w" (RFC#563)' (#1570) from feat/rfc563-ws-server-binary-strip into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Detect changes (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E Chat / detect-changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 18s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 15s
Harness Replays / Harness Replays (push) Successful in 5s
CI / Platform (Go) (push) Successful in 2m37s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m59s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 55s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m40s
publish-workspace-server-image / build-and-push (push) Successful in 5m42s
publish-workspace-server-image / Production auto-deploy (push) Failing after 16s
CI / Python Lint & Test (push) Successful in 6m59s
CI / Canvas (Next.js) (push) Successful in 7m19s
CI / all-required (push) Successful in 7m23s
E2E Chat / E2E Chat (push) Failing after 7m15s
CI / Canvas Deploy Reminder (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
main-red-watchdog / watchdog (push) Successful in 42s
gate-check-v3 / gate-check (push) Successful in 30s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Failing after 7s
gitea-merge-queue / queue (push) Has started running
status-reaper / reap (push) Failing after 26s
ci-required-drift / drift (push) Successful in 1m16s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m47s
2026-05-19 19:51:15 +00:00
infra-sre 7c751ef675 audit: phase 1 structured audit-log — emit pkg + secrets wire-in
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Failing after 2m33s
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
CI / all-required (pull_request) Failing after 50s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 8s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 27s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-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
security-review / approved (pull_request) Failing after 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m21s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m25s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6m9s
CI / Python Lint & Test (pull_request) Successful in 7m11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 52s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m27s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Failing after 6m11s
Add internal/audit with single Emit(ctx, event_type, fields) entrypoint
that ships JSON-encoded records via two transports:

  1. audit:-prefixed stdout line — tenant Vector docker-logs source
     already ships this to Loki. No obs-stack change required.
  2. Best-effort append to /var/log/molecule-audit.jsonl — durable
     forensic copy, target for the dedicated Vector file source in
     Phase 2.

Schema is stable v1 (ts, event_type, workspace_id, user_id, actor_kind,
correlation_id, fields). Cardinality budget keeps workspace_id +
user_id + correlation_id OUT of Loki labels (JSON body only) — fleet
active-stream count ~200, well within Loki headroom.

Phase 1 wires secret.set and secret.delete on the workspace-scoped
(POST/PUT/DELETE /workspaces/:id/secrets) and admin-scoped (POST/DELETE
/admin/secrets, /settings/secrets) handlers. value_hash is the first 8
hex chars of sha256(value) — never the raw value.

Tests cover: stdout emit, JSONL append, file-failure fallback,
concurrent integrity, hash bounds, raw-value-never-emitted contract.
Vet + handler-secret tests pass.

See: rfc internal/rfcs/audit-log-to-loki.md
2026-05-19 12:22:35 -07:00
core-be c7b523a0a9 ci(security): task #146 lint — no GITEA/GITHUB token in tenant-writer paths (#1565)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 22s
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 3s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 3s
Lint no tenant GITEA/GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 16s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Failing after 26s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m32s
publish-workspace-server-image / build-and-push (push) Successful in 6m40s
publish-workspace-server-image / Production auto-deploy (push) Failing after 25s
CI / Platform (Go) (push) Successful in 5m35s
E2E Chat / E2E Chat (push) Successful in 2s
CI / Canvas (Next.js) (push) Successful in 7m1s
CI / Python Lint & Test (push) Successful in 7m45s
CI / all-required (push) Successful in 7m59s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 48s
CI / Canvas Deploy Reminder (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m50s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m2s
gitea-merge-queue / queue (push) Successful in 7s
status-reaper / reap (push) Failing after 57s
ci(security): RFC#523 lint — no GITEA/GITHUB token in tenant-writer paths (#1565)

Three non-author APPROVEs (core-devops, core-security, core-qa) + CI/all-required green.
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-19 19:19:28 +00:00
core-devops fcf08647c5 fix(ci): main-red-watchdog skips cancel-cascade entries — closes #1564
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 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 9s
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 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 19s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 6s
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 7s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m22s
CI / Canvas (Next.js) (pull_request) Successful in 3m46s
CI / Platform (Go) (pull_request) Successful in 5m49s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Successful in 24s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 6m48s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Gitea maps BOTH `action_run.status=2` (Failure) AND `status=3` (Cancelled)
to commit-status string `"failure"`. On a busy `main` with
`concurrency: cancel-in-progress: true`, every merge burst cancels prior
in-flight runs (status=3) — those bubble to the combined-status `failure`
rollup and inflate the watchdog's red%, generating phantom `[main-red]`
issues (mc#1562/#1552/#1540/#1532/#1527/#1526/#1522/#1503/#1487/#1484).

Per mc#1564 the cleanest filter at this layer is option B (description
string): cancelled-run entries carry description `"Has been cancelled"`,
real failures carry `"Failing after Ns"`. is_red() now excludes the
former from the failed[] list, and combined=failure alone (no per-entry
detail) only trips red when statuses[] is empty (the CI-emitter-direct
edge case from render_body's existing fallback).

Match is description == "Has been cancelled" exactly (after strip), not
substring, so a hypothetical real-failure log line containing that
phrase still counts as red.

Canonical Gitea 1.22.6 enum per `models/actions/status.go`:
  1=Success, 2=Failure, 3=Cancelled, 4=Skipped,
  5=Waiting, 6=Running, 7=Blocked
(reference: operator memory
 reference_gitea_action_status_enum_corrected_2026_05_19
 + reference_chronic_red_sweep_cancelled_vs_failed_filter)

Tests (6 new, all 36 in suite pass locally):
  - cancel-cascade entry alone → not red
  - real-failure entry alone → red (no over-filter)
  - mixed cancel + real → red, failed[] contains only real failures
  - all entries cancelled → not red (the phantom-issue case)
  - combined=failure + empty statuses[] → still red (preserve fallback)
  - exact-match contract (substring would over-match)

Refs:
  - mc#1564
  - mc#1529 (chronic-red triage that surfaced this)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
core-be 244263430d build(ws-server): add -trimpath -ldflags="-s -w" for smaller image (RFC#563)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 22s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
gate-check-v3 / gate-check (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 5s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Platform (Go) (pull_request) Successful in 2m49s
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
CI / Canvas (Next.js) (pull_request) Successful in 6m40s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
E2E Chat / E2E Chat (pull_request) Failing after 1m22s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m43s
CI / Python Lint & Test (pull_request) Successful in 7m35s
CI / all-required (pull_request) Successful in 7m46s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 6s
Mirror the pattern already used in molecule-controlplane/Dockerfile.
Currently workspace-server only sets -X buildinfo.GitSHA; add -trimpath
plus -s -w (strip symbol table + DWARF debug info) inside the same
-ldflags string. The -X GitSHA injection is preserved (verified via
strings(1) on locally-built binary).

Empirical local measurement (CGO_ENABLED=0 GOOS=linux GOARCH=amd64,
go 1.26.3, /platform binary only):

  before  44,669,544 bytes  (42 MB)
  after   31,191,202 bytes  (29 MB)
  delta   13,478,342 bytes  (12 MB) — 30.2% reduction

RFC#563 reports the published *image* deltas as 87 -> 61 MB (-26 MB,
~29%); the per-image figure is larger than the per-binary figure
because both /platform and /memory-plugin are stripped, and the
binary is one layer of the multi-layer image.

Flag semantics (Go 1.26):
  -trimpath          strip absolute build-host paths from object code
                     (also improves reproducibility)
  -ldflags "-s -w"   linker drops symbol table (-s) and DWARF debug
                     info (-w); -X-injected strings are NOT in the
                     symbol table so GitSHA survives stripping

Single-purpose change: only ws-server Dockerfile + Dockerfile.tenant
touched; no behavioral changes to the binaries themselves.
2026-05-19 12:03:21 -07:00
documentation-specialist cf1438a525 docs: fix stale channel-install flag + dead GitHub-org refs (task #230) (#1566)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 4m53s
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
CI / Detect changes (push) Successful in 19s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 3s
E2E Chat / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 10s
CI / Platform (Go) (push) Successful in 5m52s
CI / Python Lint & Test (push) Successful in 7m1s
CI / Canvas (Next.js) (push) Successful in 8m11s
CI / all-required (push) Successful in 7m59s
publish-workspace-server-image / Production auto-deploy (push) Successful in 13m29s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
E2E Chat / E2E Chat (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m53s
CI / Canvas Deploy Reminder (push) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m4s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 32s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Has started running
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m57s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m16s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Failing after 5m27s
Runtime Pin Compatibility / PyPI-latest install + import smoke (push) Successful in 44s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m14s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 12s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 7m7s
main-red-watchdog / watchdog (push) Successful in 25s
gate-check-v3 / gate-check (push) Successful in 1m5s
status-reaper / reap (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
gitea-merge-queue / queue (push) Successful in 11s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m4s
ci-required-drift / drift (push) Successful in 28s
Co-authored-by: documentation-specialist <documentation-specialist@agents.moleculesai.app>
Co-committed-by: documentation-specialist <documentation-specialist@agents.moleculesai.app>
2026-05-19 04:05:16 +00:00
hongming e27ce29e81 Merge pull request 'seed(workspaces): production-team agent identity (internal#492 followup to #1427)' (#1563) from feat/agent-card-identity-seed-prod-team-internal-492-followup into main
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
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 shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / all-required (push) Failing after 2s
CI / Platform (Go) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
Harness Replays / detect-changes (push) Successful in 3s
CI / Detect changes (push) Successful in 14s
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m19s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 42s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
CI / Python Lint & Test (push) Successful in 6m57s
Harness Replays / Harness Replays (push) Successful in 2s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
2026-05-19 03:59:35 +00:00
hongming ec4c8d81ae Merge pull request 'fix(handlers): RFC#524 Layer 1 — convert bare-go sites to goAsync/globalGoAsync' (#1559) from fix/rfc524-layer1-bare-go-conversion into main
CI / all-required (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (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 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 shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 44s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Failing after 1m25s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 4m40s
2026-05-19 03:55:03 +00:00
infra-runtime-be 75b51028c3 seed(workspaces): production-team agent identity (internal#492 followup to #1427)
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
Check migration collisions / Migration version collision check (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 9s
security-review / approved (pull_request) Successful in 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 5m32s
CI / Python Lint & Test (pull_request) Successful in 6m42s
CI / Canvas (Next.js) (pull_request) Successful in 7m16s
CI / all-required (pull_request) Successful in 6m52s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m18s
audit-force-merge / audit (pull_request) Successful in 6s
PR #1427 added the platform-side reconcile (`agent_card_reconcile.go`)
that pulls workspaces.name and workspaces.role into the stored
agent_card on /registry/register. The reconcile only ever FILLS gaps —
without a populated workspaces row it has nothing to substitute and
the prod-team cards keep showing name=UUID / description="" / role=null
(the exact gap internal#492 is filed against).

This migration seeds name, role, and the agent_card JSONB
(description + skills[]) for the 6 CTO-locked production-team
workspaces (PM, Reviewer, Researcher, Dev-A, Dev-B, CEO-Assistant).
Idempotent UPDATEs only — no INSERTs, no schema change, zero behaviour
change for any workspace outside the prod team.

Schema sources (vendor-doc-checked):
- workspaces.{name,role} columns: 001_workspaces.sql
- agent_card JSONB shape (name/description/skills[{id,name,description,tags,examples}]/role): workspace/main.py:197-222
- validateWorkspaceFields contract (name<=255, role<=1000, no YAML
  special chars `{}[]|>*&!`, no newline/CR): workspace-server/internal/handlers/workspace_crud.go:526

CEO-Assistant uses the full UUID known from
workspace-server/internal/handlers/chat_files_test.go:286. The other
five rows are matched by 8-char prefix LIKE — the CTO will confirm on
review that each prefix resolves to a single tenant row.

NOT merged — CTO review pending per the dev-tree two-eyes gate.
2026-05-19 03:41:27 +00:00
core-be 6597e2408f fix(handlers): forward-port RFC#524 Layer 1 — convert bare-go sites to goAsync/globalGoAsync
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 3m47s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 4m47s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Failing after 1m23s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
security-review / approved (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
CI / Python Lint & Test (pull_request) Successful in 6m59s
CI / all-required (pull_request) Successful in 6m44s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m31s
audit-force-merge / audit (pull_request) Successful in 4s
RFC internal#524 Layer 1 deliverable 2: extend the canonical db.DB
race-fix primitive (69d9b4e3, already on main via the 0e13a801
staging-promote) to the ~25 sibling bare-`go` sites that 69d9b4e3 left
untouched. Without this, a SecretsHandler.Set's detached restartFunc, or
a2a_proxy's extractAndUpsertTokenUsage, or a delegation goroutine still
races a later test's setupTestDB t.Cleanup db.DB swap — exactly the
data-race class that 69d9b4e3 fixed for the WorkspaceHandler path.

What changed
============

- workspace.go: add package-level `globalAsync` sync.WaitGroup +
  `globalGoAsync(fn)` helper + `waitGlobalAsyncForTest()` drain. Same
  shape as h.goAsync but reachable from sibling handlers that don't
  carry a *WorkspaceHandler.
- handlers_test.go: drainTestAsync now drains globalAsync alongside the
  per-handler asyncWGs.
- Converted bare-`go` → tracked goroutine at 27 call sites:
    secrets.go (7)            — restartFunc fan-out + restartAllAffected
    templates.go (6)          — h.wh.RestartByID after file/template ops
    template_import.go (3)    — h.wh.RestartByID after Import/ReplaceFiles
    plugins_install.go (2)    — restartFunc after uninstall (both paths)
    plugins_install_pipeline.go (2) — restartFunc after install
    admin_plugin_drift.go (1) — restartFunc on drift apply
    registry.go (1)           — drainQueue on heartbeat capacity
    a2a_proxy.go (1)          — extractAndUpsertTokenUsage (db.DB INSERT)
    delegation.go (1)         — executeDelegation (DB-touching pipeline)
    mcp_tools.go (1)          — async MCP delegate (db.DB read+write)
    channels.go (1)           — async HandleInbound webhook delivery
    org_import.go (1)         — provisionWorkspaceAuto fan-out
- Annotated 6 connection/lifecycle-scoped goroutines with
  `goAsync-exempt` (RFC Layer 2.2 contract):
    a2a_proxy.go applyIdleTimeout — SSE idle-timer, no db.DB access
    socket.go (2)              — WebSocket Read/WritePump, conn-lifetime
    terminal.go (3)             — PTY <-> WS bridges, conn-lifetime
    eic_tunnel_pool.go (group)  — pool janitor + cleanup closures
- rfc524_layer1_async_drain_test.go: new regression test asserting
  drainTestAsync waits for BOTH per-handler asyncWG AND the package-level
  globalAsync — fails fast if either drain side is dropped.

Verification
============

- `go vet ./internal/handlers/`           : clean
- `go test -race -count=1  ./internal/handlers/`  : ok 28.6s
- `go test -race -count=10 ./internal/handlers/`  : ok 4m15s (RFC Layer 5
                                                    nightly target)
- `go test -race -shuffle=on -count=1 ...`         : ok 26.6s

The 4 `TestExecuteDelegation_*` tests were already un-Skipped on main
(via the staging→main backsync); Layer 1.3 of the RFC is therefore
already satisfied. Verified passing under -race in this run.

Layer 1 of RFC internal#524 is now complete on main. Layers 2-5 stay
as separate PRs per the RFC sequencing.

Refs
====
- RFC internal#524 (5-layer roadmap)
- molecule-core commit 69d9b4e3 (canonical fix on staging, promoted to main via 0e13a801)
- molecule-core#664, #774 (continue-on-error masks)
- task #240 (no staging→main auto-promotion — why the gap existed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:40:48 +00:00
hongming 517327aa1e Merge pull request 'fix(ci): repair docker-host guardrail follow-up' (#1561) from fix/ci-docker-host-guardrail-red into main
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 26s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
CI / Platform (Go) (push) Successful in 2m41s
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 3s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 24s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 2s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Failing after 1m17s
publish-workspace-server-image / build-and-push (push) Successful in 6m40s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m17s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m15s
CI / Canvas (Next.js) (push) Successful in 6m55s
CI / Python Lint & Test (push) Successful in 7m8s
CI / all-required (push) Successful in 7m5s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m45s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
2026-05-19 03:39:50 +00:00
claude-ceo-assistant 00351b4551 fix(ci): repair docker-host guardrail follow-up
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 11s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Failing after 1m10s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 31s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m35s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 35s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m46s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 29s
security-review / approved (pull_request) Failing after 17s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-tier-check / tier-check (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Chat / E2E Chat (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 4m58s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m12s
CI / Canvas (Next.js) (pull_request) Successful in 6m13s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 7m11s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-18 19:50:13 -07:00
hongming c6e89219e1 Merge pull request 'ci: pin docker-bound workflows to docker-host + add lint guardrail (mc#1529 follow-on, internal#512)' (#1558) from ci/docker-host-pin-mc-1529-followon into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 6m9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
CI / Detect changes (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Failing after 19s
CI / all-required (push) Failing after 8s
publish-workspace-server-image / Production auto-deploy (push) Failing after 44s
E2E API Smoke Test / detect-changes (push) Successful in 23s
E2E Chat / detect-changes (push) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 13s
Handlers Postgres Integration / detect-changes (push) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 16s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Failing after 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 29s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m45s
CI / Platform (Go) (push) Successful in 5m27s
CI / Canvas (Next.js) (push) Successful in 6m38s
CI / Python Lint & Test (push) Successful in 6m57s
CI / Canvas Deploy Reminder (push) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Harness Replays / Harness Replays (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m49s
E2E Chat / E2E Chat (push) Failing after 1m21s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m43s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
main-red-watchdog / watchdog (push) Successful in 31s
gate-check-v3 / gate-check (push) Successful in 20s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
ci-required-drift / drift (push) Successful in 1m12s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m23s
gitea-merge-queue / queue (push) Successful in 7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m7s
status-reaper / reap (push) Successful in 1m6s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m18s
2026-05-19 02:16:37 +00:00
hongming c8fbcced3d Merge pull request 'fix(ci): pin handlers-postgres-integration to docker-host label (mc#1529)' (#1543) from fix/handlers-pg-pin-docker-host-mc1529 into main
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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 Chat / detect-changes (push) Waiting to run
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) / 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 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
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 15s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 20s
2026-05-19 02:13:02 +00:00
hongming 685f6d19f4 Merge pull request 'test(e2e): fix-specific coverage for today's merged PRs (mc#1525/1535/1536/1539/1542)' (#1557) from test/e2e-todays-pr-coverage into main
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Successful in 4m40s
CI / Detect changes (push) Successful in 10s
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
CI / Shellcheck (E2E scripts) (push) Failing after 16s
publish-workspace-server-image / Production auto-deploy (push) Failing after 4m33s
CI / all-required (push) Failing after 9s
E2E API Smoke Test / detect-changes (push) Successful in 19s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 10s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
CI / Platform (Go) (push) Successful in 2m40s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m18s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m24s
CI / Canvas (Next.js) (push) Successful in 6m9s
CI / Python Lint & Test (push) Has been cancelled
main-red-watchdog / watchdog (push) Successful in 28s
gate-check-v3 / gate-check (push) Successful in 1m0s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 8m4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m52s
2026-05-19 01:57:33 +00:00
hongming f5cc9493bb Merge pull request 'feat(security): RFC#523 3-layer forbidden-env guardrail for tenant workspaces (task #146)' (#1555) from feat/146-forbidden-env-guard into main
CI / all-required (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (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
Runtime PR-Built Compatibility / 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 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 / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Harness Replays / detect-changes (push) Successful in 15s
publish-runtime-autobump / pr-validate (push) Successful in 32s
publish-runtime-autobump / bump-and-tag (push) Successful in 38s
Harness Replays / Harness Replays (push) Successful in 3s
2026-05-19 01:57:30 +00:00
hongming 71ad3ffe1d Merge pull request 'fix(sop-checklist): widen ack eligibility per RFC#450 Option C (closes internal#442)' (#1554) from fix/sop-checklist-widen-ack-internal-442 into main
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
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 shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m35s
2026-05-19 01:57:08 +00:00
hongming a3fc350c6e Merge pull request 'test(e2e): local prod-mimic backend for peer-visibility MCP gate + make e2e-peer-visibility (task #166)' (#1551) from e2e/peer-visibility-local-backend-task166 into main
CI / all-required (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (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 Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
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 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 / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (push) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Failing after 1m18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m8s
2026-05-19 01:57:06 +00:00
hongming 57364c1bed Merge pull request 'ci: arm64-lane pilot (additive shellcheck on Mac runner) [#233]' (#1553) from ci/mac-arm64-pilot-shellcheck into main
CI / all-required (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (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-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 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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
2026-05-19 01:56:16 +00:00
hongming acc149e18e Merge pull request 'fix(canvas/chat): surface actionable error reason in chat banner + link to Activity tab (internal#212)' (#1550) from fix/canvas-surface-error-detail into main
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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-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 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 / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 2m50s
2026-05-19 01:56:12 +00:00
hongming 83ad7e252b Merge pull request 'fix(workspace-server): surface secret-safe error_detail on ACTIVITY_LOGGED (internal#212)' (#1549) from fix/wsserver-broadcast-error-detail into main
CI / Platform (Go) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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
CI / Canvas (Next.js) (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
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
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
2026-05-19 01:56:10 +00:00
hongming 509bad2c68 ci: pin docker-bound workflows to docker-host + add lint guardrail
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 27s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m13s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m29s
CI / Platform (Go) (pull_request) Successful in 5m45s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Failing after 11s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m35s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
gate-check-v3 / gate-check (pull_request) Successful in 10s
qa-review / approved (pull_request) Failing after 8s
security-review / approved (pull_request) Failing after 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m37s
sop-tier-check / tier-check (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 7m13s
CI / Python Lint & Test (pull_request) Successful in 6m52s
CI / all-required (pull_request) Successful in 6m34s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m31s
Harness Replays / Harness Replays (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
E2E Chat / E2E Chat (pull_request) Failing after 1m53s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m28s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 8s
Class defect (internal#512 + mc#1529 + today's oc#81/82/83 + autogen#8):
the `ubuntu-latest` label is advertised by BOTH the Linux operator-host
runners (molecule-runner-*) AND Windows act_runner v1.0.3 on
hongming-pc-runner-*. Job placement is non-deterministic. When a
docker-bound job lands on a Windows runner, `docker run`/`docker
login`/`docker compose` fail with platform-specific errors and the
job hard-fails — placement-dependent, not transient.

Followon to mc#1543 (handlers-postgres-integration). Three more lanes
needed the same pin:

- e2e-api.yml: docker run/exec for postgres + redis containers
- e2e-chat.yml: docker run/exec for postgres + redis containers
- harness-replays.yml: docker compose ... ps/logs for tenant-alpha/beta

canvas-deploy-reminder is NOT pinned — its `docker compose ...` only
appears inside a markdown heredoc written to GITHUB_STEP_SUMMARY; it
does not exec docker.

Adds `lint-required-workflows-docker-host-pinned.yml` to catch future
regressions: any workflow whose YAML touches `docker exec` or uses
docker/* actions but doesn't pin every job's runs-on to `docker-host`
or `publish` fails the lint. Comment-only mentions of docker are
excluded (strip-`#` lines before regex). Fail-closed (per
feedback_never_skip_ci). This eliminates the manual-pin maintenance
burden the CTO flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:42:33 -07:00
hongming d27df740f5 Merge pull request 'fix(ws-server): close self-fire restart feedback loop (internal#544)' (#1556) from fix/ws-server-self-fire-restart-loop into main
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Successful in 5m15s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 24s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
CI / Platform (Go) (push) Successful in 5m20s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m16s
CI / Python Lint & Test (push) Successful in 6m53s
CI / Canvas (Next.js) (push) Successful in 7m19s
CI / all-required (push) Successful in 7m8s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
2026-05-19 01:40:54 +00:00
core-qa ab8ff865e4 test(e2e): add fix-specific E2E coverage for today's merged PRs (mc#1525/1535/1536/1539/1542)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 27s
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 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m26s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 5m45s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m22s
qa-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 4s
security-review / approved (pull_request) Failing after 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 6m54s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m30s
CI / Python Lint & Test (pull_request) Successful in 6m55s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m7s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 4s
Per E2E coverage audit 2026-05-18: today's merged platform PRs landed
with unit-test coverage only. This adds one consolidated bash E2E
(tests/e2e/test_today_pr_coverage_e2e.sh) that exercises each fix
through the real HTTP / activity-log path with no mocks of the
unit-under-fix, and wires it into the existing e2e-api.yml lane after
the poll-mode chat-upload step.

What the test asserts:

- Section A (mc#1535 + mc#1536): provisions two workspaces back-to-back,
  pulls /workspaces/:id/external/connection, regex-extracts the
  `claude mcp add <NAME>` server slug from each install snippet, and
  asserts (1) both start with `molecule-` (per-workspace, not literal
  `molecule`) and (2) the two slugs DIFFER (no overwrite class). Codex
  TOML table key uniqueness is checked too when the codex tab is in the
  build.
- Section B (mc#1525 + mc#1542): probes /admin/workspaces/:id/debug for
  the presence of GIT_HTTP_USERNAME and GIT_ASKPASS keys in
  workspace_secrets — pre-#1542 the GIT_HTTP_* key was absent entirely;
  pre-#1525 there was no env-only askpass wiring. Value-emptiness is
  tolerated on the dev platform where no persona is seeded (presence is
  the post-fix regression contract).
- Section C (mc#1539): self-delegates via POST /workspaces/:id/delegate
  with target=self and asserts (a) the API gate returns structured
  rejection OR (b) no activity_logs rows with source_id=our_uuid AND
  method != 'delegate_result' surface — the inbox-poller predicate the
  fix added. Polls activity for 2s, counts violating rows, fails closed
  on > 0.

Why E2E (not just unit):
- mc#1525 + mc#1542 ship a unit test that only checks the loader; the
  REAL contract is "git ls-remote rc=0 inside the container with the
  env the provisioner builds". This test probes the produced
  workspace_secrets map at the platform end — one step short of in-
  container exec, which the e2e-api lane lacks docker-exec privilege
  for, but materially closer than the loader-only unit.
- mc#1535 + mc#1536 unit-tested the slug helper in isolation; the bug
  was that the SNIPPET STRINGS shipped to the user still had hardcoded
  `molecule` in the codex/openclaw/hermes branches. The E2E pulls the
  literal user-facing strings.
- mc#1539 unit-tested the inbox _is_self_echo predicate; the E2E hits
  the actual /delegate → activity-log → poll path.

Test pattern follows tests/e2e/test_activity_e2e.sh (set -uo pipefail,
check/check_not helpers, BASE default, cleanup at end). EXIT cleanup
deletes both provisioned workspaces.

Time-bound: 60s default, override via E2E_TIMEOUT. CI-runnable on the
existing e2e-api lane (postgres + redis + workspace-server already
provisioned earlier in the same job).

Refs: PR audit memo 2026-05-18; pairs with
feedback_verify_actual_endstate_not_ack_follow_sop (presence-of-end-
state-not-ack rule).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:30:33 -07:00
core-devops 4bf87d122d fix(ws-server): close self-fire restart feedback loop (internal#544)
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
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 4s
Harness Replays / detect-changes (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4m38s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 5s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 6m11s
CI / Python Lint & Test (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 6m29s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m12s
audit-force-merge / audit (pull_request) Successful in 5s
Three-layer cohesive fix for the 2026-05-19 ~00:05-00:09Z 4x reprov thrash
class observed on prod-Reviewer + prod-Researcher: a single secrets PUT
fanned out into 4x stop+provision cycles per workspace within 4 min,
each stopping the just-launched (still-pending) EC2 of the previous
cycle. Root-caused via Loki (provision.ec2_started / ec2_stopped pairs).

Empirical chain (all in workspace-server/internal/handlers/):
1. secrets.go SetSecret → go h.restartFunc → coalesceRestart cycle.
2. runRestartCycle sets url='' synchronously, then async provisions EC2.
3. During 20-30s pending window: url='' AND cpProv.IsRunning()==false
   — indistinguishable from a dead container.
4. Canvas /delegations poll OR the trailing restart-context probe fires
   ProxyA2A → maybeMarkContainerDead OR preflightContainerHealth →
   RestartByID → loop.
5. coalesceRestart's pending flag drains by running ANOTHER full cycle
   → ec2_stopped of the just-booted instance → re-provision.

Fix (single PR, three interdependent layers):

L1) Restart-aware health probes — workspace_restart.go exposes
    isRestarting(workspaceID) bool. Both maybeMarkContainerDead and
    preflightContainerHealth early-return false/nil while a restart
    cycle is in flight. Breaks the self-fire at the probe layer.

L2) Restart-context probe gate — sendRestartContext now requires
    url != '' AND last_heartbeat_at > restart_start_ts before firing
    the trailing ProxyA2A probe. Adds waitForFreshHeartbeat() next to
    waitForWorkspaceOnline. Belt-and-suspenders so the probe never
    tries until the new container is actually addressable.

L3) RestartByID debounce — silent-drop successive RestartByID calls
    within restartDebounceWindow=60s of restartStartedAt. Not coalesce
    (which would still drain to another full cycle). Drop is observable
    via restartByIDDropCounter (atomic.Uint64) + the dropped log line.
    Only programmatic path; HTTP Restart handler is unaffected.

Tests:
- TestIsRestarting_{FalseWhenNoStateEntry,TrueWhileCycleRunning}
- TestMaybeMarkContainerDead_SkippedWhileRestarting (L1)
- TestPreflightContainerHealth_SkippedWhileRestarting (L1)
- TestRestartByID_DebounceSilentDrop (L3, counter assertion)
- TestRestartByID_DebounceExpiresAfterWindow (L3, window release)
- TestRestartByID_SingleProvisionPerRestart (regression — asserts
  exactly 1 cycle per trigger, with 4 dropped self-fire probes)

Existing coalesce/restart/preflight/maybeMarkContainerDead tests
remain green. Full handlers suite: ok in 15.8s.

Closes internal#544.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:24:09 -07:00
core-security aabf933a5c feat(security): RFC#523 3-layer forbidden-env guardrail for tenant workspaces (task #146)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m14s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m18s
CI / Platform (Go) (pull_request) Successful in 5m6s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 5s
security-review / approved (pull_request) Failing after 5s
qa-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 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m26s
CI / Canvas (Next.js) (pull_request) Successful in 6m10s
CI / Python Lint & Test (pull_request) Successful in 6m38s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m20s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m32s
E2E Chat / E2E Chat (pull_request) Failing after 5m29s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m4s
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 4s
Refuse to start a tenant workspace if any operator-fleet-scope env var
name is present. Threat model: a leaked GITEA_TOKEN /
CP_ADMIN_API_TOKEN / RAILWAY_TOKEN / INFISICAL_OPERATOR_TOKEN /
MOLECULE_OPERATOR_* in a tenant container would let a compromised
agent escalate from "compromise of one workspace" to "compromise of
the whole platform."

3-layer defense-in-depth:

L1 — provisioner-side fail-closed abort (Go):
  workspace_provision_forbidden_env.go + prepareProvisionContext hook.
  Runs immediately after loadWorkspaceSecrets, BEFORE the per-agent
  persona GIT_HTTP_* injection that legitimately sets a fallback
  GITEA_TOKEN. Catches leaks from the operator-controlled stores
  (global_secrets, workspace_secrets). The existing forensic #145
  silent-strip guard in provisioner.buildContainerEnv stays as
  defense-in-depth.

L2 — workspace/entrypoint.sh top-of-file env-grep + exit 1:
  Fires if both upstream layers are bypassed (e.g. docker run -e
  GITEA_TOKEN=... standalone). MOLECULE_TENANT_GUARD_DISABLE=1
  bypass for local-dev. POSIX-portable (busybox/alpine/debian).

L3 — .gitea/workflows/lint-forbidden-env-keys.yml:
  Scans workspace-server/internal/**.go for new code that hardcodes a
  forbidden env-var name. Exempts the deny-set definitions + the
  pre-existing persona-fallback paths whose downstream silent-strip +
  new L1 fail-closed already cover the runtime risk.

Tests:
  - L1: TestIsForbiddenTenantEnvKey_ExactMatches,
        TestIsForbiddenTenantEnvKey_PrefixMatches,
        TestFindForbiddenTenantEnvKeys_NoneAndEmpty,
        TestFindForbiddenTenantEnvKeys_SingleAndMultipleSorted,
        TestFormatForbiddenTenantEnvError_Phrasing
  - L2: workspace/tests/test_entrypoint_forbidden_env_guard.sh
        (12 cases — clean/per-agent/each-forbidden/prefix/disable-flag)
  - L3: verified locally that current tree passes + synthetic offender
        is caught

Open-source-template-friendly: the deny set lives in Go and YAML
constants, not hardcoded in any open-source template's start.sh.
Per memory feedback_open_source_templates_no_hardcoded_org_internals,
templates published as separate repos (template-codex / template-
hermes / template-openclaw) get their L2 added in follow-up template
PRs with a fork-friendly default deny set (no MOLECULE_-specific
literal). The MOLECULE_OPERATOR_ prefix appears only in the
internal claude-code template's entrypoint.sh.

Refs:
  - RFC#523 (internal#523)
  - Task #146
  - memory feedback_passwords_in_chat_are_burned
  - memory feedback_per_agent_gitea_identity_default
  - memory feedback_open_source_templates_no_hardcoded_org_internals
  - memory feedback_check_vendor_docs_and_actual_source_before_guess_api_shape
    (POSIX env-set semantics verified via shell test; Go os.Environ /
    map[string]string contract verified via go test)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:08 -07:00
hongming 11cd1b4c40 fix(sop-checklist): widen ack eligibility per RFC#450 Option C (closes internal#442)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 5s
security-review / approved (pull_request) Failing after 9s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m16s
sop-tier-check / tier-check (pull_request) Successful in 9s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Platform (Go) (pull_request) Successful in 5m34s
CI / Canvas (Next.js) (pull_request) Successful in 7m0s
CI / Python Lint & Test (pull_request) Successful in 7m4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 7s
The sop-checklist senior-ack gate has been blocking PRs because
`root-cause` and `no-backwards-compat` required `[managers, ceo]` acks,
but every managers/ceo persona token is dead (uid:0 / 401) and the `ceo`
team is one human. Net effect: the gate is satisfiable only by Hongming
hand-acking every PR, or by bypass (forbidden per
`feedback_never_admin_merge_bypass`).

Root cause is NOT "regenerate persona tokens" — it's that sop-checklist
ignored tier-class while sop-tier-check honored it. This PR implements
RFC#450 Option C (risk-classed two-eyes):

- Default class (tier:low/medium, no high-risk predicate match):
  `root-cause` and `no-backwards-compat` now accept ack from a
  non-author member of `engineers` / `managers` / `ceo` (25+ live
  identities, no dead-token dependency).
- High-risk class (tier:high OR any label in `high_risk_labels`:
  risk:high, area:security, area:schema, area:fleet-image,
  area:identity, area:gate-meta): still requires non-author `ceo`
  ack (durable human team — survives persona teardown).

Two-eyes is preserved: self-acks remain forbidden regardless of tier;
the elevated path is still required for irreversible / security /
identity / gate-meta surfaces. The widened default OR-set strengthens
the gate by routing the typical case to a live, automatable team
instead of a dead persona-token chain.

Mechanism:
- `.gitea/sop-checklist-config.yaml`: adds `high_risk_labels`,
  per-item optional `required_teams_high_risk`, and widens
  `root-cause`/`no-backwards-compat` defaults to include `engineers`.
- `.gitea/scripts/sop-checklist.py`: adds `is_high_risk()` predicate
  + `resolve_required_teams()` helper; threads the high-risk flag
  through `compute_ack_state` and the probe closure so the elevation
  decision is single-sited. Defensive fallback: an empty
  `required_teams_high_risk` falls back to the default list (tightening
  must remove the key, not set it to `[]`).
- Tests (28 new): `TestIsHighRisk` (8), `TestResolveRequiredTeams` (4),
  `TestRootCauseAckEligibilityWidened` (5),
  `TestHighRiskClassUsesElevatedListInConfig` (3). All 79 tests pass.

Refs internal#442, RFC#450.
2026-05-18 18:19:13 -07:00
hongming 4d6be109c7 ci: add arm64-lane pilot (additive shellcheck on Mac runner)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m25s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m22s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 4m40s
qa-review / approved (pull_request) Failing after 8s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
security-review / approved (pull_request) Failing after 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m29s
CI / Canvas (Next.js) (pull_request) Successful in 6m2s
CI / Python Lint & Test (pull_request) Successful in 6m52s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 10s
Mac-CI dual-track #233 pilot. Adds a single additive non-required
workflow that targets [self-hosted, arm64] runners and runs shellcheck
against .gitea/scripts/*.sh. Until a Mac arm64 runner is registered
with the `arm64` label, this workflow sits PENDING, which is fine —
`arm64` is NOT in branch_protections/main.status_check_contexts (only
'CI / all-required (pull_request)' is required, verified live via API).

Why shellcheck for the pilot: pure userspace, no docker.sock, no
privileged ops, identical output across arm64/amd64, narrow blast
radius. A clean signal for whether the lane works.

Pairs with internal#543 (RFC: Mac arm64 native multi-arch runner-base).
88 LoC, well under the <=100 line guidance. No required gate changes.
2026-05-18 18:16:02 -07:00
core-devops 2de81cdd85 build(make): expose e2e-peer-visibility target + fix help filter for digit-containing names (task #166)
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Failing after 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 13s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Failing after 57s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m7s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m29s
CI / Platform (Go) (pull_request) Successful in 4m56s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
qa-review / approved (pull_request) Failing after 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
security-review / approved (pull_request) Failing after 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist / na-declarations (pull_request) N/A: (none)
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m17s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
CI / Canvas (Next.js) (pull_request) Successful in 6m13s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m58s
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 4s
Wires the local peer-visibility MCP gate into the Makefile so a
developer can run it via `make e2e-peer-visibility` against an
already-up local prod-mimic stack (`make up`), without remembering the
bash path. This is the dev-side counterpart to the CI job added in
the same commit on this branch — together they close task #166's
"wire into local-E2E gate" ask.

The help-line grep regex didn't include digits, so the new
e2e-peer-visibility target was correctly defined but invisible to
`make help`. Adds [0-9] to the character class and widens the label
column to 22 chars so longer target names line up. Other targets are
unaffected.

NOT auto-merged (per task #166 instructions). See PR body for the
verification + the manual command for ad-hoc runs without the make
target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:51:43 -07:00
core-qa 84cba60ec2 test(e2e): add LOCAL backend for the peer-visibility MCP gate
PR #1298 added the peer-visibility gate but staging-only. Per the
standing rule that the local prod-mimic stack must run a MANDATORY
local-Postgres E2E BEFORE staging E2E (feedback_local_must_mimic_
production, feedback_mandatory_local_e2e_before_ship, feedback_local_
test_before_staging_e2e), peer-visibility must also run locally so
regressions are caught fast/cheap instead of late on cold EC2.

- Factor the byte-identical assertion core out of
  test_peer_visibility_mcp_staging.sh into tests/e2e/lib/
  peer_visibility_assert.sh::pv_assert_runtime. It drives the literal
  JSON-RPC tools/call name=list_peers envelope to POST /workspaces/:id/
  mcp via each workspace's OWN bearer through the real WorkspaceAuth +
  MCPRateLimiter chain, with the same anti-proxy / anti-native-fallback
  guarantees. NOT a proxy: no registry row, /health, heartbeat, or
  GET /registry/:id/peers. Only provisioning differs per backend.
- Refactor the staging script to source the shared lib (assertion
  byte-identical; provisioning/teardown/exit-codes unchanged).
- Add tests/e2e/test_peer_visibility_mcp_local.sh: local docker-compose
  backend — POST /workspaces directly, e2e_mint_test_token for the MCP
  bearer (same model test_priority_runtimes_e2e.sh / test_api.sh use,
  no new credential flow), wait online, run the shared assertion,
  scoped per-workspace teardown only (feedback_cleanup_after_each_test,
  feedback_never_run_cluster_cleanup_tests_on_live_platform). bash-3.2-
  safe (no associative arrays) so it runs on local macOS dev boxes too.
- Wire a peer-visibility-local job into e2e-peer-visibility.yml,
  bootstrapped exactly like e2e-api.yml's proven E2E API Smoke Test
  (per-run container names + ephemeral ports, go build, background
  platform-server). Runs on PR + push (local boot is minutes, not the
  30+ min cold-EC2 path), so peer-visibility is part of the local gate
  that fires before the staging E2E. Its OWN non-required status
  context `E2E Peer Visibility (local)` — non-required-by-design like
  the staging job, HONEST gate with NO continue-on-error mask
  (feedback_fix_root_not_symptom); flip-to-required tracked at #1296
  via the bp-required: pending directive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:50:01 -07:00
core-devops 44affbde24 fix(canvas/chat): surface actionable error reason in chat banner + link to Activity tab (internal#212)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 55s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 42s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m19s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 43s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 37s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 29s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 6s
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
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m22s
CI / Canvas (Next.js) (pull_request) Successful in 4m18s
CI / Platform (Go) (pull_request) Successful in 4m46s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m14s
E2E Chat / E2E Chat (pull_request) Failing after 1m4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 39s
Harness Replays / Harness Replays (pull_request) Successful in 41s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m21s
CI / Python Lint & Test (pull_request) Successful in 6m52s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m31s
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 17s
The chat error banner used to render the hardcoded
"Agent error (Exception) — see workspace logs for details." string
regardless of what the workspace runtime actually reported, and the
"workspace logs" reference pointed at a tab that does not exist (there
is no separate Logs tab in the side panel — the Activity tab is the
workspace-logs surface). Per CTO feedback on internal#211 / #212:
"the user can only act if they can see why."

useChatSocket now forwards the new ACTIVITY_LOGGED.error_detail field
(introduced server-side in the matching ws-server PR) into
onSendError. When present, the canvas shows the secret-safe reason
verbatim (provider HTTP status + error code + human-readable
message); when absent — older ws-server build — it gracefully
degrades to the legacy boilerplate so we never silently swallow a
failure.

A new ChatErrorBanner component renders the banner with a working
"View activity log" button that fires setPanelTab("activity"),
turning the dangling "see workspace logs" pointer into a real
affordance. The existing offline-Restart button is preserved.

Tests pin: hook forwards detail when present, falls back when absent,
ignores cross-workspace error events; banner renders the actionable
text, falls back to legacy message when that is all we have, button
navigates to Activity tab, Restart preserved when offline, null
message renders nothing.

Refs: internal#212, feedback_surface_actionable_failure_reason_to_user
2026-05-18 17:39:09 -07:00
hongming c5534700f8 Merge branch 'main' into fix/handlers-pg-pin-docker-host-mc1529
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 48s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 52s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 3s
gate-check-v3 / gate-check (pull_request) Successful in 8s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 33s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m21s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m26s
sop-tier-check / tier-check (pull_request) Successful in 9s
CI / Platform (Go) (pull_request) Successful in 3m9s
CI / Canvas (Next.js) (pull_request) Successful in 6m13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 6m41s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m32s
CI / all-required (pull_request) Compensating success — Gitea 1.22.6 null-state emitter bug (feedback_gitea_emitter_null_state_blocks_merge). DB action_run for ci.yml all-required job @c5534700 = status=1 SUCCESS.
audit-force-merge / audit (pull_request) Successful in 10s
2026-05-19 00:34:42 +00:00
hongming 81825575f9 Merge pull request 'fix(provisioner): inject GIT_HTTP_USERNAME/PASSWORD env from persona token (closes Dev-A/B durable git auth gap from mc#1525)' (#1542) from fix/provisioner-inject-git-http-creds-from-persona-token into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Failing after 1s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Canvas (Next.js) (push) Failing after 20s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Failing after 2s
E2E API Smoke Test / detect-changes (push) Successful in 23s
E2E Chat / detect-changes (push) Successful in 18s
publish-workspace-server-image / build-and-push (push) Successful in 5m33s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Failing after 23s
CI / Platform (Go) (push) Successful in 3m18s
CI / Python Lint & Test (push) Successful in 6m35s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 27s
Harness Replays / Harness Replays (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
E2E Chat / E2E Chat (push) Failing after 56s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m0s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 15s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m13s
main-red-watchdog / watchdog (push) Successful in 35s
gate-check-v3 / gate-check (push) Successful in 22s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m13s
ci-required-drift / drift (push) Successful in 1m0s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Successful in 9s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 23s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
2026-05-19 00:34:07 +00:00
hongming 22d2f8a6fc Merge pull request 'fix(ci): remove 3 silently-dead .github/ workflows using workflow_run (task #81)' (#1541) from fix/ci-remove-dead-workflow-run-task81 into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-19 00:33:40 +00:00
hongming a053ca6f72 Merge pull request 'fix(runtime): close self-delegation echo gap in builtin_tools + inbox kind classification (#190 / #193)' (#1539) from fix/self-delegation-echo-runtime-builtin-tools into main
CI / Platform (Go) (push) Waiting to run
CI / all-required (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
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
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-runtime-autobump / pr-validate (push) Successful in 34s
publish-runtime-autobump / bump-and-tag (push) Successful in 36s
2026-05-19 00:33:36 +00:00
hongming dfc9d91ccd Merge pull request 'docs: fix stale channel-install + Molecule-AI org references (#230)' (#1538) from fix/docs-stale-channel-install-task230 into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Has been cancelled
2026-05-19 00:32:52 +00:00
hongming 9fb7060e9c Merge pull request 'feat(canvas): homepage SEO for marketing launch (mc#1486)' (#1537) from feat/homepage-seo-mc-1486 into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 4m0s
2026-05-19 00:32:50 +00:00
core-devops 94eff31c20 fix(workspace-server): surface secret-safe error_detail on ACTIVITY_LOGGED broadcast (internal#212)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 21s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Failing after 8s
Harness Replays / Harness Replays (pull_request) Has been skipped
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 56s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 18s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 19s
E2E Chat / detect-changes (pull_request) Successful in 24s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 41s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 58s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 47s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
qa-review / approved (pull_request) Failing after 8s
gate-check-v3 / gate-check (pull_request) Successful in 11s
sop-checklist / na-declarations (pull_request) N/A: (none)
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m12s
sop-checklist / all-items-acked (pull_request) Successful in 11s
security-review / approved (pull_request) Failing after 12s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 36s
sop-tier-check / tier-check (pull_request) Successful in 13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m53s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m0s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 25s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 24s
E2E Chat / E2E Chat (pull_request) Failing after 53s
CI / Python Lint & Test (pull_request) Successful in 6m10s
CI / Platform (Go) (pull_request) Successful in 7m0s
CI / Canvas (Next.js) (pull_request) Successful in 8m36s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m18s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge); CI ran, state never persisted by Gitea 1.22.6 emitter
audit-force-merge / audit (pull_request) Successful in 17s
When an a2a_receive row is persisted with status="error" the DB column
error_detail already carries the actionable cause (provider HTTP
status, error code, provider human message). The live ACTIVITY_LOGGED
broadcast dropped it, so the canvas chat-tab error banner fell back
to a hardcoded "Agent error (Exception) — see workspace logs for
details." string with no logs tab to navigate to.

Include error_detail in the broadcast payload, omitted when nil so the
canvas's "has actionable reason" guard doesn't false-positive on empty
keys. Defense-in-depth: a sanitizeErrorDetailForBroadcast scrubber
redacts anything that looks credential-shaped (bearer tokens, sk-
prefixed API keys, JWTs) while preserving the actionable parts
(status codes, error codes, human-readable provider messages) — over-
redacting would defeat the whole point of internal#212.

Tests pin: detail surfaces on the wire, omitted when nil, scrubber
removes secret shapes but keeps actionable text, scrubber survives
the broadcast round-trip.

Refs: internal#212
2026-05-18 17:28:41 -07:00
core-devops 5687a71476 fix(ci): pin handlers-postgres-integration to docker-host label (mc#1529)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m21s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 3m54s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m6s
security-review / approved (pull_request) Failing after 4s
qa-review / approved (pull_request) Failing after 11s
gate-check-v3 / gate-check (pull_request) Successful in 11s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 32s
CI / Platform (Go) (pull_request) Successful in 4m35s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m39s
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 2s
E2E Chat / E2E Chat (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m51s
CI / Python Lint & Test (pull_request) Successful in 7m1s
CI / all-required (pull_request) Successful in 7m1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
The workflow's "Start sibling Postgres" step hard-fails when the
operator-host bridge network `molecule-core-net` is missing. PC2
runners (hongming-pc-runner-*) advertise `ubuntu-latest` but don't
have that network — when the job was scheduled there, the bridge-
inspect check correctly errored out. Result: ~30% chronic-red on
main pushes (mc#1529 sweep, last 20 commits).

Pin both jobs to the `docker-host` label, which only the
operator-host runners (molecule-runner-1..20) carry. detect-changes
doesn't strictly need the bridge but co-locating the jobs avoids
volume-cross-host edge cases.

mc#1529 §1 of 4 root causes.
2026-05-18 17:15:00 -07:00
infra-sre 95c84021c2 fix(provisioner): inject GIT_HTTP_USERNAME/PASSWORD env from persona token
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Failing after 6s
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-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / Platform (Go) (pull_request) Successful in 2m44s
CI / Canvas (Next.js) (pull_request) Successful in 5m54s
CI / Python Lint & Test (pull_request) Successful in 6m28s
CI / all-required (pull_request) Successful in 6m32s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 26s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Failing after 55s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m0s
audit-force-merge / audit (pull_request) Successful in 7s
Closes the durable-git-auth gap left by template-claude-code#30 +
mc#1525 for the prod-team workspaces (agent-dev-a / agent-dev-b /
agent-pm). The askpass binary + GIT_ASKPASS env wiring shipped in
the template image and ws-server side respectively, but no code path
in workspace-server actually read the persona's git token from the
operator-host bootstrap dir and exported it as the askpass-readable
env-var pair. Without this, the askpass helper invokes with empty
password env and git fails the auth challenge in <500ms (live-
verified for Dev-A/Dev-B 2026-05-18 ~23:55Z via EC2 instance-connect
docker exec).

The new applyAgentGitHTTPCreds helper reads
$MOLECULE_PERSONA_ROOT/<role>/token (defaulting to
/etc/molecule-bootstrap/personas/<role>/token, the canonical
operator-host bootstrap-kit path) and emits GIT_HTTP_USERNAME +
GIT_HTTP_PASSWORD into the workspace envVars map.

Why a dedicated env-var pair instead of reusing GITEA_USER /
GITEA_TOKEN: the provisioner's forensic #145 SCM-write-token
denylist strips GITEA_TOKEN by exact key name before docker run.
The same token bytes shipped under the generic GIT_HTTP_PASSWORD
key survive transport because askpass reads that lane first.
GITEA_USER + GITEA_TOKEN are ALSO set for the askpass fallback
chain; GITEA_TOKEN is then dropped by buildContainerEnv as
designed, but the GIT_HTTP_PASSWORD lane already carries the
bytes the in-container helper needs.

Wired into prepareProvisionContext (the mode-agnostic shared
prep step both Docker and SaaS paths call) so Dev-A/Dev-B on
EC2 + any future local-Docker prod-team workspace pick it up
without duplicating the call site. Runs AFTER applyAgentGitIdentity
so workspace_secrets named GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD
(operator-supplied via POST /workspaces/:id/secrets) win over
the persona-file default.

Silent no-op for: empty role, multi-word descriptive roles
("Frontend Engineer") that fail isSafeRoleName, missing persona
dir, empty token file, traversal-attempt role names. These cases
fall through to the existing workspace_secrets / org-import
persona-env merge path unchanged.

No hardcoded git.moleculesai.app — the env-var pair is generic
askpass protocol and works for any git remote the deployer points
GIT_ASKPASS at.

Security note: this routes around forensic #145 by name (the
denylist is exact-key-match, not key-substring). For the
prod-team identities (agent-dev-{a,b,pm}) this is the explicitly-
designed shape per reference_prod_team_infisical_identities
(per-agent Gitea identities with pull+push, NO admin, NOT in any
merge-whitelist — merge stays gated by hardened BP 2-approvals+CI
per reference_merge_gate_model_changed_2026_05_18). A follow-up
RFC may tighten forensic #145 to also gate GIT_HTTP_PASSWORD for
non-prod-team tenants; out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:11:12 -07:00
infra-sre aff482a43c fix(ci): remove silently-dead .github/ workflows using workflow_run trigger (task #81)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 5s
security-review / approved (pull_request) Failing after 5s
CI / Platform (Go) (pull_request) Successful in 2m47s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 5m37s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 6m44s
CI / all-required (pull_request) Successful in 6m23s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 6s
Three workflows under .github/workflows/ used `on: workflow_run:`,
an event Gitea 1.22.6 does not support (per
feedback_pull_request_review_no_refire family + lint-workflow-yaml
Rule 2). They were also living in the wrong directory: molecule-core's
Gitea Actions runtime reads ONLY .gitea/workflows/ (per
reference_molecule_core_actions_gitea_only). So these files were
doubly dead — wrong path AND unsupported trigger.

Two of them already have working replacements under .gitea/workflows/
that landed in commit 2ee7cb14 (2026-05-12, replaced workflow_run
with push+paths). The third (canary-verify.yml) was superseded by
staging-verify.yml (push-on-staging) + staging-smoke.yml (schedule).

Removed → live replacement:
  - .github/workflows/canary-verify.yml
      → .gitea/workflows/staging-verify.yml (push+paths)
      + .gitea/workflows/staging-smoke.yml (schedule cron)
  - .github/workflows/redeploy-tenants-on-main.yml
      → .gitea/workflows/redeploy-tenants-on-main.yml (workflow_dispatch)
  - .github/workflows/redeploy-tenants-on-staging.yml
      → .gitea/workflows/redeploy-tenants-on-staging.yml (push+paths)

No runtime behavior change — these files were never executed by the
Gitea Actions runner. Removing them eliminates the dead-letter risk:
an operator scanning .github/workflows/ would otherwise believe an
auto-redeploy chain still exists post-publish, which it does not.

Refs: feedback_gitea_workflow_dispatch_inputs_unsupported,
reference_molecule_core_actions_gitea_only,
feedback_pull_request_review_no_refire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:09:57 +00:00
core-devops 90e115ba55 fix(runtime): close self-delegation echo gap in builtin_tools + inbox kind
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 28s
gate-check-v3 / gate-check (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
qa-review / approved (pull_request) Failing after 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 6s
publish-runtime-autobump / pr-validate (pull_request) Successful in 48s
sop-checklist / all-items-acked (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 3m55s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 4m26s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m54s
CI / Python Lint & Test (pull_request) Successful in 7m6s
CI / all-required (pull_request) Successful in 7m2s
audit-force-merge / audit (pull_request) Successful in 6s
Task #190 / #193 — surface the self-delegation echo guard at every runtime
delegation entry point, and classify platform-pushed delegation-result
rows distinctly from peer_agent messages so a delegation timeout never
appears to the caller as a fake peer instruction.

Three layers were affected and only two were guarded:

  1. workspace/a2a_tools_delegation.py — already had the guard (added in
     #548 / #469). Untouched.
  2. workspace-server/internal/handlers/delegation.go — Go API gate
     already had the guard. Untouched.
  3. workspace/builtin_tools/a2a_tools.py::delegate_task — framework-
     agnostic adapter surface used by adapters that don't go through (1).
     NO GUARD. Added.
  4. workspace/builtin_tools/delegation.py::delegate_task_async — the
     LangChain @tool fire-and-forget path. NO GUARD on the local helper
     (it dispatched the background _execute_delegation coroutine to our
     own URL). Added.

Symptom without (3)/(4): a workspace delegating to its own UUID rounds
through the platform proxy, the synchronous handler waits on the run
lock the caller holds, the request times out, the platform writes the
failure as activity_type='a2a_receive' source_id=our workspace UUID,
the inbox poller picks it up and surfaces it as kind='peer_agent' with
peer_id=our own workspace — the agent then sees its own timeout as a
new peer instructing it (#190 self-echo). Reply via delegate_task to
that "peer" re-triggers the loop.

Inbox-side fix (workspace/inbox.py): InboxMessage.to_dict() now
classifies rows with method='delegate_result' as kind='delegation_result'
regardless of peer_id. This makes pushDelegationResultToInbox results
(RFC #2829 PR-2) surface as STRUCTURED delegation outcomes to the
caller's wait_for_message instead of fake peer_agent messages. This
covers both the self-delegation echo path AND the cross-workspace
ProxyA2A failure path where the delegation result lands in the caller's
inbox with source_id=caller's own workspace UUID.

Tests added:
  - tests/test_a2a_tools_module.py::TestSelfDelegationGuard — verifies
    the builtin_tools/a2a_tools.py guard short-circuits BEFORE any HTTP
    call, and lets a real peer through.
  - tests/test_delegation.py::TestSelfDelegationGuard — verifies
    builtin_tools/delegation.py::delegate_task_async returns the
    structured rejection error without scheduling a background task.
  - tests/test_inbox.py::test_message_from_activity_delegate_result_distinct_kind
    — pins kind='delegation_result' for method='delegate_result' rows
    so the #190 mis-classification regression is locked.

Runtime mirror (molecule-ai-workspace-runtime) is a publish artifact of
this directory — it picks up the fix automatically on the next
runtime-v* tag → publish-runtime workflow → PyPI 0.1.1003.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:53:51 -07:00
documentation-specialist f233f71f5a docs: fix stale channel-install + Molecule-AI org references (#230)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 15s
CI / Platform (Go) (pull_request) Successful in 2m53s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 6s
security-review / approved (pull_request) Failing after 3s
sop-tier-check / tier-check (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 31s
sop-checklist / all-items-acked (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 4m21s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 7m3s
CI / all-required (pull_request) Successful in 6m50s
audit-force-merge / audit (pull_request) Successful in 5s
CONTRIBUTING.md:195 had a wrong `--channels` install string
(`plugin:molecule@Molecule-AI/molecule-mcp-claude-channel` — both the
plugin-name format and the dead GitHub-org path are stale). Aligned all
three doc surfaces (CONTRIBUTING.md, README.md, README.zh-CN.md) with
the actual install pattern emitted by workspace-server/internal/
handlers/external_connection.go (externalChannelTemplate):

  /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git
  /plugin install molecule@molecule-channel
  claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel

Also normalised display labels for the now-canonical Gitea org
(`Molecule-AI/` → `molecule-ai/`) — these are link captions, the URLs
were already correct. Docs-only, no behavioural change.

Task #230. Refs memory `feedback_github_botring_fingerprint` (canonical
SCM = git.moleculesai.app/molecule-ai/...).
2026-05-18 16:48:16 -07:00
core-devops 82a6cf42cd feat(canvas): homepage SEO for marketing launch (mc#1486)
CI / Detect changes (pull_request) Successful in 6s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 8s
qa-review / approved (pull_request) Failing after 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 11s
sop-checklist / all-items-acked (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 4s
sop-tier-check / tier-check (pull_request) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
CI / Canvas (Next.js) (pull_request) Successful in 4m18s
CI / Platform (Go) (pull_request) Successful in 4m37s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Failing after 5m8s
CI / Python Lint & Test (pull_request) Successful in 6m43s
CI / all-required (pull_request) Successful in 6m55s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m20s
audit-force-merge / audit (pull_request) Successful in 8s
Adds the standard Next.js App-Router SEO surface to the canvas
landing so the marketing push has crawlable metadata + structured
data on day one.

What landed:
  - layout.tsx — Metadata API: title.template, description,
    keywords, canonical, metadataBase, OG/Twitter text fields,
    robots index:true. JSON-LD @graph (Organization + WebSite +
    SoftwareApplication) injected with the per-request CSP nonce.
  - robots.ts — allow public marketing routes (/, /pricing, /blog),
    disallow /orgs, /api/, /cp/, /checkout/; declares sitemap +
    canonical host.
  - sitemap.ts — apex + pricing + live blog post; authed routes
    excluded by construction.
  - opengraph-image.tsx — segment-level dynamic OG card via
    next/og ImageResponse (1200x630); no static binary blob.
  - __tests__/seo-routes.test.ts — pins the crawler contract
    (10 cases) so a future refactor can't silently flip the
    marketing surface to noindex or drop the sitemap.

Out of scope (per issue): design copy, hero rewrite, Lighthouse
CWV tuning. Those are CTO/marketing inputs and a separate ticket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:47:03 -07:00
105 changed files with 7029 additions and 1451 deletions
+46 -2
View File
@@ -218,6 +218,31 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
`failed_statuses` is the list of per-context entries whose own
`state` is in the red set; useful for the issue body.
Cancel-cascade filter (mc#1564, 2026-05-19):
Gitea maps BOTH `action_run.status=2 (Failure)` AND
`action_run.status=3 (Cancelled)` to commit-status string
`"failure"`. On a busy main with
`concurrency: cancel-in-progress: true`, every merge burst
cancels prior in-flight runs (status=3) — those bubble to the
combined-status `failure` and inflate the watchdog's red%,
generating phantom `[main-red]` issues (mc#1562/#1552/#1540/...).
Canonical Gitea 1.22.6 enum per `models/actions/status.go` +
`reference_gitea_action_status_enum_corrected_2026_05_19`:
1=Success, 2=Failure, 3=Cancelled, 4=Skipped,
5=Waiting, 6=Running, 7=Blocked
We only want status=2 (real defects) to file. At the
commit-status layer we don't have the integer enum directly
(only the `failure` rollup string), so we use the description
string Gitea writes when a run is cancelled — empirically
`"Has been cancelled"` (verified 2026-05-19 via #1562 body).
Real failures show `"Failing after Ns"` and are unaffected.
This is option B from mc#1564 (description-string filter, no
extra API call). Description-string stability is a soft contract
with Gitea; if a future release renames it, the cancel-cascade
entries will simply leak back through (visible-not-silent), and
we'll either re-pin the string or upgrade to option A (resolve
the underlying action_run.status integer via target_url).
"""
combined = status.get("state")
statuses = status.get("statuses") or []
@@ -233,11 +258,30 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
def _entry_state(s: dict) -> str:
return s.get("status") or s.get("state") or ""
def _is_cancel_cascade(s: dict) -> bool:
"""status=3 entry per Gitea 1.22.6 description-string contract.
Match exactly (after strip) — substring match would catch
legitimate test names like "Has been cancelled by the user
unexpectedly" in failure logs."""
desc = (s.get("description") or "").strip()
return desc == "Has been cancelled"
failed = [
s for s in statuses
if isinstance(s, dict) and _entry_state(s) in red_states
if isinstance(s, dict)
and _entry_state(s) in red_states
and not _is_cancel_cascade(s)
]
return (combined in red_states or bool(failed), failed)
# Combined state alone is no longer sufficient — combined=failure
# may be 100% cancel-cascade. Drive `red` off the FILTERED list:
# if every red-shaped per-entry was cancel-cascade, `failed` is
# empty and we report green. Combined-failure with no per-entry
# detail (empty `statuses[]`) still trips red — that's the
# "CI emitter set combined-status directly" edge case from
# render_body's fallback path; we keep filing on it so the
# operator sees the breadcrumb.
combined_red_no_detail = combined in red_states and not statuses
return (bool(failed) or combined_red_no_detail, failed)
# --------------------------------------------------------------------------
+58 -5
View File
@@ -268,6 +268,7 @@ def compute_ack_state(
items_by_slug: dict[str, dict[str, Any]],
numeric_aliases: dict[int, str],
team_membership_probe: "callable[[str, list[str]], list[str]]",
high_risk: bool = False,
) -> dict[str, dict[str, Any]]:
"""Compute per-item ack state.
@@ -330,11 +331,16 @@ def compute_ack_state(
for slug, candidates in pending_team_check.items():
if not candidates:
continue
required = items_by_slug[slug]["required_teams"]
# Risk-class-aware required-teams resolution (RFC#450 Option C):
# high-risk PRs use `required_teams_high_risk` (when set on the
# item); default class uses `required_teams`. The probe closure
# is built with the same high_risk flag so the two reads are
# always consistent (both sites share `resolve_required_teams`).
required = resolve_required_teams(items_by_slug[slug], high_risk)
approved = team_membership_probe(slug, candidates) # returns subset
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
ackers_per_slug[slug] = approved
# Stash required teams for description rendering.
# Stash resolved teams for description rendering.
items_by_slug[slug]["_required_resolved"] = required
return {
@@ -765,6 +771,42 @@ def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
return default_mode
def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
"""Return True when the PR is high-risk per RFC#450 Option C.
A PR is high-risk when ANY of:
- it carries the `tier:high` label (mechanically strictest tier), or
- it carries any label listed in cfg.high_risk_labels.
High-risk PRs use `required_teams_high_risk` (when set on an item)
instead of the default `required_teams`. Items without
`required_teams_high_risk` are unaffected (the default applies).
Governance fix for internal#442 — closes the inconsistency between
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
"""
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
if "tier:high" in label_set:
return True
high_risk_labels = set(cfg.get("high_risk_labels") or [])
return bool(label_set & high_risk_labels)
def resolve_required_teams(item: dict[str, Any], high_risk: bool) -> list[str]:
"""Pick the active required_teams list for an item.
When high_risk is True AND the item declares a non-empty
`required_teams_high_risk`, return that. Else fall back to
`required_teams`. Keeping this in one helper means the gate's
decision shape stays single-sited even as items grow.
"""
if high_risk:
elevated = item.get("required_teams_high_risk") or []
if elevated:
return list(elevated)
return list(item.get("required_teams") or [])
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--owner", required=True)
@@ -825,6 +867,12 @@ def main(argv: list[str] | None = None) -> int:
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
# High-risk classification (RFC#450 Option C, governance fix for
# internal#442). Computed ONCE per PR — used by both the probe
# closure and compute_ack_state so the elevation decision is
# single-sited.
high_risk = is_high_risk(pr, cfg)
# Build team-membership probe closure that caches results per
# (user, team-id) so a user acking multiple items only triggers
# one membership lookup per team.
@@ -832,7 +880,7 @@ def main(argv: list[str] | None = None) -> int:
def probe(slug: str, users: list[str]) -> list[str]:
item = items_by_slug[slug]
team_names: list[str] = item["required_teams"]
team_names: list[str] = resolve_required_teams(item, high_risk)
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
# available — fall back to the list endpoint.
team_ids: list[int] = []
@@ -877,7 +925,9 @@ def main(argv: list[str] | None = None) -> int:
# may still find membership in another team.
return approved
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
ack_state = compute_ack_state(
comments, author, items_by_slug, numeric_aliases, probe, high_risk=high_risk
)
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
state, description = render_status(items, ack_state, body_state)
@@ -890,7 +940,10 @@ def main(argv: list[str] | None = None) -> int:
description = f"[info tier:low] {description}"
# Diagnostics to job log.
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
print(
f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} "
f"mode={mode} risk_class={'high' if high_risk else 'default'}"
)
for it in items:
slug = it["slug"]
ackers = ack_state[slug]["ackers"]
+213 -1
View File
@@ -602,4 +602,216 @@ class TestComputeNaState(unittest.TestCase):
self.assertEqual(len(na_directives), 1)
self.assertEqual(na_directives[0][0], "sop-n/a")
self.assertEqual(na_directives[0][1], "qa-review")
self.assertIn("no surface", na_directives[0][2])
# ---------------------------------------------------------------------------
# RFC#450 Option C — risk-classed two-eyes (governance fix for internal#442)
# ---------------------------------------------------------------------------
class TestIsHighRisk(unittest.TestCase):
"""The high-risk predicate decides which required_teams list applies.
Predicate: tier:high label OR any label in cfg.high_risk_labels.
"""
def setUp(self):
self.cfg = sop.load_config(CONFIG_PATH)
def test_no_labels_is_default_class(self):
pr = {"labels": []}
self.assertFalse(sop.is_high_risk(pr, self.cfg))
def test_tier_high_is_high_risk(self):
pr = {"labels": [{"name": "tier:high"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_tier_low_is_default_class(self):
pr = {"labels": [{"name": "tier:low"}]}
self.assertFalse(sop.is_high_risk(pr, self.cfg))
def test_tier_medium_is_default_class(self):
# tier:medium alone is NOT high-risk (Option C — medium routes
# to the wider engineers OR-set).
pr = {"labels": [{"name": "tier:medium"}]}
self.assertFalse(sop.is_high_risk(pr, self.cfg))
def test_area_security_label_is_high_risk(self):
pr = {"labels": [{"name": "tier:medium"}, {"name": "area:security"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_area_schema_label_is_high_risk(self):
pr = {"labels": [{"name": "area:schema"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_area_identity_label_is_high_risk(self):
pr = {"labels": [{"name": "area:identity"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_area_fleet_image_label_is_high_risk(self):
pr = {"labels": [{"name": "area:fleet-image"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_area_gate_meta_label_is_high_risk(self):
# Gate-meta = changes to sop-checklist/sop-tier-check itself.
pr = {"labels": [{"name": "area:gate-meta"}]}
self.assertTrue(sop.is_high_risk(pr, self.cfg))
def test_unknown_area_label_is_default_class(self):
pr = {"labels": [{"name": "area:docs"}]}
self.assertFalse(sop.is_high_risk(pr, self.cfg))
class TestResolveRequiredTeams(unittest.TestCase):
"""The team resolver picks the elevated list only for high-risk PRs
AND only when the item declares one — items without an elevated
list always use the default required_teams."""
def test_default_class_uses_default_teams(self):
item = {"required_teams": ["engineers", "managers", "ceo"], "required_teams_high_risk": ["ceo"]}
self.assertEqual(
sop.resolve_required_teams(item, high_risk=False),
["engineers", "managers", "ceo"],
)
def test_high_risk_uses_elevated_teams(self):
item = {"required_teams": ["engineers", "managers", "ceo"], "required_teams_high_risk": ["ceo"]}
self.assertEqual(
sop.resolve_required_teams(item, high_risk=True),
["ceo"],
)
def test_high_risk_without_elevated_falls_back_to_default(self):
# Items that don't declare required_teams_high_risk (e.g.
# comprehensive-testing, staging-smoke) are unaffected by risk-class.
item = {"required_teams": ["engineers"]}
self.assertEqual(
sop.resolve_required_teams(item, high_risk=True),
["engineers"],
)
def test_empty_elevated_list_falls_back_to_default(self):
# A defensive case: required_teams_high_risk: [] should not
# silently lock out all approvers — fall back to the default
# so the gate stays satisfiable. (Tightening should remove the
# key, not set it to empty.)
item = {"required_teams": ["engineers"], "required_teams_high_risk": []}
self.assertEqual(
sop.resolve_required_teams(item, high_risk=True),
["engineers"],
)
class TestRootCauseAckEligibilityWidened(unittest.TestCase):
"""Closes internal#442: a non-author engineers-team ack now satisfies
root-cause / no-backwards-compat for the default class.
The dead-managers/ceo-persona-token gridlock is the symptom; the
root cause is that sop-checklist ignored tier-class. These tests
pin the new wider-default behavior so it can't regress silently.
"""
def setUp(self):
self.items = _items_by_slug()
self.aliases = _numeric_aliases()
@staticmethod
def _approve_only(allowed):
return lambda slug, users: [u for u in users if u in allowed]
def test_engineers_ack_satisfies_root_cause_default_class(self):
# Bob is in engineers only (not managers, not ceo). Default class.
comments = [_comment("bob", "/sop-ack root-cause")]
# Probe: bob is approved because root-cause now lists engineers.
probe = self._approve_only({"bob"})
state = sop.compute_ack_state(
comments, "alice", self.items, self.aliases, probe, high_risk=False
)
self.assertEqual(state["root-cause"]["ackers"], ["bob"])
def test_engineers_ack_satisfies_no_backwards_compat_default_class(self):
comments = [_comment("bob", "/sop-ack no-backwards-compat")]
probe = self._approve_only({"bob"})
state = sop.compute_ack_state(
comments, "alice", self.items, self.aliases, probe, high_risk=False
)
self.assertEqual(state["no-backwards-compat"]["ackers"], ["bob"])
def test_engineers_ack_alone_fails_root_cause_when_high_risk(self):
# High-risk PR: only ceo can ack. Engineers-only ack must fail.
comments = [_comment("bob", "/sop-ack root-cause")]
# Probe: bob is in engineers, not ceo. Under high_risk,
# required_teams_high_risk=[ceo] → bob is NOT approved.
# Probe receives the items + flag indirectly via main(); for
# the unit-test path we inject a probe that rejects bob.
probe = self._approve_only(set()) # nobody is in ceo
state = sop.compute_ack_state(
comments, "alice", self.items, self.aliases, probe, high_risk=True
)
self.assertEqual(state["root-cause"]["ackers"], [])
self.assertIn("bob", state["root-cause"]["rejected"]["not_in_team"])
def test_ceo_ack_satisfies_root_cause_when_high_risk(self):
# High-risk PR + ceo-team approver → passes (the senior path).
comments = [_comment("hongming", "/sop-ack root-cause")]
probe = self._approve_only({"hongming"})
state = sop.compute_ack_state(
comments, "alice", self.items, self.aliases, probe, high_risk=True
)
self.assertEqual(state["root-cause"]["ackers"], ["hongming"])
def test_self_ack_still_forbidden_even_with_widened_eligibility(self):
# Author cannot self-ack — widening teams must NOT weaken
# the non-author rule.
comments = [_comment("alice", "/sop-ack root-cause")]
probe = self._approve_only({"alice"})
state = sop.compute_ack_state(
comments, "alice", self.items, self.aliases, probe, high_risk=False
)
self.assertEqual(state["root-cause"]["ackers"], [])
self.assertIn("alice", state["root-cause"]["rejected"]["self_ack"])
class TestHighRiskClassUsesElevatedListInConfig(unittest.TestCase):
"""End-to-end: the shipped config + RFC#450 predicate must keep
root-cause / no-backwards-compat gated on ceo for high-risk PRs."""
def test_root_cause_high_risk_elevated_to_ceo_only(self):
items = _items_by_slug()
# tier:high alone makes the PR high-risk → root-cause needs ceo.
self.assertEqual(
sop.resolve_required_teams(items["root-cause"], high_risk=True),
["ceo"],
)
# Default class accepts engineers/managers/ceo.
self.assertEqual(
sorted(sop.resolve_required_teams(items["root-cause"], high_risk=False)),
sorted(["engineers", "managers", "ceo"]),
)
def test_no_backwards_compat_high_risk_elevated_to_ceo_only(self):
items = _items_by_slug()
self.assertEqual(
sop.resolve_required_teams(items["no-backwards-compat"], high_risk=True),
["ceo"],
)
self.assertEqual(
sorted(sop.resolve_required_teams(items["no-backwards-compat"], high_risk=False)),
sorted(["engineers", "managers", "ceo"]),
)
def test_other_items_unchanged_by_risk_class(self):
# Items without required_teams_high_risk are unaffected.
items = _items_by_slug()
for slug in (
"comprehensive-testing",
"local-postgres-e2e",
"staging-smoke",
"five-axis-review",
"memory-consulted",
):
self.assertEqual(
sop.resolve_required_teams(items[slug], high_risk=False),
sop.resolve_required_teams(items[slug], high_risk=True),
f"item {slug} should not be affected by risk-class",
)
+43 -7
View File
@@ -50,6 +50,34 @@ tier_failure_mode:
"tier:low": soft
default_mode: hard # used when no tier:* label is present
# High-risk class (RFC#450 Option C, governance-fix for internal#442).
#
# A PR is "high-risk" when ANY of the listed labels are applied OR when
# the PR has `tier:high` (mechanically the strictest existing tier).
# High-risk items use `required_teams_high_risk` (when present on the
# item); non-high-risk items use the default `required_teams`.
#
# This closes the inconsistency that the SOP charter already mandates
# `tier:high → ceo only` for the sibling `sop-tier-check` gate; the
# sop-checklist's `root-cause` and `no-backwards-compat` items now
# follow the same risk-classed two-eyes shape:
# - Default class (tier:low/medium, not high-risk): a non-author
# engineers/managers/ceo ack satisfies the item — 25+ live
# identities, no dependency on a dead/inactive senior persona
# token.
# - High-risk class (tier:high OR any high_risk_label): still
# requires a non-author ceo ack (durable human team).
#
# Tightening: add labels to high_risk_labels.
# Loosening: remove labels.
high_risk_labels:
- "risk:high"
- "area:security"
- "area:schema"
- "area:fleet-image"
- "area:identity"
- "area:gate-meta"
items:
- slug: comprehensive-testing
numeric_alias: 1
@@ -78,11 +106,15 @@ items:
- slug: root-cause
numeric_alias: 4
pr_section_marker: "Root-cause not symptom"
required_teams: [managers, ceo]
required_teams: [engineers, managers, ceo]
required_teams_high_risk: [ceo]
description: >-
One-sentence root-cause statement. Ack from managers tier
(team-leads) or ceo. Senior judgment required to attest
root-cause-versus-symptom.
One-sentence root-cause statement. Default class: non-author
engineers/managers/ceo ack suffices (engineers can attest
root-cause-vs-symptom for routine fixes). High-risk class
(see `high_risk_labels`): non-author ceo ack required —
senior judgment for irreversible/security/identity/gate
changes. Closes internal#442 + tracks RFC#450.
- slug: five-axis-review
numeric_alias: 5
@@ -95,10 +127,14 @@ items:
- slug: no-backwards-compat
numeric_alias: 6
pr_section_marker: "No backwards-compat shim / dead code added"
required_teams: [managers, ceo]
required_teams: [engineers, managers, ceo]
required_teams_high_risk: [ceo]
description: >-
Yes/no + justification if no. Senior ack required because
backward-compat shims are how dead-code accretes.
Yes/no + justification if no. Default class: non-author
engineers/managers/ceo ack suffices. High-risk class
(see `high_risk_labels`): non-author ceo ack required —
senior judgment for shim-versus-real-fix on irreversible
surfaces. Closes internal#442 + tracks RFC#450.
- slug: memory-consulted
numeric_alias: 7
+1 -1
View File
@@ -401,7 +401,7 @@ jobs:
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
runs-on: docker-host
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
# ci_job_names() detects this as github.ref-gated and skips it from F1.
# The step-level exit 0 handles the "not main push" case; the job-level
+21 -2
View File
@@ -108,7 +108,20 @@ env:
jobs:
detect-changes:
runs-on: ubuntu-latest
# mc#1529 follow-on: pin to `docker-host` so the e2e-api lane lands
# on Linux operator-host runners (molecule-runner-*) that carry the
# `molecule-core-net` bridge network + a working `aws ecr get-login-
# password | docker login` path. The bare `ubuntu-latest` label is
# also accepted by hongming-pc-runner-* (Windows act_runner v1.0.3),
# where the docker.sock-bound steps below fail non-deterministically
# (e.g. `docker run -d --name pg-e2e-api-...` with port-bind +
# `docker exec ... pg_isready` cannot work against a Windows daemon).
# detect-changes itself doesn't bind docker.sock, but pinning here too
# keeps both jobs on the same lane so we don't re-roll the dice on
# workspace-volume cross-host surprises and the routing rule is
# discoverable in one place. Mirror of mc#1543 (handlers-postgres-
# integration). See internal#512 for the class defect.
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -160,7 +173,10 @@ jobs:
e2e-api:
needs: detect-changes
name: E2E API Smoke Test
runs-on: ubuntu-latest
# mc#1529 follow-on: must run on operator-host Linux runners (where
# docker.sock + `molecule-core-net` + `aws ecr ...` work). See
# detect-changes for the full rationale.
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -365,6 +381,9 @@ jobs:
- name: Run poll-mode chat upload E2E (RFC #2891)
if: needs.detect-changes.outputs.api == 'true'
run: bash tests/e2e/test_poll_mode_chat_upload_e2e.sh
- name: Run today's-PR-coverage E2E (mc#1525/1535/1536/1539/1542 fix-specific assertions)
if: needs.detect-changes.outputs.api == 'true'
run: bash tests/e2e/test_today_pr_coverage_e2e.sh
- name: Dump platform log on failure
if: failure() && needs.detect-changes.outputs.api == 'true'
run: cat workspace-server/platform.log || true
+10 -2
View File
@@ -33,7 +33,13 @@ env:
jobs:
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
detect-changes:
runs-on: ubuntu-latest
# mc#1529 follow-on: pin to `docker-host` (Linux operator-host
# runners). The bare `ubuntu-latest` label is also advertised by
# hongming-pc-runner-* (Windows act_runner v1.0.3) where the
# docker.sock-bound steps below fail. Mirror of mc#1543
# (handlers-postgres-integration). See internal#512 for the class
# defect.
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -71,7 +77,9 @@ jobs:
e2e-chat:
needs: detect-changes
name: E2E Chat
runs-on: ubuntu-latest
# mc#1529 follow-on: docker run/exec for postgres + redis containers.
# Must land on operator-host Linux (docker-host).
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
+177 -5
View File
@@ -52,6 +52,30 @@ name: E2E Peer Visibility (literal MCP list_peers)
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
# and cannot run per-PR-update).
#
# LOCAL BACKEND (added 2026-05-15 — feedback_local_must_mimic_production,
# feedback_mandatory_local_e2e_before_ship, feedback_local_test_before_
# staging_e2e)
# --------------------------------------------------------------------
# The standing rule is that the local prod-mimic stack runs a MANDATORY
# local-Postgres E2E BEFORE staging E2E. A staging-only peer-visibility
# gate caught regressions late + expensively (cold EC2). The
# `peer-visibility-local` job below runs the SAME byte-identical
# assertion (tests/e2e/lib/peer_visibility_assert.sh) against the local
# docker-compose stack — built + booted exactly like e2e-api.yml's
# proven E2E API Smoke Test job (ephemeral pg/redis ports, go build,
# background platform-server). It runs on PR + push (local boot is
# minutes, not the 30+ min cold-EC2 path), so peer-visibility is part of
# the local gate that fires before the staging E2E.
#
# It is its OWN non-required status context `E2E Peer Visibility (local)`
# — same non-required-by-design decision as the staging job (red until
# Hermes-401 #162 / OpenClaw-never-online #165 land; flip-to-required
# tracked at molecule-core#1296). It is an HONEST gate: NO
# continue-on-error mask (feedback_fix_root_not_symptom). It is kept a
# distinct context (not folded into e2e-api.yml's required `E2E API
# Smoke Test`) precisely so a deliberately-RED-today gate cannot wedge
# the required local-E2E job or any unrelated merge.
on:
push:
@@ -65,6 +89,8 @@ on:
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
pull_request:
branches: [main]
@@ -77,6 +103,8 @@ on:
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
workflow_dispatch:
schedule:
@@ -108,16 +136,160 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate driving script
- name: Validate driving scripts + shared assertion lib
run: |
bash -n tests/e2e/lib/peer_visibility_assert.sh
echo "lib/peer_visibility_assert.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
echo "Real fresh-provision MCP list_peers E2E runs on push to"
bash -n tests/e2e/test_peer_visibility_mcp_local.sh
echo "test_peer_visibility_mcp_local.sh — bash syntax OK"
echo "Staging fresh-provision MCP list_peers E2E runs on push to"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
echo "The LOCAL backend runs in the peer-visibility-local job"
echo "below on this same PR (local docker-compose stack)."
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
# peer set, then scoped teardown. push(main)/dispatch/cron only.
# LOCAL gate: same byte-identical assertion against the local prod-mimic
# docker-compose stack — the MANDATORY local-E2E that must run BEFORE
# the staging E2E (feedback_mandatory_local_e2e_before_ship,
# feedback_local_test_before_staging_e2e). Bootstrap mirrors
# e2e-api.yml's proven E2E API Smoke Test job (per-run container names +
# ephemeral host ports so concurrent host-network act_runner runs don't
# collide; go build; background platform-server). Its OWN non-required
# status context `E2E Peer Visibility (local)` — non-required-by-design
# exactly like the staging job (red until #162/#165 land;
# flip-to-required tracked at molecule-core#1296). HONEST gate, NO
# continue-on-error mask (feedback_fix_root_not_symptom). Runs on PR +
# push (local boot is minutes, not the 30+ min cold-EC2 path).
# bp-required: pending #1296
peer-visibility-local:
name: E2E Peer Visibility (local)
runs-on: docker-host
timeout-minutes: 30
env:
# Per-run names + ephemeral ports — same collision-avoidance as
# e2e-api.yml (host-network act_runner; feedback_act_runner_*).
PG_CONTAINER: pg-e2e-pv-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-pv-${{ github.run_id }}-${{ github.run_attempt }}
# LLM keys so hermes/openclaw can actually boot. The local script
# SKIPs (not fails) any runtime whose key is absent, so a partially
# keyed CI env still exercises whatever it can.
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.E2E_CLAUDE_CODE_OAUTH_TOKEN }}
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
PV_RUNTIMES: "hermes openclaw claude-code"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- name: Pre-pull alpine + ensure provisioner network
run: |
docker pull alpine:latest >/dev/null
docker network create molecule-core-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured."
- name: Start Postgres (docker, ephemeral port)
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 0:5432 postgres:16 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
[ -n "$PG_PORT" ] || PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
docker logs "$PG_CONTAINER" || true; exit 1
fi
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "Postgres ready after ${i}s"; exit 0; }
sleep 1
done
echo "::error::Postgres did not become ready in 30s"; docker logs "$PG_CONTAINER" || true; exit 1
- name: Start Redis (docker, ephemeral port)
run: |
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
[ -n "$REDIS_PORT" ] || REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
docker logs "$REDIS_CONTAINER" || true; exit 1
fi
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "Redis ready after ${i}s"; exit 0; }
sleep 1
done
echo "::error::Redis did not become ready in 15s"; docker logs "$REDIS_CONTAINER" || true; exit 1
- name: Build platform
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Pick platform port
run: |
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Kill stale platform-server before start
run: |
killed=0
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
kpid="${pid%/comm}"; kpid="${kpid##*/}"
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
if echo "$cmdline" | grep -q "platform-server"; then
echo "Killing stale platform-server pid ${kpid}"
kill "$kpid" 2>/dev/null || true; killed=$((killed + 1))
fi
done
[ "$killed" -gt 0 ] && sleep 2 || true
echo "stale-kill done ($killed killed)"
- name: Start platform (background)
working-directory: workspace-server
run: |
./platform-server > platform.log 2>&1 &
echo $! > platform.pid
- name: Wait for /health
run: |
for i in $(seq 1 30); do
curl -sf "$BASE/health" > /dev/null && { echo "Platform up after ${i}s"; exit 0; }
sleep 1
done
echo "::error::Platform did not become healthy in 30s"
cat workspace-server/platform.log || true; exit 1
- name: Run LOCAL fresh-provision peer-visibility E2E (literal MCP list_peers)
# HONEST gate — NO continue-on-error. Red today (Hermes-401 #162 /
# OpenClaw-never-online #165 not yet fixed); green when they land.
# Non-required-by-design via its distinct status context until the
# molecule-core#1296 flip-to-required.
run: bash tests/e2e/test_peer_visibility_mcp_local.sh
- name: Dump platform log on failure
if: failure()
run: cat workspace-server/platform.log || true
- name: Stop platform
if: always()
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always()
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
# Real STAGING gate: provisions a throwaway org + sibling-per-runtime,
# drives the LITERAL list_peers MCP call per runtime, asserts 200 +
# expected peer set, then scoped teardown. push(main)/dispatch/cron only.
peer-visibility:
name: E2E Peer Visibility
runs-on: ubuntu-latest
@@ -77,7 +77,16 @@ env:
jobs:
detect-changes:
name: detect-changes
runs-on: ubuntu-latest
# mc#1529 §1: pin to `docker-host` so the integration job runs on the
# operator-host runners (molecule-runner-*), which carry the
# `molecule-core-net` bridge network this workflow depends on. PC2
# runners (hongming-pc-runner-*) also advertise ubuntu-latest but
# don't have that network — the previous `runs-on: ubuntu-latest`
# rolled the dice and hard-failed the bridge-inspect step ~30% of
# the time. detect-changes itself doesn't need the bridge, but keeping
# both jobs on the same label avoids workspace-volume cross-host
# surprises and keeps the routing rule discoverable in one place.
runs-on: docker-host
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -129,7 +138,9 @@ jobs:
integration:
name: Handlers Postgres Integration
needs: detect-changes
runs-on: ubuntu-latest
# mc#1529 §1: must run on operator-host (where `molecule-core-net`
# exists). See detect-changes for the full routing rationale.
runs-on: docker-host
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
+10 -2
View File
@@ -62,7 +62,13 @@ env:
jobs:
# bp-exempt: change detector only; downstream Harness Replays is the meaningful gate.
detect-changes:
runs-on: ubuntu-latest
# mc#1529 follow-on: pin to `docker-host` so this lane lands on
# Linux operator-host runners (the only ones with a working
# docker.sock + `molecule-core-net`). The bare `ubuntu-latest`
# label is also matched by hongming-pc-runner-* (Windows act_runner
# v1.0.3), where the `docker compose ...` exec below fails. Mirror
# of mc#1543; see internal#512 for class defect.
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -162,7 +168,9 @@ jobs:
harness-replays:
needs: detect-changes
name: Harness Replays
runs-on: ubuntu-latest
# mc#1529 follow-on: `docker compose ... ps/logs` against tenant-alpha/
# beta containers. Must run on operator-host Linux (docker-host).
runs-on: docker-host
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -0,0 +1,168 @@
name: Lint forbidden tenant-env keys
# RFC#523 Layer 3 (task #146): scan workspace_secrets-writer Go code
# under workspace-server/ for new code that hardcodes a forbidden
# operator-scope env var NAME (GITEA_TOKEN, CP_ADMIN_API_TOKEN,
# RAILWAY_TOKEN, INFISICAL_OPERATOR_TOKEN, MOLECULE_OPERATOR_*, …).
#
# Catches the class "a new writer accidentally widens the propagation
# set" — e.g. a future env-mutator plugin that sets envVars["GITEA_TOKEN"]
# directly. Today the L1 runtime guard would abort the provision, but
# this lint surfaces the offending code at PR review time instead of
# at first provision attempt.
#
# Companion layers:
# - L1: workspace-server/internal/handlers/workspace_provision_forbidden_env.go
# (fail-closed abort at provision time)
# - L2: workspace/entrypoint.sh top-of-file env-grep + exit 1
#
# Open-source-template-friendly: the deny pattern is generic. A fork
# can copy this workflow and replace OPERATOR_KEY_PATTERN with its
# own operator-scope key names.
#
# Path-filter discipline:
# This workflow runs on every PR (no paths: filter — see
# feedback_path_filtered_workflow_cant_be_required). The scan itself
# targets workspace_secrets-writer paths via grep -r; it's fast
# (sub-second) so unconditional run is fine.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
scan:
name: Scan workspace_secrets writers for forbidden env keys
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Scan for forbidden operator-scope env key NAMES in writer paths
run: |
set -euo pipefail
# Forbidden EXACT-MATCH env var names. Kept in lockstep with
# workspace-server/internal/handlers/workspace_provision_forbidden_env.go
# forbiddenTenantEnvKeys. The Go-side test
# TestIsForbiddenTenantEnvKey_ExactMatches is the source of
# truth — if Go-side adds a key, also add it here (and
# vice-versa). Drift between the two is the failure mode this
# entire 3-layer guardrail is designed to catch.
FORBIDDEN_KEYS=(
"GITEA_TOKEN" "GITEA_PAT"
"GITHUB_TOKEN" "GITHUB_PAT" "GH_TOKEN"
"GITLAB_TOKEN" "GL_TOKEN"
"BITBUCKET_TOKEN"
"CP_ADMIN_API_TOKEN" "CP_ADMIN_TOKEN"
"INFISICAL_OPERATOR_TOKEN" "INFISICAL_BOOTSTRAP_TOKEN"
"RAILWAY_TOKEN" "RAILWAY_PERSONAL_API_TOKEN"
"HETZNER_TOKEN" "HETZNER_API_TOKEN"
)
# Forbidden PREFIX patterns — operator-scope families.
FORBIDDEN_PREFIXES=(
"MOLECULE_OPERATOR_"
)
# Writer paths: Go source under workspace-server/ that
# writes to the env-vars map or to workspace_secrets DB rows.
# Tests, the forbidden-env source itself, and the silent-
# strip denylist are exempt (they LIST the keys by design).
SCAN_ROOT="workspace-server/internal"
# Exempt paths fall in two classes:
# 1. The deny-set definitions + the silent-strip denylist:
# they LIST the forbidden names by design.
# 2. Pre-RFC#523 persona-merge / config-read paths that
# already handle these names correctly (the silent-
# strip downstream + the new L1 fail-closed cover the
# runtime risk; these reads are unchanged).
# New code MUST NOT be added to this list without reviewer
# signoff and a one-line justification in this diff.
EXEMPT_PATHS=(
# Class 1 — deny-set definitions
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
"workspace-server/internal/provisioner/provisioner.go"
"workspace-server/internal/provisioner/provisioner_test.go"
# Class 2 — pre-existing persona-fallback / org-helper paths
# that set the GITEA_TOKEN fallback lane (stripped downstream
# by provisioner.buildContainerEnv per forensic #145). The
# new L1 fail-closed runs BEFORE these writers, so any
# operator-scope leak via global/workspace_secrets is
# already caught. See applyAgentGitHTTPCreds doc-comment.
"workspace-server/internal/handlers/agent_git_identity.go"
"workspace-server/internal/handlers/org_helpers.go"
"workspace-server/internal/handlers/org.go"
# Class 2 — CP→platform admin auth (NOT a tenant env write;
# this is the control-plane HTTP auth header source).
"workspace-server/internal/provisioner/cp_provisioner.go"
)
# Build a single grep -F pattern: every forbidden key wrapped
# in quotes (Go string-literal form, which is how env-map
# writes appear). e.g. envVars["GITEA_TOKEN"] = ... or
# `"GITEA_TOKEN":` in a literal-map declaration.
#
# We deliberately match the quoted form so a comment that
# happens to spell the name without quotes (e.g. "see
# GITEA_TOKEN below") doesn't trip the lint.
PATTERN=""
for k in "${FORBIDDEN_KEYS[@]}"; do
PATTERN="${PATTERN}\"${k}\"\n"
done
for p in "${FORBIDDEN_PREFIXES[@]}"; do
# Prefix match needs a regex; switch to grep -E below for
# this slice. Kept conceptually here so the deny set lives
# in one place; scan is run twice (literal + prefix).
true
done
# Build exempt-paths grep filter — `grep -v -f` style.
EXEMPT_FILTER=$(mktemp)
trap 'rm -f "$EXEMPT_FILTER"' EXIT
for p in "${EXEMPT_PATHS[@]}"; do
echo "$p" >> "$EXEMPT_FILTER"
done
# --- Exact-match scan ---
HITS=""
for k in "${FORBIDDEN_KEYS[@]}"; do
# Only .go files; skip _test.go for the writer-path scan
# since tests legitimately reference the names. The
# writer-path lint targets PRODUCTION code only.
found=$(grep -rn --include='*.go' --exclude='*_test.go' "\"${k}\"" "$SCAN_ROOT" 2>/dev/null \
| grep -v -F -f "$EXEMPT_FILTER" || true)
if [ -n "$found" ]; then
HITS="${HITS}${found}\n"
fi
done
# --- Prefix scan ---
for prefix in "${FORBIDDEN_PREFIXES[@]}"; do
found=$(grep -rnE --include='*.go' --exclude='*_test.go' "\"${prefix}[A-Z0-9_]+\"" "$SCAN_ROOT" 2>/dev/null \
| grep -v -F -f "$EXEMPT_FILTER" || true)
if [ -n "$found" ]; then
HITS="${HITS}${found}\n"
fi
done
if [ -n "$HITS" ]; then
echo "::error::RFC#523 Layer 3: forbidden operator-scope env var name(s) hardcoded in tenant-workspace writer paths:"
printf "$HITS"
echo ""
echo "These env-var NAMES are on the operator-scope deny list (see"
echo "workspace-server/internal/handlers/workspace_provision_forbidden_env.go)."
echo "If your code legitimately needs to inject one of these for a"
echo "non-tenant code path, add the file to EXEMPT_PATHS in this"
echo "workflow with a one-line justification — reviewer signoff required."
exit 1
fi
echo "OK No forbidden operator-scope env key names hardcoded in writer paths."
@@ -0,0 +1,179 @@
name: Lint no tenant GITEA/GITHUB token write
# Task #146 — CI guardrail companion to RFC#523's `lint-forbidden-env-keys.yml`.
#
# `lint-forbidden-env-keys.yml` (Layer 3) catches code that hardcodes a
# forbidden env-var key NAME as a quoted literal in workspace_secrets
# writer paths under workspace-server/internal/.
#
# This workflow catches a BROADER class: any code path that reads a
# repo-host token (GITEA_TOKEN / GITHUB_TOKEN / GH_TOKEN) and then writes
# it into a TENANT WORKSPACE's env, secret store, user-data, or
# provision payload. This is the actual RFC#523 threat-model statement —
# the goal is "no tenant workspace ever receives an operator-scope repo
# token," not just "no _quoted_ literal `GITEA_TOKEN`." A future writer
# could route the value via a variable, a struct field, or a config key
# and slip past the existing literal scan; this lint catches those
# routing patterns at PR review time.
#
# Scope
# Scans the WHOLE repo's Go sources (not just workspace-server/) for
# co-occurrences of:
# - a repo-host token NAME (GITEA_TOKEN / GITHUB_TOKEN / GH_TOKEN /
# GITEA_PAT / GITHUB_PAT) used as os.Getenv argument or string
# literal
# - within a file that ALSO references a tenant-writer surface
# (`tenant`, `workspace_secrets`, `global_secrets`, `seedAllowList`,
# `/settings/secrets`, `userData`, `provisionPayload`,
# `envVars[`, `containerEnv`).
#
# Co-occurrence (not single-line) is the false-positive control: a
# file that just LOGS the variable name (e.g. "missing GITEA_TOKEN")
# without touching any tenant surface won't fire.
#
# Drift contract with lint-forbidden-env-keys.yml
# Both lints share the same FORBIDDEN_KEYS list (a subset — only the
# repo-host tokens, since this lint's threat model is "tenant gets
# write access to operator's git host"). If RFC#523's deny set grows,
# update BOTH this file AND lint-forbidden-env-keys.yml AND the Go
# source-of-truth in
# workspace-server/internal/handlers/workspace_provision_forbidden_env.go.
#
# Open-source-template-friendly
# The patterns scanned are generic (no MOLECULE_-prefix literals).
# A fork can copy this workflow as-is and adjust FORBIDDEN_KEYS.
#
# Path-filter discipline
# No `paths:` filter — required-status workflows must run on every PR
# per `feedback_path_filtered_workflow_cant_be_required`. Scan is
# sub-second.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
scan:
name: Scan for repo-host token write into tenant workspace surface
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Find Go files referencing a tenant-writer surface AND a repo-host token
run: |
set -euo pipefail
# Repo-host token NAMES — the threat-model subset. Operator-fleet
# tokens (CP_ADMIN_API_TOKEN, RAILWAY_TOKEN, INFISICAL_*) are
# caught by lint-forbidden-env-keys.yml's broader deny set; this
# lint focuses on the git-host class so a single co-occurrence
# match has a low false-positive rate.
FORBIDDEN_KEYS=(
"GITEA_TOKEN"
"GITEA_PAT"
"GITHUB_TOKEN"
"GITHUB_PAT"
"GH_TOKEN"
)
# Tenant-writer surface markers. A file matches the surface set
# if it references ANY of these strings. This is the "is this
# code path writing into a tenant workspace?" heuristic.
# Curated to catch the actual code shapes used in this repo
# (verified by grep against current main 2026-05-19):
# - "workspace_secrets" / "global_secrets" → DB table writes
# - "seedAllowList" → CP-side seed table
# - "/settings/secrets" → tenant HTTP API write
# - "envVars[" → in-memory env map write
# - "containerEnv" → docker-run env-set
# - "userData" → EC2 user-data script
# - "provisionPayload" / "provisionContext" → provision-request shape
SURFACE_PATTERN='workspace_secrets|global_secrets|seedAllowList|/settings/secrets|envVars\[|containerEnv|userData|provisionPayload|provisionContext'
# Files that legitimately reference these names AND a surface
# marker, but do so for guard / strip / test / doc-comment
# reasons. New entries require reviewer signoff and a one-line
# justification in the diff.
EXEMPT_FILES=(
# RFC#523 L1 deny-set source-of-truth + tests
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
# Forensic-#145 silent-strip denylist (defense-in-depth, by design lists the names)
"workspace-server/internal/provisioner/provisioner.go"
"workspace-server/internal/provisioner/provisioner_test.go"
# Pre-RFC#523 persona-fallback / org-helper paths. The L1
# fail-closed runs BEFORE these writers; downstream silent-strip
# also covers them. See applyAgentGitHTTPCreds doc-comment.
"workspace-server/internal/handlers/agent_git_identity.go"
"workspace-server/internal/handlers/org_helpers.go"
"workspace-server/internal/handlers/org.go"
# CP→platform admin auth (NOT a tenant env write).
"workspace-server/internal/provisioner/cp_provisioner.go"
)
# Build an extended-regex alternation of forbidden keys.
KEY_ALT="$(IFS='|'; echo "${FORBIDDEN_KEYS[*]}")"
# Find candidate files: Go non-test sources that contain a
# tenant-writer surface marker.
mapfile -t CANDIDATES < <(
grep -rlE --include='*.go' --exclude='*_test.go' \
"${SURFACE_PATTERN}" . 2>/dev/null \
| sed 's|^\./||' \
| sort -u
)
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
echo "OK No tenant-writer-surface files found in tree (unexpected, but not a lint failure)."
exit 0
fi
HITS=""
for f in "${CANDIDATES[@]}"; do
# Skip exempt files.
skip=0
for ex in "${EXEMPT_FILES[@]}"; do
if [ "$f" = "$ex" ]; then skip=1; break; fi
done
[ "$skip" = "1" ] && continue
# File contains a surface marker; now grep for a forbidden
# key NAME. We require a QUOTED-literal match to avoid
# firing on a comment like "// also handle GITEA_TOKEN".
#
# The literal form catches:
# - os.Getenv("GITEA_TOKEN")
# - envVars["GITEA_TOKEN"] = ...
# - {envKey: "GITEA_TOKEN", tenantKey: "GITEA_TOKEN"}
# but not:
# - // see GITEA_TOKEN below (no quotes)
found=$(grep -nE "\"(${KEY_ALT})\"" "$f" 2>/dev/null || true)
if [ -n "$found" ]; then
HITS="${HITS}--- ${f} ---\n${found}\n"
fi
done
if [ -n "$HITS" ]; then
echo "::error::Task #146 lint: repo-host token name(s) quoted in a tenant-writer-surface file:"
printf "$HITS"
echo ""
echo "These files reference a tenant-writer surface (workspace_secrets,"
echo "seedAllowList, /settings/secrets, containerEnv, userData, etc.)"
echo "AND quote a repo-host token name (GITEA_TOKEN/GITHUB_TOKEN/…)."
echo "Per RFC#523 threat model, tenant workspaces MUST NOT receive"
echo "operator-scope repo-host tokens. If your code legitimately needs"
echo "to reference one of these names in a tenant-writer file (e.g."
echo "a deny-set definition or silent-strip list), add the file to"
echo "EXEMPT_FILES with a one-line justification — reviewer signoff"
echo "required."
exit 1
fi
echo "OK No tenant-writer-surface file co-mentions a repo-host token literal."
@@ -0,0 +1,163 @@
name: lint-required-workflows-docker-host-pinned
# Fail-closed lint that catches workflows touching docker.sock without
# pinning `runs-on:` to a Linux-only label.
#
# Class defect (internal#512 + mc#1529 + today's oc#81/82/83 + autogen#8):
# the `ubuntu-latest` label is advertised by BOTH the Linux operator-host
# runners (molecule-runner-*) AND the Windows act_runner v1.0.3 on
# hongming-pc-runner-*. Job placement is non-deterministic. When a docker-
# bound job lands on a Windows runner, `docker run`/`docker login`/
# `docker compose` fail with platform-specific errors ("protocol not
# available", "cannot exec", etc.) — placement-dependent, not transient.
#
# This lint enforces the convention: any workflow whose YAML body
# contains a docker exec (`docker run|build|buildx|compose|pull|push|
# exec|tag|login|cp|inspect|ps` OR `docker/build-push-action|docker/
# login-action|docker/setup-buildx`) MUST pin every job's `runs-on:` to
# one of:
# - docker-host (general docker.sock work — molecule-runner-*)
# - publish (image build/push — molecule-runner-publish-*)
#
# Comments and heredoc/markdown bodies that merely MENTION docker are
# excluded by the detection rule (see scan.py below).
#
# Per `feedback_never_skip_ci`: this is fail-closed (exit 1 on miss).
on:
pull_request:
paths:
- '.gitea/workflows/**'
- '.github/workflows/**'
push:
branches: [main, staging]
paths:
- '.gitea/workflows/**'
- '.github/workflows/**'
permissions:
contents: read
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
lint-docker-host-pin:
name: Lint docker-host pin on docker-touching workflows
runs-on: docker-host
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Scan workflows for docker-bound jobs missing docker-host/publish pin
run: |
set -euo pipefail
python3 - <<'PY'
import os
import re
import sys
# Docker-step detection: real exec, not just word-mention in comments.
# We strip comment-only lines, then look for the docker subcommand
# tokens at word-boundary, OR uses: docker/* actions.
DOCKER_EXEC = re.compile(
r'(?<!\w)docker\s+(run|build|buildx|compose|pull|push|exec|tag|login|cp|inspect|ps)\b'
)
DOCKER_ACTION = re.compile(
r'uses:\s*docker/(build-push-action|login-action|setup-buildx-action|setup-qemu-action)'
)
# Detect a job header line like ` myjob:` (2-space indent) AND its runs-on.
JOB_HEADER = re.compile(r'^( {2})([a-zA-Z0-9_-]+):\s*$')
RUNS_ON = re.compile(r'^( {4})runs-on:\s*(.+?)\s*$')
ALLOWED_LABELS = {'docker-host', 'publish'}
fails = []
warnings = []
roots = []
for root in ('.gitea/workflows', '.github/workflows'):
if os.path.isdir(root):
roots.append(root)
for root in roots:
for fn in sorted(os.listdir(root)):
if not (fn.endswith('.yml') or fn.endswith('.yaml')):
continue
path = os.path.join(root, fn)
with open(path) as f:
raw_lines = f.readlines()
# Parse job headers + their runs-on. Simple line scan; relies on
# 2-space job indent + 4-space runs-on indent under `jobs:`.
jobs = []
current = None
in_jobs = False
for i, line in enumerate(raw_lines, 1):
if re.match(r'^jobs:\s*$', line):
in_jobs = True
continue
if not in_jobs:
continue
mh = JOB_HEADER.match(line)
if mh:
if current:
current['end'] = i - 1
jobs.append(current)
current = {'name': mh.group(2), 'line': i, 'end': len(raw_lines), 'runs_on': None}
continue
mr = RUNS_ON.match(line)
if mr and current and current['runs_on'] is None:
current['runs_on'] = mr.group(2).strip()
if current:
jobs.append(current)
for j in jobs:
# Strip pure-comment lines for docker-exec detection so
# documentation comments don't trigger the lint. Scan the
# current job body only: a workflow may contain one
# docker-bound job and several harmless metadata jobs.
job_lines = raw_lines[j['line'] - 1:j['end']]
scan_text = ''.join(
l for l in job_lines
if not re.match(r'^\s*#', l)
)
has_docker = bool(DOCKER_EXEC.search(scan_text)) or bool(DOCKER_ACTION.search(scan_text))
if not has_docker:
continue
ro = j['runs_on']
if ro is None:
# Reusable workflow caller (`uses:` instead of `runs-on:`) —
# skip; rule enforced in the called workflow.
continue
# Strip surrounding [ ] and quotes.
ro_norm = ro.strip('[]').strip().strip('"\'')
# Multi-label "[a, b]" — split.
labels = [t.strip().strip('"\'') for t in ro_norm.split(',') if t.strip()]
if any(lbl in ALLOWED_LABELS for lbl in labels):
continue
# Allow caller-supplied label expressions; spell the
# marker indirectly so Gitea's expression parser does
# not try to parse this Python heredoc.
expression_marker = '$' + '{{'
if any(expression_marker in lbl for lbl in labels):
continue
fails.append(
f"{path}:{j['line']}: job `{j['name']}` uses docker but runs-on={ro!r} "
f"(must be one of {sorted(ALLOWED_LABELS)})"
)
if fails:
print("FAIL: docker-bound jobs missing docker-host/publish pin:")
for f in fails:
print(f" - {f}")
print()
print("Why this rule exists (internal#512 + mc#1529):")
print(" Bare `ubuntu-latest` is advertised by BOTH Linux operator-host")
print(" runners AND Windows hongming-pc-runner-* (act_runner v1.0.3).")
print(" Docker-bound jobs that land on Windows fail non-deterministically.")
print(" Pin to `docker-host` (general) or `publish` (image build/push).")
sys.exit(1)
print("OK: all docker-bound jobs are pinned to docker-host or publish.")
PY
@@ -0,0 +1,88 @@
name: Lint shellcheck (arm64 pilot)
# Mac-CI dual-track pilot (#233). ADDITIVE / NOT REQUIRED.
#
# Validates the arm64 self-hosted lane (no docker.sock, no privileged
# ops) before any required gate moves onto it. Until a Mac arm64 runner
# is registered with the `arm64` label, this workflow sits PENDING —
# that is FINE: `arm64` is NOT in branch_protections required contexts.
#
# Pairs with internal#543 (RFC: Mac arm64 multi-arch runner-base).
# No paths: filter on purpose (feedback_path_filtered_workflow_cant_be_required).
on:
pull_request:
branches:
- main
- staging
push:
branches:
- main
permissions:
contents: read
jobs:
shellcheck-arm64:
name: shellcheck-arm64 (pilot)
runs-on: [self-hosted, arm64]
# 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').
timeout-minutes: 10
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
steps:
- name: Identify runner
run: |
set -eu
echo "arch=$(uname -m)"
echo "kernel=$(uname -sr)"
echo "shell=$BASH_VERSION"
# Sanity: must actually be arm64. If amd64 sneaks in here,
# fail fast — that means the label routing is wrong.
case "$(uname -m)" in
aarch64|arm64) echo "arm64 confirmed" ;;
*) echo "ERROR: expected arm64, got $(uname -m)"; exit 1 ;;
esac
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install shellcheck (arm64)
run: |
set -eu
if command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck already present: $(shellcheck --version | head -1)"
else
# Prefer apt if the runner base ships it; else download arm64 binary.
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends shellcheck
else
SC_VER=v0.10.0
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${SC_VER}/shellcheck-${SC_VER}.linux.aarch64.tar.xz" \
| tar -xJf - --strip-components=1
sudo mv shellcheck /usr/local/bin/
fi
fi
shellcheck --version | head -2
- name: Run shellcheck on .gitea/scripts/*.sh
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.
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"
exit 0
fi
echo "Checking ${#TARGETS[@]} file(s):"
printf ' %s\n' "${TARGETS[@]}"
# SC1091 = couldn't follow non-constant source; expected for
# CI-time analysis without the full runtime layout.
shellcheck --severity=error --exclude=SC1091 "${TARGETS[@]}"
@@ -29,6 +29,14 @@ on:
pull_request:
paths:
- "workspace/**"
# mc#1578 / a05add29 cure: build_runtime_package.py owns PYPROJECT_TEMPLATE
# (deps, classifiers, project metadata). A change there is publish-affecting
# even when workspace/** is untouched, so the autobump must fire to claim
# the next runtime-v$VERSION tag. Without this, manual tagging races PyPI
# (e.g. runtime-v0.1.18 collided with the 2026-04-27 PyPI 0.1.18 publish,
# blocking the python-multipart pin from reaching prod).
- "scripts/build_runtime_package.py"
- "scripts/test_build_runtime_package.py"
# Bump-and-tag on main/staging push (the actual operational trigger).
push:
branches:
@@ -36,6 +44,8 @@ on:
- staging
paths:
- "workspace/**"
- "scripts/build_runtime_package.py"
- "scripts/test_build_runtime_package.py"
# Manual dispatch — useful when Gitea Actions API (/actions/*) is
# unreachable (e.g. act_runner 404 on Gitea 1.22.6) and we cannot
# re-trigger via curl.
+16 -90
View File
@@ -1,11 +1,16 @@
# Consolidated comment dispatcher for manual review/tier refires.
# DEPRECATED — superseded by `.gitea/workflows/sop-checklist.yml`.
#
# The review-refire logic (qa/security/tier slash-command dispatch) has been
# merged into sop-checklist.yml as the `review-refire` job. This workflow
# is kept as a no-op stub to avoid a gap during the transition window where
# this file may be deleted while sop-checklist.yml has not yet been merged.
#
# After sop-checklist.yml lands, this file will be deleted (issue #1280).
#
# Historical behavior (superseded):
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
# qa-review, security-review, sop-checklist, and sop-tier-refire all
# listened to comments. This workflow is the single non-SOP comment subscriber:
# ordinary comments no-op quickly; slash commands post the required status
# contexts to the PR head SHA.
# evaluating job-level `if:`. Previously this workflow was the single
# non-SOP comment subscriber for qa/security/tier refire slash commands.
name: review-refire-comments
@@ -23,91 +28,12 @@ concurrency:
cancel-in-progress: true
jobs:
# No-op stub — all refire logic moved to sop-checklist.yml review-refire job.
# Kept to avoid transition gap; will be deleted after sop-checklist.yml merges.
dispatch:
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
- name: Deprecated — refire logic moved to sop-checklist.yml
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
if [ "$IS_PR" != "true" ]; then
echo "::notice::not a PR comment; no-op"
exit 0
fi
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
echo "::warning::review-refire-comments.yml is deprecated. Refire logic is now in sop-checklist.yml review-refire job. This workflow is a no-op stub pending deletion (issue #1280)."
exit 0
+119 -21
View File
@@ -2,24 +2,20 @@
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# === DESIGN ===
# === CONSOLIDATION (issue #1280) ===
#
# Goal: each PR must answer 7 SOP-checklist questions in its body,
# and each item must have at least one /sop-ack <slug> comment from
# a non-author peer in the required team. BP requires the
# `sop-checklist / all-items-acked (pull_request)` status to merge.
# This workflow is the SINGLE `issue_comment` subscriber — the logic from
# `review-refire-comments.yml` has been merged in. Before this change:
# - sop-checklist.yml (pre-2026-05-16) → issue_comment:[created,edited,deleted] → runner slot used, job no-oped
# - review-refire-comments.yml → issue_comment:[created] → runner slot used, job no-oped
# → every non-refire comment occupied 2 runner slots for ~800 s each
# (~650 no-op runs/day, ~1,300 runner-slot-occupancy-hours/day).
#
# Triggers:
# - `pull_request_target`: opened, edited, synchronize, reopened
# → fires when PR opens, body is edited (refire — RFC#351 §4),
# or new code is pushed (head.sha changes → stale status would
# be auto-discarded by BP via dismiss_stale_reviews, but the
# status itself is per-SHA so we re-post on the new head).
# - `issue_comment`: created, edited, deleted
# → fires on any new comment so /sop-ack / /sop-revoke take
# effect immediately (Gitea 1.22.6 doesn't refire on
# pull_request_review per feedback_pull_request_review_no_refire,
# so issue_comment is the canonical refire channel).
# Fix (PR #1345 / issue #1280):
# - ONE workflow, ONE issue_comment:[created] subscription (no edited/deleted)
# - all-items-acked job: pull_request_target OR sop slash-command comments
# - review-refire job: qa/security/tier refire slash commands
# → ~50% reduction in comment-triggered runner occupancy vs pre-fix.
#
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
# `pull_request_target` (not `pull_request`) — workflow def is loaded
@@ -51,7 +47,7 @@
# /sop-ack <slug-or-numeric-alias> [optional note]
# — register a peer-ack for one checklist item.
# — slug accepts kebab-case, snake_case, or natural-spaces
# (all normalize to canonical kebab-case).
# (all normalized to canonical kebab-case).
# — numeric 1..7 maps via config.items[*].numeric_alias.
# — most-recent (user, slug) directive wins.
#
@@ -61,6 +57,13 @@
# — most-recent (user, slug) directive wins, so a later /sop-ack
# re-restores the ack.
#
# /sop-n/a <gate> [reason]
# — declare a gate (qa-review, security-review) N/A.
# — see sop-checklist-config.yaml n/a_gates section.
#
# /qa-recheck /security-recheck /refire-tier-check
# — refire the corresponding status check on the PR head.
#
# The eval is read-only + idempotent (read PR + comments + team
# membership, compute, post status). Re-running on any event is safe —
# the new status overwrites the previous one for the same context.
@@ -79,7 +82,10 @@ on:
pull_request_target:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
issue_comment:
types: [created, edited, deleted]
types: [created] # NOT [created, edited, deleted] — Gitea 1.22.6 holds a runner slot
# at job-parsing time, before job-level if: guards run. edited/deleted events
# occupied ~1,300 runner-slot-hours/day on this workflow alone during the
# 2026-05-16 freeze. Per PR #1345 fix.
permissions:
contents: read
@@ -88,10 +94,10 @@ permissions:
secrets: read
jobs:
# sop-checklist gate: runs on PR lifecycle events OR sop slash commands.
# All other comment types (no-op text comments) no longer assign a runner
# because this job's if: guard short-circuits before runner assignment.
all-items-acked:
# Run on pull_request_target events always. On issue_comment events,
# only when the comment is on a PR (issue_comment fires for issues
# too) and the body contains one of the slash-commands.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
@@ -125,3 +131,95 @@ jobs:
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app
# bp-exempt: informational refire handler, not a merge gate. Emits
# qa-review/security-review status updates on /qa-recheck et al slash commands.
review-refire:
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
# RFC_324_TEAM_READ_TOKEN is read-only (team membership read scope only).
# review-refire-status.sh POSTs to /statuses — requires write scope.
# SOP_TIER_CHECK_TOKEN carries write:repository + write:issue + read:organization.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
# RFC_324_TEAM_READ_TOKEN is read-only (team membership read scope only).
# review-refire-status.sh POSTs to /statuses — requires write scope.
# SOP_TIER_CHECK_TOKEN carries write:repository + write:issue + read:organization.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
-255
View File
@@ -1,255 +0,0 @@
name: canary-verify
# Runs the canary smoke suite against the staging canary tenant fleet
# after a new :staging-<sha> image lands in ECR. On green, calls the
# CP redeploy-fleet endpoint to promote :staging-<sha> → :latest so
# the prod tenant fleet's 5-minute auto-updater picks up the verified
# digest. On red, :latest stays on the prior known-good digest and
# prod is untouched.
#
# Registry note (2026-05-10): This workflow previously used GHCR
# (ghcr.io/molecule-ai/platform-tenant) — that registry was retired
# during the 2026-05-06 Gitea suspension migration when publish-
# workspace-server-image.yml switched to the operator's ECR org
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/
# platform-tenant). The GHCR → ECR migration was never applied to
# this file, so canary-verify was silently smoke-testing the stale
# GHCR image while the actual staging/prod tenants ran the ECR image.
# Result: smoke tests could not catch a broken ECR build. Fix:
# - Wait step: reads SHA from running canary /health (tenant-
# agnostic, works regardless of registry).
# - Promote step: calls CP redeploy-fleet endpoint with target_tag=
# staging-<sha>, same mechanism as redeploy-tenants-on-main.yml.
# No longer attempts GHCR crane ops.
#
# Dependencies:
# - publish-workspace-server-image.yml publishes :staging-<sha>
# to ECR on staging and main merges.
# - Canary tenants are configured to pull :staging-<sha> from ECR
# (TENANT_IMAGE env set to the ECR :staging-<sha> tag).
# - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS /
# CANARY_CP_SHARED_SECRET are populated.
on:
workflow_run:
workflows: ["publish-workspace-server-image"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
packages: write
actions: read
env:
# ECR registry (post-2026-05-06 SSOT for tenant images).
# publish-workspace-server-image.yml pushes here.
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
# CP endpoint for redeploy-fleet (used in promote step below).
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
jobs:
canary-smoke:
# Skip when the upstream workflow failed — no image to test against.
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.compute.outputs.sha }}
smoke_ran: ${{ steps.smoke.outputs.ran }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Compute sha
id: compute
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Wait for canary tenants to pick up :staging-<sha>
# Poll canary health endpoints every 30s for up to 7 min instead
# of a fixed 6-min sleep. Exits as soon as ALL canaries report
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
# proceeding after 7 min even if not all canaries responded —
# the smoke suite will catch any that didn't update.
#
# NOTE: The SHA is read from the running tenant's /health response,
# NOT from a registry lookup. This is registry-agnostic and works
# regardless of whether the tenant pulls from ECR, GHCR, or any
# other registry — the canary is telling us what it's actually
# running, which is the ground truth for smoke testing.
env:
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
run: |
if [ -z "$CANARY_TENANT_URLS" ]; then
echo "No canary URLs configured — falling back to 60s wait"
sleep 60
exit 0
fi
IFS=',' read -ra URLS <<< "$CANARY_TENANT_URLS"
MAX_WAIT=420 # 7 minutes
INTERVAL=30
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
ALL_READY=true
for url in "${URLS[@]}"; do
HEALTH=$(curl -s --max-time 5 "${url}/health" 2>/dev/null || echo "{}")
SHA=$(echo "$HEALTH" | grep -o "\"sha\":\"[^\"]*\"" | head -1 | cut -d'"' -f4)
if [ "$SHA" != "$EXPECTED_SHA" ]; then
ALL_READY=false
break
fi
done
if $ALL_READY; then
echo "All canaries running staging-${EXPECTED_SHA} after ${ELAPSED}s"
exit 0
fi
echo "Waiting for canaries... (${ELAPSED}s / ${MAX_WAIT}s)"
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
done
echo "Timeout after ${MAX_WAIT}s — proceeding anyway (smoke suite will validate)"
- name: Run canary smoke suite
id: smoke
# Graceful-skip when no canary fleet is configured (Phase 2 not yet
# stood up — see molecule-controlplane/docs/canary-tenants.md).
# Sets `ran=false` on skip so promote-to-latest stays off (we don't
# want every main merge auto-promoting without gating). Manual
# promote-latest.yml is the release gate while canary is absent.
# Once the fleet is real: delete the early-exit branch.
env:
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
CANARY_ADMIN_TOKENS: ${{ secrets.CANARY_ADMIN_TOKENS }}
CANARY_CP_BASE_URL: https://staging-api.moleculesai.app
CANARY_CP_SHARED_SECRET: ${{ secrets.CANARY_CP_SHARED_SECRET }}
run: |
set -euo pipefail
if [ -z "${CANARY_TENANT_URLS:-}" ] \
|| [ -z "${CANARY_ADMIN_TOKENS:-}" ] \
|| [ -z "${CANARY_CP_SHARED_SECRET:-}" ]; then
{
echo "## ⚠️ canary-verify skipped"
echo
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
echo "Phase 2 canary fleet has not been stood up yet —"
echo "see [canary-tenants.md](https://git.moleculesai.app/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
echo
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
} >> "$GITHUB_STEP_SUMMARY"
echo "ran=false" >> "$GITHUB_OUTPUT"
echo "::notice::canary-verify: skipped — no canary fleet configured"
exit 0
fi
bash scripts/canary-smoke.sh
echo "ran=true" >> "$GITHUB_OUTPUT"
- name: Summary on failure
if: ${{ failure() }}
run: |
{
echo "## Canary smoke FAILED"
echo
echo "Canary tenants rejected image \`staging-${{ steps.compute.outputs.sha }}\`."
echo ":latest stays pinned to the prior good digest — prod is untouched."
echo
echo "Fix forward and merge again, or investigate the specific failed"
echo "assertions in the canary-smoke step log above."
} >> "$GITHUB_STEP_SUMMARY"
promote-to-latest:
# On green, calls the CP redeploy-fleet endpoint with target_tag=
# staging-<sha> to promote the verified ECR image. This is the same
# mechanism as redeploy-tenants-on-main.yml — no GHCR crane ops.
#
# Pre-fix history: the old GHCR promote step used `crane tag` against
# ghcr.io/molecule-ai/platform-tenant, but publish-workspace-server-
# image.yml had already migrated to ECR on 2026-05-07 (commit
# 10e510f5). The GHCR tags were never updated, so this step was
# silently promoting a stale GHCR image while actual prod tenants
# pulled from ECR. Canary smoke tests were GHCR-targeted and could
# not catch a broken ECR build.
needs: canary-smoke
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
runs-on: ubuntu-latest
env:
SHA: ${{ needs.canary-smoke.outputs.sha }}
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
# CP_ADMIN_API_TOKEN gates write access to the redeploy endpoint.
# Stored at the repo level so all workflows pick it up automatically.
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
# canary_slug pin: deploy the verified :staging-<sha> to the canary
# first (soak 120s), then fan out to the rest of the fleet.
CANARY_SLUG: ${{ vars.CANARY_PROMOTE_SLUG || '' }}
SOAK_SECONDS: ${{ vars.CANARY_PROMOTE_SOAK || '120' }}
BATCH_SIZE: ${{ vars.CANARY_PROMOTE_BATCH || '3' }}
steps:
- name: Check CP credentials
run: |
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret is not set — promote step cannot call redeploy-fleet."
echo "::error::Set it at: repo Settings → Actions → Variables and Secrets → New Secret."
exit 1
fi
- name: Promote verified ECR image to :latest
run: |
set -euo pipefail
TARGET_TAG="staging-${SHA}"
BODY=$(jq -nc \
--arg tag "$TARGET_TAG" \
--argjson soak "${SOAK_SECONDS:-120}" \
--argjson batch "${BATCH_SIZE:-3}" \
--argjson dry false \
'{
target_tag: $tag,
soak_seconds: $soak,
batch_size: $batch,
dry_run: $dry
}')
if [ -n "${CANARY_SLUG:-}" ]; then
BODY=$(jq '. * {canary_slug: $slug}' --arg slug "$CANARY_SLUG" <<<"$BODY")
fi
echo "Calling: POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" >"$HTTP_CODE_FILE"
CURL_EXIT=$?
set -e
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE (curl exit $CURL_EXIT)"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
if [ "$HTTP_CODE" -ge 400 ]; then
echo "::error::CP redeploy-fleet returned HTTP $HTTP_CODE — refusing to proceed."
exit 1
fi
- name: Summary
run: |
{
echo "## Canary verified — :latest promoted via CP redeploy-fleet"
echo ""
echo "- **Target tag:** \`staging-${{ needs.canary-smoke.outputs.sha }}\`"
echo "- **Registry:** ECR (\`${TENANT_IMAGE_NAME}\`)"
echo "- **Canary slug:** \`${CANARY_SLUG:-<none>}\` (soak ${SOAK_SECONDS}s)"
echo "- **Batch size:** ${BATCH_SIZE:-3}"
echo ""
echo "CP redeploy-fleet is rolling out the verified image across the prod fleet."
echo "The fleet's 5-minute health-check loop will pick up the update automatically."
} >> "$GITHUB_STEP_SUMMARY"
+1 -2
View File
@@ -300,7 +300,7 @@ jobs:
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
runs-on: docker-host
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -440,4 +440,3 @@ jobs:
# SDK + plugin validation moved to standalone repo:
# github.com/molecule-ai/molecule-sdk-python
+1 -1
View File
@@ -128,7 +128,7 @@ jobs:
e2e-api:
needs: detect-changes
name: E2E API Smoke Test
runs-on: ubuntu-latest
runs-on: docker-host
timeout-minutes: 15
env:
# Unique per-run container names so concurrent runs on the host-
@@ -88,7 +88,7 @@ jobs:
integration:
name: Handlers Postgres Integration
needs: detect-changes
runs-on: ubuntu-latest
runs-on: docker-host
env:
# Unique name per run so concurrent jobs don't collide on the
# bridge network. ${RUN_ID}-${RUN_ATTEMPT} is unique even across
@@ -249,4 +249,3 @@ jobs:
# already gone (e.g. concurrent rerun race), don't fail the job.
docker rm -f "${PG_NAME}" >/dev/null 2>&1 || true
echo "Cleaned up ${PG_NAME}"
+1 -1
View File
@@ -102,7 +102,7 @@ jobs:
harness-replays:
needs: detect-changes
name: Harness Replays
runs-on: ubuntu-latest
runs-on: docker-host
timeout-minutes: 30
steps:
- name: No-op pass (paths filter excluded this commit)
+1 -1
View File
@@ -39,7 +39,7 @@ env:
jobs:
build-and-push:
name: Build & push canvas image
runs-on: ubuntu-latest
runs-on: publish
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -1,400 +0,0 @@
name: redeploy-tenants-on-main
# Auto-refresh prod tenant EC2s after every main merge.
#
# Why this workflow exists: publish-workspace-server-image builds and
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
# but running tenants pulled their image once at boot and never re-pull.
# Users see stale code indefinitely.
#
# This workflow closes the gap by calling the control-plane admin
# endpoint that performs a canary-first, batched, health-gated rolling
# redeploy across every live tenant. Implemented in molecule-ai/
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
# (feat/tenant-auto-redeploy, landing alongside this workflow).
#
# Registry: ECR (153263036946.dkr.ecr.us-east-2.amazonaws.com/
# molecule-ai/platform-tenant). GHCR was retired 2026-05-07 during the
# Gitea suspension migration. The canary-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
#
# Runtime ordering:
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
# 2. This workflow fires via workflow_run, calls redeploy-fleet with
# target_tag=staging-<sha>. No CDN propagation wait needed —
# ECR image manifest is consistent immediately after push.
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
# period. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
#
# Rollback path: re-run this workflow with a specific SHA pinned via
# the workflow_dispatch input. That calls redeploy-fleet with
# target_tag=<sha>, re-pulling the older image on every tenant.
on:
workflow_run:
workflows: ['publish-workspace-server-image']
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
target_tag:
# Empty default → auto-trigger and dispatch-without-input both
# resolve to `staging-<short_head_sha>` (the digest publish-image
# just pushed). Pre-fix this defaulted to 'latest', which only
# gets retagged by canary-verify's promote-to-latest job — and
# that job soft-skips when CANARY_TENANT_URLS is unset (the
# current state, until Phase 2 canary fleet is live). Result:
# `:latest` had been pinned to a 4-day-old digest (2026-04-28)
# while every main push pushed fresh `staging-<sha>` images;
# every prod redeploy pulled the stale `:latest` and the verify
# step correctly flagged 3/3 tenants STALE. Pulling the
# just-published `staging-<sha>` directly skips the dead retag
# path. When canary fleet is real, this workflow should chain
# on canary-verify completion (workflow_run from canary-verify),
# not publish-image — separate, smaller PR.
description: 'Tenant image tag to deploy (e.g. "latest", "staging-a59f1a6c"). Empty = auto staging-<head_sha>.'
required: false
type: string
default: ''
canary_slug:
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately).'
required: false
type: string
# Must be an actual prod tenant slug (current: hongming,
# chloe-dong, reno-stars). The previous default 'hongmingwang'
# didn't match any tenant — CP soft-skipped the missing canary
# and the fleet rolled out without the soak gate, defeating the
# whole point of canary-first.
default: 'hongming'
soak_seconds:
description: 'Seconds to wait after canary before fanning out.'
required: false
type: string
default: '60'
batch_size:
description: 'How many tenants SSM redeploys in parallel per batch.'
required: false
type: string
default: '3'
dry_run:
description: 'Plan only — do not actually redeploy.'
required: false
type: boolean
default: false
permissions:
contents: read
# No write scopes needed — the workflow hits an external CP endpoint,
# not the GitHub API.
# Serialize redeploys so two rapid main pushes' redeploys don't overlap
# and cause confusing per-tenant SSM state. Without this, GitHub's
# implicit workflow_run queueing would *probably* serialize them, but
# the explicit block makes the invariant defensible. Mirrors the
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
#
# cancel-in-progress: false → aborting a half-rolled-out fleet would
# leave tenants stuck on whatever image they happened to be on when
# cancelled. Better to finish the in-flight rollout before starting
# the next one.
concurrency:
group: redeploy-tenants-on-main
cancel-in-progress: false
jobs:
redeploy:
# Skip the auto-trigger if publish-workspace-server-image didn't
# actually succeed. workflow_run fires on any completion state; we
# don't want to redeploy against a half-built image.
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Note on ECR propagation
# ECR image manifests are consistent immediately after push — no
# CDN cache to wait for. The old GHCR-based workflow had a 30s
# sleep to avoid race conditions; ECR makes that unnecessary.
run: echo "ECR image available immediately after push — proceeding."
- name: Compute target tag
id: tag
# Resolution order:
# 1. Operator-supplied input (workflow_dispatch with explicit
# tag) → used verbatim. Lets ops pin `latest` for emergency
# rollback to last canary-verified digest, or pin a specific
# `staging-<sha>` to roll back to a known-good build.
# 2. Default → `staging-<short_head_sha>`. The just-published
# digest. Bypasses the `:latest` retag path that's currently
# dead (canary-verify soft-skips without canary fleet, so
# the only thing retagging `:latest` today is the manual
# promote-latest.yml — last run 2026-04-28). Auto-trigger
# from workflow_run uses workflow_run.head_sha; manual
# dispatch with no input falls through to github.sha.
env:
INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -euo pipefail
if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag: $INPUT_TAG"
else
SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
fi
- name: Call CP redeploy-fleet
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
# molecule-ai/molecule-core, matching the staging/prod CP's
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
# repo's secrets for CI.
env:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: |
set -euo pipefail
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
exit 1
fi
BODY=$(jq -nc \
--arg tag "$TARGET_TAG" \
--arg canary "$CANARY_SLUG" \
--argjson soak "$SOAK_SECONDS" \
--argjson batch "$BATCH_SIZE" \
--argjson dry "$DRY_RUN" \
'{
target_tag: $tag,
canary_slug: $canary,
soak_seconds: $soak,
batch_size: $batch,
dry_run: $dry
}')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
# Route -w into its own tempfile so curl's exit code (e.g. 56
# on connection-reset, 22 on --fail-with-body 4xx/5xx) can't
# pollute the captured stdout. The previous inline-substitution
# shape produced "000000" on connection reset (curl wrote
# "000" via -w, then the inline echo-fallback appended another
# "000") — caught on the 2026-05-04 redeploy of sha 2b862f6.
# set +e/-e keeps the non-zero curl exit from tripping the
# outer pipeline. See lint-curl-status-capture.yml for the
# CI gate that pins this fix shape.
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" >"$HTTP_CODE_FILE"
set -e
# Stderr from curl (e.g. dial errors with -sS) goes to the runner
# log so operators can see WHY a connection failed. Stdout is
# captured to $HTTP_CODE_FILE because that's where -w writes.
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
# Pretty-print per-tenant results in the job summary so
# ops can see which tenants were redeployed without drilling
# into the raw response.
{
echo "## Tenant redeploy fleet"
echo ""
echo "**Target tag:** \`$TARGET_TAG\`"
echo "**Canary:** \`$CANARY_SLUG\` (soak ${SOAK_SECONDS}s)"
echo "**Batch size:** $BATCH_SIZE"
echo "**Dry run:** $DRY_RUN"
echo "**HTTP:** $HTTP_CODE"
echo ""
echo "### Per-tenant result"
echo ""
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
echo '|------|-------|------------|------|---------|-------|'
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
exit 1
fi
OK=$(jq -r '.ok' "$HTTP_RESPONSE")
if [ "$OK" != "true" ]; then
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
exit 1
fi
echo "::notice::Tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
# Stash the response for the verify step. $RUNNER_TEMP outlasts
# the step boundary; $HTTP_RESPONSE doesn't.
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
- name: Verify each tenant /buildinfo matches published SHA
# ROOT FIX FOR #2395.
#
# `redeploy-fleet`'s `ssm_status=Success` means "the SSM RPC
# didn't error" — NOT "the new image is running on the tenant."
# `:latest` lives in the local Docker daemon's image cache; if
# the SSM document does `docker compose up -d` without an
# explicit `docker pull`, the daemon serves the previously-
# cached digest and the container restarts on stale code.
# 2026-04-30 incident: hongmingwang's tenant reported
# ssm_status=Success at 17:00:53Z but kept serving pre-501a42d7
# chat_files for 30+ min — the lazy-heal fix never reached the
# user despite green deploy + green redeploy.
#
# This step closes the gap by curling each tenant's /buildinfo
# endpoint (added in workspace-server/internal/buildinfo +
# /Dockerfile* GIT_SHA build-arg, this PR) and comparing the
# returned git_sha to the SHA the workflow expects. Mismatches
# fail the workflow, which is what `ok=true` should have
# guaranteed all along.
#
# When the redeploy was triggered by workflow_dispatch with a
# specific tag (target_tag != "latest"), the expected SHA may
# not equal ${{ github.sha }} — in that case we resolve via
# GHCR's manifest. For workflow_run (default :latest) the
# workflow_run.head_sha is the SHA that just published.
env:
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
# Tenant subdomain template — slugs from the response are
# appended. Production CP issues `<slug>.moleculesai.app`;
# staging CP issues `<slug>.staging.moleculesai.app`. This
# workflow runs on main → prod CP → no `staging.` infix.
TENANT_DOMAIN: 'moleculesai.app'
run: |
set -euo pipefail
EXPECTED_SHORT="${EXPECTED_SHA:0:7}"
if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
# workflow_dispatch with a pinned tag that isn't the head
# SHA — operator is rolling back / pinning. Skip the
# verification because we don't have the expected SHA in
# this context (would need to crane-inspect the GHCR
# manifest, which is a follow-up). Failing-open here is
# safe: the operator chose the tag deliberately.
#
# `staging-<short_head_sha>` IS verified — it's the new
# auto-trigger default (see Compute target tag step) and
# the digest under that tag SHOULD match EXPECTED_SHA.
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
exit 0
fi
RESP="$RUNNER_TEMP/redeploy-response.json"
if [ ! -s "$RESP" ]; then
echo "::error::redeploy-response.json missing or empty — verify step ran without a response to read"
exit 1
fi
# Pull only successfully-redeployed tenants. Any tenant that
# halted the rollout already failed the previous step, so we
# don't double-count them here.
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
if [ ${#SLUGS[@]} -eq 0 ]; then
echo "::warning::No tenants reported healthz_ok — nothing to verify"
exit 0
fi
echo "Verifying ${#SLUGS[@]} tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
# Two distinct failure modes — STALE (the #2395 bug class, hard-fail)
# vs UNREACHABLE (teardown race, soft-warn). See the staging variant's
# comment for the full rationale; same logic applies on prod even
# though prod has fewer ephemeral tenants — the asymmetry would be a
# gratuitous fork.
STALE_COUNT=0
UNREACHABLE_COUNT=0
STALE_LINES=()
UNREACHABLE_LINES=()
for slug in "${SLUGS[@]}"; do
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
# 30s total: tenant just SSM-restarted, may still be coming
# up. Retry-on-empty rather than retry-on-status — we want
# to fail fast on "responded with wrong SHA", not "still
# warming up".
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
if [ -z "$ACTUAL_SHA" ]; then
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
continue
fi
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
else
STALE_COUNT=$((STALE_COUNT + 1))
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
fi
done
{
echo ""
echo "### Per-tenant /buildinfo verification"
echo ""
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
echo ""
if [ $STALE_COUNT -gt 0 ]; then
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
echo ""
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
echo "|------|----------------------|----------|--------|"
for line in "${STALE_LINES[@]}"; do echo "$line"; done
echo ""
fi
if [ $UNREACHABLE_COUNT -gt 0 ]; then
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely teardown race (soft-warn, not failing):**"
echo ""
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
echo "|------|----------------------|----------|--------|"
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
echo ""
fi
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
echo "All ${#SLUGS[@]} tenants returned matching SHA. ✓"
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ $UNREACHABLE_COUNT -gt 0 ]; then
echo "::warning::$UNREACHABLE_COUNT tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
fi
# Belt-and-suspenders sanity floor: same logic as the staging
# variant — see that file's comment for the full rationale.
# Floor only applies when fleet >= 4; below that, canary-verify
# is the actual gate.
TOTAL_VERIFIED=${#SLUGS[@]}
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
exit 1
fi
if [ $STALE_COUNT -gt 0 ]; then
echo "::error::$STALE_COUNT tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
exit 1
fi
echo "::notice::Tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
@@ -1,362 +0,0 @@
name: redeploy-tenants-on-staging
# Auto-refresh staging tenant EC2s after every staging-branch merge.
#
# Mirror of redeploy-tenants-on-main.yml, with the staging-CP host and
# the :staging-latest tag. Sister workflow exists for prod (rolls
# :latest after canary-verify). Both share the same shape — just
# different CP_URL + target_tag + admin token secret.
#
# Why this workflow exists: publish-workspace-server-image now builds
# on every staging-branch push (PR #2335), pushing
# platform-tenant:staging-latest to GHCR. Existing tenants pulled
# their image once at boot and never re-pull, so the new image just
# sits unused until the tenant is reprovisioned.
#
# This workflow closes the gap by calling staging-CP's
# /cp/admin/tenants/redeploy-fleet, which performs a canary-first,
# batched, health-gated SSM redeploy across every live staging tenant.
# Same endpoint shape as prod CP — only the host differs.
#
# Runtime ordering:
# 1. publish-workspace-server-image completes on staging branch →
# new :staging-latest in GHCR.
# 2. This workflow fires via workflow_run, waits 30s for GHCR's CDN
# to propagate the new tag.
# 3. Calls redeploy-fleet with no canary (staging IS canary; we don't
# need a sub-canary inside it). Soak still applies to the first
# tenant in case of bad-deploy detection.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
#
# Rollback path: re-run with workflow_dispatch + target_tag=staging-<sha>
# of a known-good build.
on:
workflow_run:
workflows: ['publish-workspace-server-image']
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
target_tag:
description: 'Tenant image tag to deploy (e.g. "staging-latest" or "staging-a59f1a6c"). Defaults to staging-latest when empty.'
required: false
type: string
default: 'staging-latest'
canary_slug:
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately). Default empty for staging since staging itself is the canary.'
required: false
type: string
default: ''
soak_seconds:
description: 'Seconds to wait after canary before fanning out. Only meaningful if canary_slug is set.'
required: false
type: string
default: '60'
batch_size:
description: 'How many tenants SSM redeploys in parallel per batch.'
required: false
type: string
default: '3'
dry_run:
description: 'Plan only — do not actually redeploy.'
required: false
type: boolean
default: false
permissions:
contents: read
# No write scopes needed — the workflow hits an external CP endpoint,
# not the GitHub API.
# Serialize per-branch so two rapid staging pushes' redeploys don't
# overlap and cause confusing per-tenant SSM state. cancel-in-progress
# is false because aborting a half-rolled-out fleet leaves tenants
# stuck on whatever image they happened to be on when cancelled.
concurrency:
group: redeploy-tenants-on-staging
cancel-in-progress: false
jobs:
redeploy:
# Skip the auto-trigger if publish-workspace-server-image didn't
# actually succeed. workflow_run fires on any completion state; we
# don't want to redeploy against a half-built image.
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Wait for GHCR tag propagation
# GHCR's edge cache takes ~15-30s to consistently serve the new
# :staging-latest manifest after the registry accepts the push.
# Same rationale as redeploy-tenants-on-main.yml.
run: sleep 30
- name: Call staging-CP redeploy-fleet
# CP_STAGING_ADMIN_API_TOKEN must be set as a repo/org secret
# on molecule-ai/molecule-core, matching staging-CP's
# CP_ADMIN_API_TOKEN env var (visible in Railway controlplane
# / staging environment). Stored separately from the prod
# CP_ADMIN_API_TOKEN so a leak of one doesn't auth the other.
env:
CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
CANARY_SLUG: ${{ inputs.canary_slug || '' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: |
set -euo pipefail
# Schedule-vs-dispatch hardening (mirrors sweep-cf-orphans
# and sweep-cf-tunnels): hard-fail on auto-trigger when the
# secret is missing so a misconfigured-repo doesn't silently
# serve stale staging tenants. Soft-skip on operator dispatch.
if [ -z "${CP_STAGING_ADMIN_API_TOKEN:-}" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::CP_STAGING_ADMIN_API_TOKEN secret not set — skipping redeploy"
echo "::warning::Set CP_STAGING_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
echo "::notice::Pull the value from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 0
fi
echo "::error::staging redeploy cannot run — CP_STAGING_ADMIN_API_TOKEN secret missing"
echo "::error::set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 1
fi
BODY=$(jq -nc \
--arg tag "$TARGET_TAG" \
--arg canary "$CANARY_SLUG" \
--argjson soak "$SOAK_SECONDS" \
--argjson batch "$BATCH_SIZE" \
--argjson dry "$DRY_RUN" \
'{
target_tag: $tag,
canary_slug: $canary,
soak_seconds: $soak,
batch_size: $batch,
dry_run: $dry
}')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
# Route -w into its own tempfile so curl's exit code (e.g. 56
# on connection-reset) can't pollute the captured stdout. The
# previous inline-substitution shape produced "000000" on
# connection reset — caught on main variant 2026-05-04
# redeploying sha 2b862f6. Same fix shape as the synth-E2E
# §9c gate (PR #2797). See lint-curl-status-capture.yml for
# the CI gate that pins this fix shape.
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_STAGING_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" >"$HTTP_CODE_FILE"
set -e
# Stderr from curl (-sS shows dial errors etc.) goes to the
# runner log so operators can see WHY a connection failed.
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
{
echo "## Staging tenant redeploy fleet"
echo ""
echo "**Target tag:** \`$TARGET_TAG\`"
echo "**Canary:** \`${CANARY_SLUG:-(none — staging is itself the canary)}\` (soak ${SOAK_SECONDS}s)"
echo "**Batch size:** $BATCH_SIZE"
echo "**Dry run:** $DRY_RUN"
echo "**HTTP:** $HTTP_CODE"
echo ""
echo "### Per-tenant result"
echo ""
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
echo '|------|-------|------------|------|---------|-------|'
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
# Distinguish "real fleet failure" from "E2E teardown race".
#
# CP returns HTTP 500 + ok=false whenever ANY tenant in the
# fleet failed SSM or healthz. In practice the recurring source
# of these is ephemeral test tenants being torn down by their
# parent E2E run mid-redeploy: the EC2 dies → SSM exit=2 or
# healthz timeout → CP marks the fleet failed → this workflow
# goes red even though every operator-facing tenant rolled fine.
#
# Ephemeral slug prefixes (kept in sync with sweep-stale-e2e-orgs.yml
# — see that file for the source-of-truth list and rationale):
# - e2e-* — canvas/saas/ext E2E suites
# - rt-e2e-* — runtime-test harness fixtures (RFC #2251)
# Long-lived prefixes that are NOT ephemeral and MUST hard-fail:
# demo-prep, dryrun-*, dryrun2-*, plus all human tenant slugs.
#
# Filter: if HTTP=500/ok=false AND every failed slug matches an
# ephemeral prefix, treat as soft-warn and let the verify step
# downstream handle unreachable-vs-stale (#2402). Any non-ephemeral
# failure or a non-500 HTTP response remains a hard failure.
OK=$(jq -r '.ok // "false"' "$HTTP_RESPONSE")
FAILED_SLUGS=$(jq -r '
.results[]?
| select((.healthz_ok != true) or (.ssm_status != "Success"))
| .slug' "$HTTP_RESPONSE" 2>/dev/null || true)
EPHEMERAL_PREFIX_RE='^(e2e-|rt-e2e-)'
NON_EPHEMERAL_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -Ev "$EPHEMERAL_PREFIX_RE" || true)
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
: # happy path — fall through to verification
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_EPHEMERAL_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -Ec "$EPHEMERAL_PREFIX_RE" || true)
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is ephemeral (e2e-*/rt-e2e-*) — treating as teardown race, soft-warning."
printf '%s\n' "$FAILED_SLUGS" | sed 's/^/::warning:: failed: /'
elif [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
if [ -n "$NON_EPHEMERAL_FAILED" ]; then
echo "::error::non-ephemeral tenant(s) failed:"
printf '%s\n' "$NON_EPHEMERAL_FAILED" | sed 's/^/::error:: /'
fi
exit 1
else
# HTTP=200 but ok=false (shouldn't happen with current CP
# but keep the gate for completeness).
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
exit 1
fi
echo "::notice::Staging tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
- name: Verify each staging tenant /buildinfo matches published SHA
# Mirror of the verify step in redeploy-tenants-on-main.yml — see
# there for the rationale (#2395 root fix). Staging has the same
# ssm_status-success-but-stale-image hazard and benefits from the
# same gate. Diff: TENANT_DOMAIN includes the `staging.` infix.
env:
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
TENANT_DOMAIN: 'staging.moleculesai.app'
run: |
set -euo pipefail
# staging-latest is the staging-side moving tag; treat it the
# same way main treats `latest`. Operator-pinned SHAs skip
# verification (see main variant for why).
if [ "$TARGET_TAG" != "staging-latest" ] && [ "$TARGET_TAG" != "latest" ] && [ "$TARGET_TAG" != "$EXPECTED_SHA" ]; then
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
exit 0
fi
RESP="$RUNNER_TEMP/redeploy-response.json"
if [ ! -s "$RESP" ]; then
echo "::error::redeploy-response.json missing or empty"
exit 1
fi
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
if [ ${#SLUGS[@]} -eq 0 ]; then
echo "::warning::No staging tenants reported healthz_ok — nothing to verify"
exit 0
fi
echo "Verifying ${#SLUGS[@]} staging tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
# Two distinct failure modes here:
# STALE_COUNT — tenant returned a SHA that doesn't match. THIS is
# the #2395 bug class: tenant up + serving old code.
# Always hard-fail the workflow.
# UNREACHABLE_COUNT — tenant didn't respond. Almost always a benign
# teardown race: redeploy-fleet snapshot says
# healthz_ok=true, then the E2E suite tears the
# ephemeral tenant down before this step runs (the
# e2e-* fixtures churn 5-10/hour on staging). Soft-
# warn so we don't block staging→main on cleanup.
# Real "tenant up but unreachable" is caught by CP's
# own healthz monitor + the post-redeploy alert; we
# don't need to double-count it here.
STALE_COUNT=0
UNREACHABLE_COUNT=0
STALE_LINES=()
UNREACHABLE_LINES=()
for slug in "${SLUGS[@]}"; do
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
if [ -z "$ACTUAL_SHA" ]; then
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
continue
fi
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
else
STALE_COUNT=$((STALE_COUNT + 1))
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
fi
done
{
echo ""
echo "### Per-tenant /buildinfo verification (staging)"
echo ""
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
echo ""
if [ $STALE_COUNT -gt 0 ]; then
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
echo ""
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
echo "|------|----------------------|----------|--------|"
for line in "${STALE_LINES[@]}"; do echo "$line"; done
echo ""
fi
if [ $UNREACHABLE_COUNT -gt 0 ]; then
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely E2E teardown race (soft-warn, not failing):**"
echo ""
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
echo "|------|----------------------|----------|--------|"
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
echo ""
fi
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
echo "All ${#SLUGS[@]} staging tenants returned matching SHA. ✓"
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ $UNREACHABLE_COUNT -gt 0 ]; then
echo "::warning::$UNREACHABLE_COUNT staging tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
fi
# Belt-and-suspenders sanity floor: if MORE than half the fleet is
# unreachable AND the fleet is large enough that "half down" is
# statistically meaningful, this is a real outage (e.g. new image
# crashes on startup), not a teardown race. Hard-fail.
#
# Floor only applies when TOTAL_VERIFIED >= 4 — below that, the
# canary-verify step is the actual gate for "all tenants down"
# detection (it runs against the canary first and aborts the
# rollout if the canary fails to come up). Without the >=4 gate,
# a 1-tenant fleet (e.g. a single ephemeral e2e-* tenant on a
# quiet staging push) would re-flake on the exact teardown-race
# condition #2402 fixed: 1 of 1 unreachable = 100% > 50% → fail.
TOTAL_VERIFIED=${#SLUGS[@]}
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED staging tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
exit 1
fi
if [ $STALE_COUNT -gt 0 ]; then
echo "::error::$STALE_COUNT staging tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
exit 1
fi
echo "::notice::Staging tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
+19 -18
View File
@@ -57,24 +57,24 @@ See `CLAUDE.md` for a full list of environment variables and their purposes.
This repo is scoped to **code** (canvas, workspace, workspace-server, related
infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
DevRel demos) lives in [`Molecule-AI/docs`](https://git.moleculesai.app/molecule-ai/docs).
DevRel demos) lives in [`molecule-ai/docs`](https://git.moleculesai.app/molecule-ai/docs).
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
or other removed paths — open against `Molecule-AI/docs` instead.
or other removed paths — open against `molecule-ai/docs` instead.
| Content type | Target |
|---|---|
| Blog posts | `Molecule-AI/docs``content/blog/<YYYY-MM-DD-slug>/` |
| Doc pages | `Molecule-AI/docs``content/docs/` |
| Marketing copy / PMM positioning | `Molecule-AI/docs``marketing/` |
| OG images, visual assets | `Molecule-AI/docs``app/` or `marketing/` |
| SEO briefs | `Molecule-AI/docs``marketing/` |
| DevRel demos (runnable code) | Standalone repo under `Molecule-AI/`, OR embedded in `Molecule-AI/docs` |
| Launch checklists, internal tracking | GitHub Issues — **not** committed files |
| Blog posts | `molecule-ai/docs``content/blog/<YYYY-MM-DD-slug>/` |
| Doc pages | `molecule-ai/docs``content/docs/` |
| Marketing copy / PMM positioning | `molecule-ai/docs``marketing/` |
| OG images, visual assets | `molecule-ai/docs``app/` or `marketing/` |
| SEO briefs | `molecule-ai/docs``marketing/` |
| DevRel demos (runnable code) | Standalone repo under `molecule-ai/`, OR embedded in `molecule-ai/docs` |
| Launch checklists, internal tracking | Gitea Issues — **not** committed files |
| Engineering docs (`docs/adr/`, `docs/architecture/`, `docs/incidents/`) | This repo (internal, not published) |
| Live product pages (e.g. `canvas/src/app/pricing/page.tsx`) | This repo (these are app code, not marketing copy) |
If a PR fails the `Block forbidden paths` check, the contents belong in
`Molecule-AI/docs`. No CI drag, no Canvas E2E, content lands in minutes.
`molecule-ai/docs`. No CI drag, no Canvas E2E, content lands in minutes.
## Development Workflow
@@ -106,7 +106,7 @@ causing a render loop when any node position changed.
#### Auto-merge & the "extra commit" trap
**Two system guards protect against pushing commits after auto-merge has been enabled.** Don't try to work around them — they exist because we shipped a half-merged PR on 2026-04-27 (`#2174` merged with only its first commit; the second was orphaned on a branch GitHub had already deleted).
**Two system guards protect against pushing commits after auto-merge has been enabled.** Don't try to work around them — they exist because we shipped a half-merged PR on 2026-04-27 (`#2174` merged with only its first commit; the second was orphaned on a branch the host had already deleted).
1. **Repo-wide:** "Automatically delete head branches" is on. Once a PR merges, the branch is deleted server-side. Any subsequent `git push` to that branch fails with `remote rejected — no such branch`.
@@ -145,7 +145,7 @@ Fix violations before committing — the hook will reject the commit.
### CI Pipeline
CI runs on GitHub Actions with a self-hosted runner. External contributors:
CI runs on Gitea Actions with self-hosted runners. External contributors:
PRs from forks will not trigger CI automatically. A maintainer will review
and run CI manually.
@@ -190,9 +190,9 @@ Runs the full regression suite against a fixture HTTP server. No network access
Code in this repo lands in molecule-core. Some related runtime artifacts
live in their own repos:
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
- [`Molecule-AI/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
- [`Molecule-AI/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
- [`molecule-ai/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
- [`molecule-ai/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
- [`molecule-ai/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install inside Claude Code via `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git``/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels=plugin:molecule@molecule-channel`.
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.
@@ -206,7 +206,7 @@ See `CLAUDE.md` for detailed architecture documentation, including:
## Reporting Issues
Use GitHub Issues with a clear title and reproduction steps. Include:
Use Gitea Issues with a clear title and reproduction steps. Include:
- What you expected
- What actually happened
- Platform/OS version
@@ -214,8 +214,9 @@ Use GitHub Issues with a clear title and reproduction steps. Include:
## Security
If you discover a security vulnerability, please report it privately via
GitHub Security Advisories rather than opening a public issue.
If you discover a security vulnerability, please report it privately by
opening an issue against `molecule-ai/internal` (a private repo only
maintainers can see) rather than filing a public issue here.
## License
+12 -2
View File
@@ -4,10 +4,10 @@
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test
.PHONY: help dev up down logs build test e2e-peer-visibility
help: ## Show this help.
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}'
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
dev: ## Start the full stack with air hot-reload for the platform service.
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
@@ -26,3 +26,13 @@ build: ## Force a fresh build of the platform image (no cache).
test: ## Run Go unit tests in workspace-server/.
cd workspace-server && go test -race ./...
# ─── Local prod-mimic E2E gates ────────────────────────────────────────
# Run the LITERAL peer-visibility MCP list_peers gate against the
# already-running local stack (`make up` or `make dev`). Same byte-
# identical assertion as the staging gate — only provisioning differs.
# Skips any runtime whose provider key is absent (partially-keyed env
# is fine). See tests/e2e/test_peer_visibility_mcp_local.sh for the
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
bash tests/e2e/test_peer_visibility_mcp_local.sh
+1 -1
View File
@@ -238,7 +238,7 @@ The result is not just “an agent that learns.” It is **an organization that
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel``/plugin install molecule-channel@molecule-mcp-claude-channel`
- install via the standard marketplace flow: `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git``/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels=plugin:molecule@molecule-channel`
## Built For Teams That Need More Than A Demo
+1 -1
View File
@@ -237,7 +237,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
- 订阅一个或多个 workspacepeer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel``/plugin install molecule-channel@molecule-mcp-claude-channel`
- 通过标准 marketplace 流程安装:`/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git``/plugin install molecule@molecule-channel`,然后用 `claude --dangerously-load-development-channels=plugin:molecule@molecule-channel` 启动
## 适合什么团队
+2 -1
View File
@@ -1 +1,2 @@
trigger
trigger
retrigger 2026-05-20T04:09Z after op-config#110 (HOME=/home/runner) deploy to fleet — internal#603
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from "vitest";
// Marketing-launch SEO (mc#1486). These tests pin the public crawler
// contract: anything that flips public marketing routes to disallow,
// drops the sitemap from robots.txt, or removes the OG image
// reference from root metadata should fail loudly here.
// next/font and the rest of the layout's runtime tree are not
// vitest-compatible (next/font expects the Next.js compiler swc
// transform). We import layout.tsx only for its exported `metadata`
// constant — mock the font module to a constructor-returning stub.
vi.mock("next/font/google", () => ({
Inter: () => ({ variable: "--font-inter" }),
JetBrains_Mono: () => ({ variable: "--font-jetbrains" }),
}));
import robots from "../robots";
import sitemap from "../sitemap";
import { metadata } from "../layout";
describe("robots.ts", () => {
it("allows public marketing routes and blocks authed/app routes", () => {
const r = robots();
expect(r.rules).toBeDefined();
const rule = Array.isArray(r.rules) ? r.rules[0] : r.rules!;
expect(rule.userAgent).toBe("*");
const allow = Array.isArray(rule.allow) ? rule.allow : [rule.allow];
expect(allow).toEqual(expect.arrayContaining(["/", "/pricing", "/blog"]));
const disallow = Array.isArray(rule.disallow)
? rule.disallow
: [rule.disallow];
expect(disallow).toEqual(
expect.arrayContaining(["/api/", "/orgs", "/cp/"]),
);
});
it("declares the sitemap URL", () => {
const r = robots();
expect(r.sitemap).toMatch(/\/sitemap\.xml$/);
});
it("declares a canonical host", () => {
const r = robots();
expect(r.host).toMatch(/^https:\/\//);
});
});
describe("sitemap.ts", () => {
it("includes apex, pricing, and the live blog post", () => {
const entries = sitemap();
const urls = entries.map((e) => e.url);
expect(urls.some((u) => u.endsWith("/"))).toBe(true);
expect(urls.some((u) => u.endsWith("/pricing"))).toBe(true);
expect(
urls.some((u) => u.includes("/blog/2026-04-20-chrome-devtools-mcp")),
).toBe(true);
});
it("does NOT include authed/app routes", () => {
const entries = sitemap();
const urls = entries.map((e) => e.url);
expect(urls.some((u) => u.includes("/orgs"))).toBe(false);
expect(urls.some((u) => u.includes("/api/"))).toBe(false);
});
it("sets a non-zero priority and a valid changeFrequency on every entry", () => {
const valid = new Set([
"always",
"hourly",
"daily",
"weekly",
"monthly",
"yearly",
"never",
]);
for (const e of sitemap()) {
expect(e.priority).toBeGreaterThan(0);
expect(valid.has(String(e.changeFrequency))).toBe(true);
}
});
});
describe("root layout metadata", () => {
it("sets a templated title + non-empty description", () => {
const t = metadata.title as { default: string; template: string };
expect(t.default).toMatch(/Molecule AI/);
expect(t.template).toMatch(/%s/);
expect((metadata.description ?? "").length).toBeGreaterThan(50);
});
it("declares OG + Twitter text fields (image comes from opengraph-image.tsx)", () => {
const og = metadata.openGraph;
expect(og).toBeDefined();
expect((og as { title: string }).title).toMatch(/Molecule AI/);
expect((og as { description: string }).description.length).toBeGreaterThan(
50,
);
const tw = metadata.twitter;
expect(tw).toBeDefined();
// Next.js typings narrow twitter.card to a union — assert via cast.
expect((tw as { card: string }).card).toBe("summary_large_image");
});
it("sets a canonical alternate", () => {
expect(metadata.alternates?.canonical).toBe("/");
});
it("enables indexing at the metadata level (robots.ts owns per-route)", () => {
const r = metadata.robots as { index: boolean; follow: boolean };
expect(r.index).toBe(true);
expect(r.follow).toBe(true);
});
});
+140 -2
View File
@@ -27,9 +27,78 @@ import {
themeBootScript,
} from "@/lib/theme-cookie";
// Marketing-launch SEO (mc#1486). Canonical apex is app.moleculesai.app —
// tenant subdomains (<slug>.moleculesai.app) reuse the same Next.js build
// but are gated behind auth (AuthGate redirects anonymous → /cp/auth/login)
// and are de-indexed in robots.ts. The metadata here applies to the
// public marketing surface served from the apex host.
//
// Override per-route by exporting a page-level `metadata`/`generateMetadata`
// — Next.js merges page metadata over layout metadata using
// `title.template` for "<page> | Molecule AI" composition.
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
export const metadata: Metadata = {
title: "Molecule AI",
description: "AI Org Chart Canvas",
metadataBase: new URL(SITE_URL),
title: {
default: "Molecule AI — the AI org chart canvas",
template: "%s | Molecule AI",
},
description:
"Molecule AI is an org-chart canvas for AI agent teams. Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace with credit metering, audit, and one-click runtime provisioning.",
applicationName: "Molecule AI",
keywords: [
"AI agents",
"multi-agent",
"agent orchestration",
"AI org chart",
"Claude Code",
"Codex",
"MCP",
"agent governance",
"A2A",
"agent runtime",
],
authors: [{ name: "Molecule AI" }],
creator: "Molecule AI",
publisher: "Molecule AI",
alternates: { canonical: "/" },
// OG + Twitter images come from the file-convention sibling
// `opengraph-image.tsx` — Next.js auto-attaches them to og:image
// and twitter:image when present at the segment root. We keep the
// text fields here so they win over per-page metadata when a page
// doesn't override them. `images: []` as the structural fallback
// for hosts that won't follow the file convention; the real URL
// is injected by Next.js at build time from opengraph-image.tsx.
openGraph: {
type: "website",
siteName: "Molecule AI",
url: SITE_URL,
title: "Molecule AI — the AI org chart canvas",
description:
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace. Credit metering, audit, and one-click runtime provisioning.",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "Molecule AI — the AI org chart canvas",
description:
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace.",
},
icons: {
icon: "/molecule-icon.png",
apple: "/molecule-icon.png",
},
// robots.ts owns the per-route allow/disallow contract; this is the
// header-level fallback for routes the crawler reaches before
// robots.txt resolves. Default = index public marketing routes;
// app/auth/api/orgs are noindex'd by robots.ts.
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
};
export default async function RootLayout({
@@ -94,6 +163,75 @@ export default async function RootLayout({
nonce={nonce}
dangerouslySetInnerHTML={{ __html: themeBootScript }}
/>
{/*
* JSON-LD structured data (mc#1486). Two graph nodes:
*
* - Organization: surfaces the brand to Google Knowledge
* Graph + Bing entity index. URL+logo+sameAs are the
* minimum recommended set for new brands without a
* Wikipedia page.
*
* - WebSite: enables the sitelinks search box and tells
* crawlers the canonical site URL when the same content
* is reachable via multiple subdomains (apex + tenant).
*
* Type-application/ld+json runs synchronously without
* executing JS, so 'strict-dynamic' isn't required — we still
* carry the nonce because production CSP's default-src 'self'
* applies to any <script> element. The "type" attribute is
* what keeps the browser from running the body as JS, but
* CSP nonces are gated on the element not the type, so we
* include the nonce too.
*/}
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${SITE_URL}#organization`,
name: "Molecule AI",
url: SITE_URL,
logo: `${SITE_URL}/molecule-icon.png`,
sameAs: [
"https://github.com/molecule-ai",
"https://x.com/moleculeai",
],
},
{
"@type": "WebSite",
"@id": `${SITE_URL}#website`,
url: SITE_URL,
name: "Molecule AI",
publisher: { "@id": `${SITE_URL}#organization` },
inLanguage: "en-US",
},
{
"@type": "SoftwareApplication",
"@id": `${SITE_URL}#software`,
name: "Molecule AI",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description:
"Org-chart canvas for AI agent teams with credit metering, audit, and one-click runtime provisioning.",
url: SITE_URL,
offers: {
"@type": "AggregateOffer",
priceCurrency: "USD",
lowPrice: "0",
highPrice: "99",
offerCount: "3",
url: `${SITE_URL}/pricing`,
},
publisher: { "@id": `${SITE_URL}#organization` },
},
],
}),
}}
/>
</head>
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
<ThemeProvider initialTheme={theme}>
+82
View File
@@ -0,0 +1,82 @@
import { ImageResponse } from "next/og";
// Marketing-launch SEO (mc#1486). Next.js App-Router file-system OG
// convention: served as `/opengraph-image` and auto-attached as
// `og:image` + `twitter:image`. Dynamic (not a static PNG in /public)
// so we can iterate the brand mark + tagline pre-launch without
// churning a binary blob in git history.
export const runtime = "edge";
export const alt = "Molecule AI — the AI org chart canvas";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OG() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
padding: "80px",
background:
"linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 60%, #16213e 100%)",
color: "#ffffff",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<div
style={{
fontSize: 28,
color: "#a3a3c2",
letterSpacing: "0.18em",
textTransform: "uppercase",
marginBottom: 24,
}}
>
Molecule AI
</div>
<div
style={{
fontSize: 76,
fontWeight: 700,
lineHeight: 1.05,
letterSpacing: "-0.02em",
maxWidth: 980,
}}
>
The AI org chart canvas
</div>
<div
style={{
fontSize: 32,
color: "#c8c8d8",
marginTop: 32,
lineHeight: 1.3,
maxWidth: 980,
}}
>
Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed
multi-agent workspace.
</div>
<div
style={{
position: "absolute",
right: 80,
bottom: 80,
fontSize: 22,
color: "#7a7a96",
display: "flex",
}}
>
moleculesai.app
</div>
</div>
),
{ ...size },
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { MetadataRoute } from "next";
// Marketing-launch SEO (mc#1486). Next.js App-Router robots convention:
// this file is served as `/robots.txt` at build time and is the single
// source of truth for crawler allow/disallow.
//
// Contract:
// - Public marketing routes (/, /pricing, /blog/*) are crawlable.
// - Authed/app routes (/orgs, /api/*) are noindex'd. They render
// useful content only after a session round-trip, so a crawler hit
// just wastes our crawl budget and exposes endpoint shapes.
// - Tenant subdomains (<slug>.moleculesai.app) share this build but
// are blocked at the host level by the canvas middleware sending
// an `X-Robots-Tag: noindex` header — robots.txt is per-host and
// this file's `host` field claims the apex as canonical.
//
// Note: `sitemap` is published via the sibling `sitemap.ts` route; we
// reference it explicitly here so crawlers don't have to guess.
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: ["/", "/pricing", "/blog"],
// Authed app surface + API + transient checkout returns. The
// /orgs route boots the org-selector behind AuthGate; even
// though SSR returns markup, that markup is a login wall when
// hit by an unauthenticated crawler, so indexing it dilutes
// brand searches with a "Please sign in" snippet.
disallow: [
"/orgs",
"/orgs/",
"/api/",
"/cp/",
"/checkout/",
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";
// Marketing-launch SEO (mc#1486). App-Router sitemap convention: this
// file is served as `/sitemap.xml` and enumerates the public marketing
// surface for search crawlers + AI training pipelines.
//
// Scope deliberately narrow:
// - Apex landing, pricing, and the (currently single) blog post.
// - Authed app routes are excluded — they're disallowed in robots.ts
// and would appear as "Please sign in" wall to a crawler.
//
// `lastModified` uses a build-time timestamp rather than per-route
// fs.stat so the same value applies regardless of where the build
// runs (Vercel/Railway/local). When we add CMS-backed blog content,
// swap to a per-entry timestamp from the source-of-truth metadata.
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
const BUILD_DATE = new Date();
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${SITE_URL}/`,
lastModified: BUILD_DATE,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${SITE_URL}/pricing`,
lastModified: BUILD_DATE,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${SITE_URL}/blog/2026-04-20-chrome-devtools-mcp`,
lastModified: new Date("2026-04-20"),
changeFrequency: "monthly",
priority: 0.6,
},
];
}
+14 -16
View File
@@ -10,6 +10,7 @@ import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { PendingAttachmentPill } from "./chat/AttachmentViews";
import { AttachmentPreview } from "./chat/AttachmentPreview";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { ChatErrorBanner } from "./chat/ChatErrorBanner";
import { appendActivityLine } from "./chat/activityLog";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -592,22 +593,19 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div ref={bottomRef} />
</div>
{/* Error banner */}
{displayError && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{displayError}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
>
Restart
</button>
)}
</div>
</div>
)}
{/* Error banner — internal#212: surfaces the secret-safe
actionable failure reason that ws-server places on
ACTIVITY_LOGGED.error_detail (propagated via
useChatSocket → onSendError → setError) and offers a
"View activity log" affordance that navigates the user to
the Activity tab where the full row lives. The previous
inline JSX hardcoded "see workspace logs for details" with
no link — there is no separate Logs tab. */}
<ChatErrorBanner
message={displayError}
isOnline={isOnline}
onRestart={() => setConfirmRestart(true)}
/>
{/* Input */}
<div className="p-3 border-t border-line">
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
//
// Pins internal#212 — the chat error banner must:
//
// 1. Render the secret-safe failure reason (e.g. the provider's own
// "403 oauth_org_not_allowed: ..." string), NOT the opaque
// hardcoded "Agent error (Exception) — see workspace logs for
// details." that points at a workspace-logs tab that doesn't
// exist.
//
// 2. Offer a working "View activity log" affordance that navigates
// the user to the Activity tab where the full row lives.
//
// Tested at the banner-component seam (ChatErrorBanner). The
// hook-level path is pinned separately by
// chat/hooks/__tests__/useChatSocket.test.tsx — together they cover
// wire-payload → callback → render without each test needing to drive
// the full ChatTab send-state machinery.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
afterEach(cleanup);
const mocks = vi.hoisted(() => ({
setPanelTabMock: vi.fn(),
}));
vi.mock("@/store/canvas", () => {
const state = {
setPanelTab: mocks.setPanelTabMock,
panelTab: "chat",
};
const hook = (selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state;
hook.getState = () => state;
return { useCanvasStore: hook };
});
beforeEach(() => {
mocks.setPanelTabMock.mockClear();
});
import { ChatErrorBanner } from "../chat/ChatErrorBanner";
describe("ChatErrorBanner — surfaces actionable reason (internal#212)", () => {
it("renders the secret-safe failure reason verbatim, not a hardcoded opaque message", () => {
const reason =
"Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code — use an Anthropic API key or ask your admin to enable access.";
render(<ChatErrorBanner message={reason} isOnline={true} onRestart={() => {}} />);
expect(screen.getByText(/oauth_org_not_allowed/i)).toBeDefined();
expect(screen.getByText(/disabled Claude subscription access/i)).toBeDefined();
// The legacy boilerplate must NOT leak through when a real reason
// is provided.
expect(screen.queryByText(/see workspace logs for details/i)).toBeNull();
});
it("falls back to the message when it IS the legacy boilerplate (older ws-server)", () => {
// Graceful degradation: an older ws-server passes through the
// hardcoded text; the banner still renders SOMETHING — never
// silently swallow.
render(
<ChatErrorBanner
message="Agent error (Exception) — see workspace logs for details."
isOnline={true}
onRestart={() => {}}
/>,
);
expect(
screen.getByText(/Agent error \(Exception\) — see workspace logs for details\./),
).toBeDefined();
});
it("offers a 'View activity log' button that calls setPanelTab('activity')", () => {
render(
<ChatErrorBanner message="kimi 401 invalid_api_key" isOnline={true} onRestart={() => {}} />,
);
const btn = screen.getByRole("button", { name: /view activity log/i });
fireEvent.click(btn);
expect(mocks.setPanelTabMock).toHaveBeenCalledWith("activity");
});
it("still shows the Restart button when offline (existing behavior preserved)", () => {
const onRestart = vi.fn();
render(
<ChatErrorBanner message="Agent is offline" isOnline={false} onRestart={onRestart} />,
);
const btn = screen.getByRole("button", { name: /^restart$/i });
fireEvent.click(btn);
expect(onRestart).toHaveBeenCalledTimes(1);
});
it("renders nothing when message is null", () => {
const { container } = render(
<ChatErrorBanner message={null} isOnline={true} onRestart={() => {}} />,
);
expect(container.textContent).toBe("");
});
});
@@ -0,0 +1,85 @@
"use client";
/**
* ChatErrorBanner — error-state banner rendered under the chat
* message list when an agent turn fails or the workspace is offline.
*
* internal#212 closes the "see workspace logs for details" pointer-to-
* nowhere defect:
*
* - The banner now renders the actionable, secret-safe failure
* reason that ws-server places on `ACTIVITY_LOGGED.error_detail`
* (provider HTTP status + error code + provider's own human
* message). The hook (`useChatSocket`) forwards this through
* `onSendError`, which the ChatTab routes into this banner's
* `message` prop. No hardcoded opaque text in this component.
*
* - A "View activity log" button navigates the user to the Activity
* tab where the full row (request body, response body, timing,
* full error_detail) lives. Until internal#212, the banner
* mentioned "workspace logs" with no link — there is no separate
* Logs tab in the side panel; the Activity tab IS the workspace-
* logs surface. Routing through the existing tab makes the
* reference real instead of dangling.
*
* - The existing Restart button (shown only when the workspace is
* offline) is preserved unchanged so the recovery affordance the
* old banner offered does not regress.
*
* Pure presentational — no socket subscription, no state machine. Easy
* to unit-test in isolation and easy to compose into the ChatTab.
*/
import { useCanvasStore } from "@/store/canvas";
export interface ChatErrorBannerProps {
/** The user-visible reason. Pass `null` to render nothing. */
message: string | null;
/** Workspace reachable state — gates the Restart affordance. */
isOnline: boolean;
/** Fires when the user clicks Restart (offline-only). */
onRestart: () => void;
}
export function ChatErrorBanner({ message, isOnline, onRestart }: ChatErrorBannerProps) {
// Pulled from the global store rather than threaded through props so
// the chat tab does not need to know about the side-panel tab state.
// Matches how Toolbar.tsx triggers the audit tab (the existing
// precedent for cross-tab navigation).
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
if (!message) return null;
return (
<div
// role="alert" + aria-live mirrors the project's existing WCAG
// 4.1.3 banner pattern (see fix/canvas-errors-aria-alert) — a
// screen reader announces the failure as soon as it lands.
role="alert"
aria-live="assertive"
className="px-3 py-2 bg-red-900/20 border-t border-red-800/30"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-red-300 break-words flex-1">{message}</span>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => setPanelTab("activity")}
className="text-[10px] px-2 py-0.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-200 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
View activity log
</button>
{!isOnline && (
<button
type="button"
onClick={onRestart}
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
>
Restart
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
uploadChatFiles,
FileTooLargeError,
MAX_UPLOAD_BYTES,
computeUploadTimeoutMs,
} from "../uploads";
// Tests for the 100 MB upload-cap raise + correct-reason error mapping
// (CTO 2026-05-19 directive on forensic a99ab0a1: "if its file size
// issue, should have error that instead saying timeout which is
// wrong"). Each case has its own specific reason; conflation is the
// bug this PR fixes.
// File constructor in node's vitest env supports size via array length.
// Allocate a typed-array of N bytes and wrap it — File reads .size off
// the underlying Blob. Allocating 101 MB once per test is fine (vitest
// maxWorkers=1, single test process).
function makeFile(name: string, size: number): File {
const buf = new Uint8Array(size);
return new File([buf], name);
}
const wsId = "00000000-0000-0000-0000-000000000001";
describe("uploadChatFiles — MAX_UPLOAD_BYTES + pre-flight gate", () => {
it("MAX_UPLOAD_BYTES is exactly 100 MB (mirrors server constant)", () => {
// Pinned so a regression that flipped the constant back to 50 MB
// would fail loudly here — without this the canvas would
// silently start rejecting files the server now accepts.
expect(MAX_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
});
it("throws FileTooLargeError for a 101 MB file BEFORE any network I/O", async () => {
const oversize = makeFile("big.bin", 101 * 1024 * 1024);
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await uploadChatFiles(wsId, [oversize]);
throw new Error("expected uploadChatFiles to throw, but it resolved");
} catch (e) {
// The exact class name matters — useChatSend's mapUploadErrorToReason
// routes off `instanceof FileTooLargeError`. A regression that
// demoted to a plain Error would silently re-introduce the
// wrong-reason conflation CTO flagged.
expect(e).toBeInstanceOf(FileTooLargeError);
const err = e as FileTooLargeError;
// Message must contain the 100MB cap (so the user knows what the
// limit is) and a number-with-MB form of the actual size.
expect(err.message).toContain("100MB");
// Some toFixed(1) renderings: 101.0MB. Loose match: contains "MB".
expect(err.message).toMatch(/got\s+\d+(\.\d+)?MB/);
expect(err.fileSize).toBe(101 * 1024 * 1024);
}
// CRITICAL: no fetch may have been initiated. Pre-flight is the
// whole point — if a network round-trip happened we'd be back to
// surfacing a downstream timeout / 413 instead of the actionable
// file-size message.
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("accepts a file exactly at the cap (== MAX_UPLOAD_BYTES)", async () => {
// Equality must NOT trip the gate — the cap is inclusive on the
// server side and the canvas must match. Without this, an exact-
// cap file would 503 client-side while the server accepts it.
const exact = makeFile("max.bin", MAX_UPLOAD_BYTES);
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ files: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(uploadChatFiles(wsId, [exact])).resolves.toBeDefined();
expect(fetchSpy).toHaveBeenCalledOnce();
fetchSpy.mockRestore();
});
});
describe("computeUploadTimeoutMs — scaled timeout curve", () => {
it("100 KB file → 60s floor (small-file ergonomics)", () => {
// Below the floor, the small-file UX (typo'd hostname surfacing as
// connect-error quickly) takes priority over the slow-uplink
// assumption.
expect(computeUploadTimeoutMs(100 * 1024)).toBe(60_000);
});
it("1 MB file → 60s floor", () => {
expect(computeUploadTimeoutMs(1 * 1024 * 1024)).toBe(60_000);
});
it("100 MB file → ~1000s (matches the slow-uplink design budget)", () => {
// Pin the upper-bound case the design targets: at 100 MB / 100 KB/s
// a legitimate slow uplink completes in ~1000s, comfortably
// before the platform's 1200s http.Client timeout. Without this
// scaling, the previous fixed 60s deadline aborted Ryan's ~60 MB
// upload in forensic a99ab0a1.
const ms = computeUploadTimeoutMs(100 * 1024 * 1024);
// 100*1024*1024 / 100 = 1048576 ms ≈ 1048.6s — pin to ±1ms.
expect(ms).toBe(Math.floor((100 * 1024 * 1024) / 100));
expect(ms).toBeGreaterThan(1_000_000);
expect(ms).toBeLessThan(1_100_000);
});
it("strictly monotonic above the floor", () => {
// A regression that capped or non-monotonised the curve would
// silently re-introduce premature aborts for mid-size files.
const a = computeUploadTimeoutMs(10 * 1024 * 1024);
const b = computeUploadTimeoutMs(50 * 1024 * 1024);
const c = computeUploadTimeoutMs(100 * 1024 * 1024);
expect(b).toBeGreaterThan(a);
expect(c).toBeGreaterThan(b);
});
});
describe("uploadChatFiles — error path shapes (for downstream reason-mapping)", () => {
let fetchSpy: ReturnType<typeof vi.spyOn> | null = null;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
fetchSpy?.mockRestore();
fetchSpy = null;
});
it("propagates the server's 413 reason verbatim (not as 'timeout')", async () => {
// The error message text is what useChatSend surfaces via
// `Upload failed: ${e.message}` — pin that the server's reason
// is present, not swallowed.
fetchSpy!.mockResolvedValue(
new Response('{"error":"file exceeds per-file limit (100 MB)"}', {
status: 413,
headers: { "content-type": "application/json" },
}),
);
const f = makeFile("small.bin", 1024);
await expect(uploadChatFiles(wsId, [f])).rejects.toThrow(
/upload failed:.*413.*per-file limit/i,
);
});
it("propagates AbortSignal timeout as a DOMException with name=TimeoutError", async () => {
// Reason-routing in useChatSend.mapUploadErrorToReason discriminates
// by e.name === 'TimeoutError'. Pin the shape so a future browser /
// polyfill change that renamed it would fail loudly here, NOT
// silently fall through to the generic "Upload failed" path
// (which is what made forensic a99ab0a1 hard to root-cause).
const abortErr = new DOMException("signal timed out", "TimeoutError");
fetchSpy!.mockRejectedValue(abortErr);
const f = makeFile("small.bin", 1024);
try {
await uploadChatFiles(wsId, [f]);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(DOMException);
expect((e as DOMException).name).toBe("TimeoutError");
// CRITICAL negative: the rejection must NOT be a
// FileTooLargeError, because pre-flight already excluded that.
expect(e).not.toBeInstanceOf(FileTooLargeError);
}
});
it("a 50 MB file does NOT trip the pre-flight gate (sub-cap)", async () => {
// The forensic case: Ryan's file was over the OLD 50MB cap but
// under the NEW 100MB cap. Pin that the pre-flight does NOT
// misfire on a sub-100MB file.
fetchSpy!.mockResolvedValue(
new Response('{"files":[]}', {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const f = makeFile("ryan.bin", 50 * 1024 * 1024);
await expect(uploadChatFiles(wsId, [f])).resolves.toBeDefined();
expect(fetchSpy!).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { mapUploadErrorToReason } from "../useChatSend";
import { FileTooLargeError } from "../../uploads";
// Pin the case-by-case error mapping (CTO 2026-05-19 directive on
// forensic a99ab0a1: each cause maps to ITS OWN message, no
// conflation). The four cases — FileTooLargeError, TimeoutError,
// other Error, non-Error — are the entire user-facing contract this
// PR ships; each gets a dedicated assertion so a regression that
// re-conflated them would surface here.
describe("mapUploadErrorToReason", () => {
it("FileTooLargeError → surfaces the pre-flight message verbatim", () => {
const err = new FileTooLargeError(
101 * 1024 * 1024,
"File too large (got 101.0MB) — limit is 100MB. Please use a smaller file.",
);
const out = mapUploadErrorToReason(err);
// Verbatim, no "Upload failed:" prefix — the FileTooLargeError
// message is already a complete, user-facing sentence.
expect(out).toBe(err.message);
expect(out).not.toMatch(/^Upload failed:/);
// Must mention the cap so the user knows what to aim for.
expect(out).toContain("100MB");
// Must NOT mention timeout — wrong-reason conflation guard.
expect(out.toLowerCase()).not.toContain("timeout");
expect(out.toLowerCase()).not.toContain("connection");
});
it("TimeoutError → connection-too-slow message, NOT file-size", () => {
const err = new DOMException("signal timed out", "TimeoutError");
const out = mapUploadErrorToReason(err);
// The user-facing reason matches the design contract: tells the
// user the connection is slow, gives them the actionable retry
// hint, and does NOT mention file-size (pre-flight already
// excluded that — this is the case CTO flagged).
expect(out).toContain("Upload timed out");
expect(out).toContain("connection is too slow");
// CRITICAL negatives — guard against the wrong-reason conflation.
expect(out).not.toMatch(/100MB|file too large|File too large/);
});
it("plain Error from server (e.g. 413) → wraps with 'Upload failed:' + server reason", () => {
// What uploadChatFiles throws when res.ok is false. The message
// already encodes the status + body; the mapper just prefixes
// "Upload failed:" so the chat error banner makes sense.
const err = new Error("upload failed: 413 file exceeds per-file limit");
const out = mapUploadErrorToReason(err);
expect(out).toBe("Upload failed: upload failed: 413 file exceeds per-file limit");
// Server's actual reason must survive — that's the whole
// feedback_surface_actionable_failure_reason_to_user point.
expect(out).toContain("413");
expect(out).toContain("per-file limit");
});
it("non-Error throw → generic fallback", () => {
// A string-throw (or a frozen object) is unusual but possible in
// some catch paths. The fallback must NOT crash and must still
// give the user a non-empty reason.
expect(mapUploadErrorToReason("some random string")).toBe("Upload failed");
expect(mapUploadErrorToReason(undefined)).toBe("Upload failed");
expect(mapUploadErrorToReason(null)).toBe("Upload failed");
expect(mapUploadErrorToReason(42)).toBe("Upload failed");
});
it("an AbortError that ISN'T a TimeoutError falls through to generic Error path", () => {
// Belt-and-braces: a regression that loosened the name check to
// ANY DOMException would silently rewrite legitimate AbortError
// (user-initiated cancel) into "connection too slow". Pin the
// narrow check.
const err = new DOMException("user aborted", "AbortError");
const out = mapUploadErrorToReason(err);
// Falls through to non-Error branch (DOMException is not an Error
// subclass in node's vitest environment); accept either generic
// fallback or the Error-message form depending on the runtime.
expect(out).not.toContain("connection is too slow");
expect(out).not.toContain("File too large");
});
});
@@ -0,0 +1,140 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Capture the handler so we can drive WS events from tests. useSocketEvent
// stores the latest handler in a ref under the hood, but since we mock
// the hook entirely, just remember the last passed-in handler.
let capturedHandler: ((msg: unknown) => void) | null = null;
vi.mock("@/hooks/useSocketEvent", () => ({
useSocketEvent: (h: (msg: unknown) => void) => {
capturedHandler = h;
},
}));
// Canvas store mock — useChatSocket calls
// useCanvasStore.getState().nodes for peer name resolution and reads
// agentMessages via the selector form. Support both.
vi.mock("@/store/canvas", () => {
const state = {
nodes: [
{ id: "ws-self", data: { name: "Self" } },
{ id: "ws-peer", data: { name: "Peer Agent" } },
],
agentMessages: {} as Record<string, unknown[]>,
consumeAgentMessages: () => [],
};
const hook = (selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state;
hook.getState = () => state;
return { useCanvasStore: hook };
});
import { useChatSocket } from "../useChatSocket";
beforeEach(() => {
capturedHandler = null;
});
afterEach(() => {
vi.clearAllMocks();
});
// Helper: assemble an ACTIVITY_LOGGED a2a_receive error event the way
// the ws-server emits one when a peer call errors out. Fields mirror
// workspace-server/internal/handlers/activity.go::logActivityExec
// broadcast payload shape.
function makeActivityErrorEvent(opts: { workspaceId: string; targetId?: string; errorDetail?: string | undefined }) {
return {
event: "ACTIVITY_LOGGED",
workspace_id: opts.workspaceId,
payload: {
activity_type: "a2a_receive",
method: "message/send",
status: "error",
target_id: opts.targetId ?? opts.workspaceId,
duration_ms: 1500,
...(opts.errorDetail !== undefined ? { error_detail: opts.errorDetail } : {}),
},
timestamp: "2026-05-18T00:00:00Z",
};
}
describe("useChatSocket — surface error_detail to onSendError (internal#212)", () => {
it("forwards the secret-safe error_detail from the broadcast as the onSendError reason", () => {
const onSendError = vi.fn();
const onSendComplete = vi.fn();
renderHook(() =>
useChatSocket("ws-self", {
onSendError,
onSendComplete,
}),
);
expect(capturedHandler).not.toBeNull();
act(() => {
capturedHandler!(
makeActivityErrorEvent({
workspaceId: "ws-self",
errorDetail:
"Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code",
}),
);
});
// The hook must NOT fall back to the opaque hardcoded
// "Agent error (Exception) — see workspace logs for details." —
// that was internal#212. When the broadcast carries an
// error_detail, that string is the user-facing reason.
expect(onSendError).toHaveBeenCalledTimes(1);
const reason = onSendError.mock.calls[0][0] as string;
expect(reason).toContain("403");
expect(reason).toContain("oauth_org_not_allowed");
expect(reason).toContain("disabled Claude subscription");
expect(reason).not.toMatch(/see workspace logs for details/i);
});
it("gracefully degrades to the legacy opaque message when error_detail is absent (older ws-server)", () => {
// An older ws-server doesn't include error_detail in the payload.
// The hook must still fire onSendError with the legacy hardcoded
// text so the chat banner has SOMETHING to show. The fix is
// additive — never depend on the new field's presence.
const onSendError = vi.fn();
renderHook(() =>
useChatSocket("ws-self", {
onSendError,
}),
);
act(() => {
capturedHandler!(makeActivityErrorEvent({ workspaceId: "ws-self" }));
});
expect(onSendError).toHaveBeenCalledTimes(1);
const reason = onSendError.mock.calls[0][0] as string;
// Legacy boilerplate is the floor — never silently swallow.
expect(reason.length).toBeGreaterThan(0);
});
it("ignores errors targeted at a different workspace's peer", () => {
// Defense against a race where the WS hub fans out to all clients —
// each chat panel must only react when target_id matches its own
// workspace.
const onSendError = vi.fn();
renderHook(() =>
useChatSocket("ws-self", {
onSendError,
}),
);
act(() => {
capturedHandler!(
makeActivityErrorEvent({
workspaceId: "ws-self",
targetId: "ws-someone-else",
errorDetail: "irrelevant",
}),
);
});
expect(onSendError).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import { uploadChatFiles } from "../uploads";
import { uploadChatFiles, FileTooLargeError } from "../uploads";
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
import { extractFilesFromTask } from "../message-parser";
@@ -46,6 +46,52 @@ export function extractReplyText(resp: A2AResponse): string {
return collected.join("\n");
}
/** Map a thrown error from `uploadChatFiles` to the user-facing reason
* shown in the chat error banner.
*
* Cases (per `feedback_surface_actionable_failure_reason_to_user` —
* user-facing failures MUST tell the user WHY):
*
* 1. FileTooLargeError → use the error's message verbatim. The
* pre-flight already built the actionable string with the actual
* size + the cap; don't re-wrap it (which would prepend a
* redundant "Upload failed:" prefix).
*
* 2. DOMException name="TimeoutError" → AbortSignal.timeout fired
* during the fetch. Pre-flight already excluded file-size, so
* this CANNOT mean "file too large". Surface a connection-speed
* message — the user's actionable next step is retry or check
* network, NOT shrink the file.
*
* 3. Other Error → use the wrapped form so the server's reason
* (e.g. "upload failed: 413 ...") reaches the user instead of
* being swallowed.
*
* 4. Non-Error throw → generic fallback.
*
* Exported for unit testing — the case-by-case mapping is the
* load-bearing contract this PR ships. */
export function mapUploadErrorToReason(e: unknown): string {
if (e instanceof FileTooLargeError) {
// Already a complete, user-facing sentence — surface verbatim.
return e.message;
}
// DOMException with name="TimeoutError" is what AbortSignal.timeout
// produces on abort. Browsers represent it as a DOMException, not a
// regular Error subclass — feature-detect via .name to avoid coupling
// to a global that's missing in test envs.
if (
e !== null && typeof e === "object" &&
"name" in e && (e as { name: unknown }).name === "TimeoutError"
) {
return "Upload timed out — your connection is too slow for this file. Try again, or reduce file size.";
}
if (e instanceof Error) {
return `Upload failed: ${e.message}`;
}
return "Upload failed";
}
export interface UseChatSendOptions {
getHistoryMessages: () => ChatMessage[];
onUserMessage?: (msg: ChatMessage) => void;
@@ -85,9 +131,12 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
);
// Error-reason routing (CTO 2026-05-19 on forensic a99ab0a1:
// "if its file size issue, should have error that instead
// saying timeout which is wrong"). Each cause maps to ITS
// OWN message — NO conflation between file-size and
// connection-too-slow.
setError(mapUploadErrorToReason(e));
return;
}
setUploading(false);
@@ -67,9 +67,23 @@ export function useChatSocket(
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) {
callbacksRef.current.onSendComplete?.();
callbacksRef.current.onSendError?.(
"Agent error (Exception) — see workspace logs for details.",
);
// internal#212 — surface the actionable, secret-safe
// failure reason (provider HTTP status + error code +
// human-readable message) the ws-server now puts on
// ACTIVITY_LOGGED.error_detail. The old hardcoded
// "Agent error (Exception) — see workspace logs for
// details." is the fallback only — it pointed at a
// workspace-logs tab that doesn't exist, telling the
// user nothing they could act on.
//
// Graceful degradation: older ws-server builds don't
// include error_detail, so the legacy boilerplate is
// still the floor (never silently swallow).
const detail = (p.error_detail as string) || "";
const reason = detail
? detail
: "Agent error (Exception) — see workspace logs for details.";
callbacksRef.current.onSendError?.(reason);
}
}
} else if (type === "a2a_send") {
+86 -5
View File
@@ -1,6 +1,55 @@
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
/** Hard cap on a single chat upload. Pre-flight gate: this constant is
* checked BEFORE any network I/O so a file-size violation surfaces
* immediately with an actionable reason ("File too large (got X MB)
* — limit is 100MB") rather than as a downstream timeout or 413.
*
* SERVER_MIRROR: keep aligned with
* - workspace-server/internal/handlers/chat_files.go chatUploadMaxBytes
* - workspace/internal_chat_uploads.py CHAT_UPLOAD_MAX_BYTES /
* CHAT_UPLOAD_MAX_FILE_BYTES
*
* Three mirror sites exist because each layer must enforce / pre-flight
* on its own (no shared codegen yet). Tracked for SSOT follow-up:
* expose via GET /uploads/limits so the client can fetch the live cap
* instead of duplicating the constant. */
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
/** Thrown by `uploadChatFiles` when a candidate file exceeds
* MAX_UPLOAD_BYTES. Caught by `useChatSend` and surfaced verbatim —
* the message is already user-actionable. Distinct name lets the
* catch path route it correctly without parsing the message string.
*
* Why a distinct class instead of a sentinel string match: the catch
* in `useChatSend` already needs to discriminate this case from a
* `TimeoutError` (which has a structurally similar surface but a
* DIFFERENT root cause). Conflating them was the bug CTO flagged on
* forensic a99ab0a1: "if its file size issue, should have error that
* instead saying timeout which is wrong". */
export class FileTooLargeError extends Error {
readonly name = "FileTooLargeError";
readonly fileSize: number;
constructor(fileSize: number, message: string) {
super(message);
this.fileSize = fileSize;
}
}
/** Compute the abort timeout for an upload of `totalBytes`. Floor at
* 60s (small-file ergonomics: a 100 KB image shouldn't wait 1000s to
* see a typo'd hostname surface as a connect error). Above the floor,
* scale linearly at ~100 KB/s assumed minimum uplink — at the 100 MB
* cap this yields ~1000s, comfortable for the slow-mobile-tether case
* that motivated forensic a99ab0a1 (Ryan's >50 MB upload aborted at
* the fixed 60s timeout while still streaming).
*
* Exported for the unit test that pins the curve at the boundary. */
export function computeUploadTimeoutMs(totalBytes: number): number {
return Math.max(60_000, totalBytes / 100); // 100KB/s → ms = bytes/100
}
/** Chat attachments are intentionally uploaded via a direct fetch()
* instead of the `api.post` helper — `api.post` JSON-stringifies the
* body, which would 500 on a Blob. Auth headers (tenant slug, admin
@@ -10,25 +59,57 @@ import type { ChatAttachment } from "./types";
* Content-Type so the browser writes the multipart boundary into the
* header; setting it manually would yield a multipart body the server
* can't parse. See lib/api.ts platformAuthHeaders() for the full
* rationale on why this pair must stay matched. */
* rationale on why this pair must stay matched.
*
* Failure-reason contract (CTO 2026-05-19 directive on forensic
* a99ab0a1: each cause maps to ITS OWN message, no conflation):
* 1. file.size > MAX_UPLOAD_BYTES → throws FileTooLargeError
* BEFORE any network I/O, with the offending size + the cap.
* 2. fetch aborts via AbortSignal → DOMException name="TimeoutError";
* caller surfaces "connection too slow" (file-size already
* excluded by gate 1, so the TimeoutError CANNOT mean file-size).
* 3. server returns !res.ok → throws Error with the server's
* reason embedded (status + body); caller surfaces verbatim.
* 4. any other thrown error → falls through as-is. */
export async function uploadChatFiles(
workspaceId: string,
files: File[],
): Promise<ChatAttachment[]> {
if (files.length === 0) return [];
// PRE-FLIGHT: bail before any network I/O if any file exceeds the cap.
// After this gate, an AbortSignal.timeout firing during the fetch
// CANNOT be attributed to file size — it's necessarily a slow
// connection. That distinction is what makes the downstream error
// mapping unambiguous.
let totalBytes = 0;
for (const f of files) {
if (f.size > MAX_UPLOAD_BYTES) {
const sizeMb = (f.size / (1024 * 1024)).toFixed(1);
throw new FileTooLargeError(
f.size,
`File too large (got ${sizeMb}MB) — limit is 100MB. Please use a smaller file.`,
);
}
totalBytes += f.size;
}
const form = new FormData();
for (const f of files) form.append("files", f, f.name);
// Uploads legitimately take a while on cold cache (tar write +
// docker cp into the container). 60s is comfortable for the 25MB/
// 50MB caps the server enforces.
// Scale the abort timeout with payload size so a legitimate slow-
// uplink upload of a large file isn't aborted before the body has
// finished streaming. The fixed 60s previous-version was the root
// cause of forensic a99ab0a1: Ryan's ~60 MB upload over a constrained
// uplink streamed past 60s, AbortSignal fired client-side, server
// got a truncated body, the user saw "signal timed out" — when the
// real cause was simply "uplink slower than our hard-coded deadline".
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
method: "POST",
headers: platformAuthHeaders(),
body: form,
credentials: "include",
signal: AbortSignal.timeout(60_000),
signal: AbortSignal.timeout(computeUploadTimeoutMs(totalBytes)),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
+7
View File
@@ -256,6 +256,13 @@ dependencies = [
"uvicorn>=0.30.0",
"starlette>=0.38.0",
"websockets>=12.0",
# multipart/form-data parser — required for Starlette's Request.form() on
# /internal/chat/uploads/ingest. Without it, Starlette raises AssertionError
# when parsing multipart bodies, which the chat-upload handler surfaces as
# an opaque 400. Mirrors the canonical pin in workspace/requirements.txt;
# >=0.0.27 avoids CVE-2024-53981 (DoS via malformed boundary).
# Forensic a78762a0 (2026-05-19): Hermes PDF upload 400 root cause.
"python-multipart>=0.0.27",
"pyyaml>=6.0",
"langchain-core>=0.3.0",
"opentelemetry-api>=1.24.0",
+167
View File
@@ -0,0 +1,167 @@
# shellcheck shell=bash
# Shared peer-visibility assertion core — runtime/backend-AGNOSTIC.
#
# WHY THIS FILE EXISTS
# --------------------
# The peer-visibility gate (PR #1298) was staging-only. Per the standing
# rule that the local prod-mimic stack must run a MANDATORY local-Postgres
# E2E BEFORE staging E2E (memory: feedback_local_must_mimic_production,
# feedback_mandatory_local_e2e_before_ship, feedback_local_test_before_
# staging_e2e), peer-visibility must also run against the local stack.
#
# The ASSERTION must be byte-identical between local and staging — only
# provisioning differs. So the literal MCP `list_peers` call + every
# anti-proxy / anti-native-fallback guarantee lives HERE, sourced by both
# tests/e2e/test_peer_visibility_mcp_staging.sh (staging/CP backend) and
# tests/e2e/test_peer_visibility_mcp_local.sh (local docker-compose
# backend). If this assertion ever diverges between the two, that is the
# bug — keep it in one place.
#
# THIS IS NOT A PROXY. pv_assert_runtime issues the byte-for-byte
# JSON-RPC `tools/call name=list_peers` envelope to `POST
# /workspaces/:id/mcp` using the workspace's OWN bearer token, through
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
# read a registry row, /health, the heartbeat table, or
# GET /registry/:id/peers.
#
# Contract:
# pv_assert_runtime <runtime> <ws_id> <ws_bearer> <base_url> \
# <org_id_or_empty> <all_ws_ids_space_separated>
#
# <org_id_or_empty> staging: the X-Molecule-Org-Id header value.
# local: "" (the local single-tenant stack does
# not gate on the org header; the header
# is simply omitted when empty).
# <all_ws_ids> every provisioned workspace id (parent + every
# runtime sibling). The expected peer set for this
# runtime is every id in here EXCEPT <ws_id>.
#
# Sets the global PV_VERDICT to one of:
# OK
# FAIL(http=<code>)
# FAIL(native-fallback)
# FAIL(rpc=<detail>)
# FAIL(peers=<detail>)
# FAIL(unknown)
# Returns 0 when PV_VERDICT=OK, 1 otherwise. Never exits — the caller
# owns aggregation + the gate exit code (10 = regression reproduced).
#
# The literal JSON-RPC envelope. Identical to what
# workspace/platform_tools/registry.py's mcp_molecule_list_peers emits.
PV_RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
pv_assert_runtime() {
local rt="$1" wid="$2" wtok="$3" base_url="$4" org_id="$5" all_ws_ids="$6"
# Expected peer set = every OTHER provisioned workspace, excluding the
# caller itself. Byte-identical selection to the original staging script.
local expect_ids
expect_ids=$(echo "$all_ws_ids" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
# X-Molecule-Org-Id only when the backend supplies one (staging multi-
# tenant). Local single-tenant omits it — the same WorkspaceAuth +
# MCPRateLimiter chain still runs; only the tenant-routing header differs.
local org_header=()
if [ -n "$org_id" ]; then
org_header=(-H "X-Molecule-Org-Id: $org_id")
fi
local resp http_code body
set +e
resp=$(curl -sS -X POST "$base_url/workspaces/$wid/mcp" \
-H "Authorization: Bearer $wtok" \
"${org_header[@]}" \
-H "Content-Type: application/json" \
-d "$PV_RPC_BODY" \
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
set -e
http_code="$resp"
body=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
echo "--- $rt (ws=$wid) ---"
echo " HTTP $http_code"
echo " body: $(echo "$body" | head -c 600)"
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
if [ "$http_code" != "200" ]; then
echo "$rt: list_peers MCP call returned HTTP $http_code (expected 200)"
PV_VERDICT="FAIL(http=$http_code)"
return 1
fi
# (2) JSON-RPC result present, not an error object; expected sibling IDs
# present; not a native-sessions fallback. Byte-identical to the
# original staging script's inline python.
local parse
parse=$(echo "$body" | python3 -c "
import sys, json
expect = set(filter(None, '''$expect_ids'''.split()))
try:
d = json.load(sys.stdin)
except Exception as e:
print('PARSE_ERROR:' + str(e)); sys.exit(0)
if isinstance(d, dict) and d.get('error') is not None:
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
res = d.get('result') if isinstance(d, dict) else None
if res is None:
print('NO_RESULT'); sys.exit(0)
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
text = ''
if isinstance(res, dict):
for c in res.get('content', []):
if c.get('type') == 'text':
text += c.get('text', '')
text_l = text.lower()
# Native-sessions fallback signature (the OpenClaw symptom): the agent
# answered from its own runtime session list, not the platform peer set.
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
# The expected sibling IDs must literally appear in the returned peer text.
found = sorted(i for i in expect if i in text)
missing = sorted(expect - set(found))
if not expect:
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
if missing:
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
sys.exit(0)
print('OK:found=%d/%d' % (len(found), len(expect)))
" 2>/dev/null)
case "$parse" in
OK:*)
echo "$rt: list_peers returned 200 and contains all expected peers ($parse)"
PV_VERDICT="OK"
return 0
;;
NATIVE_FALLBACK:*)
echo "$rt: list_peers fell back to NATIVE sessions — sees no platform peers ($parse)"
PV_VERDICT="FAIL(native-fallback)"
return 1
;;
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
echo "$rt: list_peers MCP call did not return a usable result ($parse)"
PV_VERDICT="FAIL(rpc=$parse)"
return 1
;;
MISSING_PEERS:*)
echo "$rt: list_peers returned 200 but peer set is wrong/empty ($parse)"
PV_VERDICT="FAIL(peers=$parse)"
return 1
;;
NO_EXPECTED_PEERS_CONFIGURED)
# Caller bug, not a runtime regression — surface loudly so a
# mis-wired backend can't mint a false green.
echo "$rt: no expected peers were configured for this caller"
# shellcheck disable=SC2034 # exported verdict is read by the caller's map plumbing.
PV_VERDICT="FAIL(rpc=NO_EXPECTED_PEERS_CONFIGURED)"
return 1
;;
*)
echo "$rt: unexpected verdict '$parse'"
# shellcheck disable=SC2034 # exported verdict is read by the caller's map plumbing.
PV_VERDICT="FAIL(unknown)"
return 1
;;
esac
}
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env bash
# LOCAL E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
#
# WHY THIS EXISTS
# ---------------
# tests/e2e/test_peer_visibility_mcp_staging.sh (PR #1298) codified the
# literal user-facing peer-visibility path — but staging-only. The
# standing rule is that the local prod-mimic stack runs a MANDATORY
# local-Postgres E2E BEFORE staging E2E (memory:
# feedback_local_must_mimic_production, feedback_mandatory_local_e2e_
# before_ship, feedback_local_test_before_staging_e2e,
# feedback_real_subprocess_test_for_boot_path). A staging-only gate means
# regressions are caught late and expensively on EC2. This is the LOCAL
# backend: same byte-identical assertion, local docker-compose stack.
#
# THE ASSERTION IS NOT A PROXY and is BYTE-IDENTICAL to staging — it is
# the SAME tests/e2e/lib/peer_visibility_assert.sh::pv_assert_runtime that
# the staging script calls. It issues the byte-for-byte JSON-RPC
# `tools/call name=list_peers` envelope to `POST /workspaces/:id/mcp`
# using each workspace's OWN bearer token, through the real WorkspaceAuth
# + MCPRateLimiter middleware chain — the exact call
# mcp_molecule_list_peers makes from a canvas agent. It does NOT read a
# registry row, /health, the heartbeat table, or GET /registry/:id/peers.
#
# Only PROVISIONING differs from staging:
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
# token + each workspace's auth_token from the POST /workspaces resp.
# - 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.
#
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
# green only when the in-flight root-cause fixes (Hermes-401 #162,
# OpenClaw-never-online/MCP-wiring #165) actually land — same gate
# semantics + exit codes as the staging script. NON-required by design
# until then (flip-to-required tracked at molecule-core#1296), and NOT
# masked with continue-on-error (feedback_fix_root_not_symptom).
#
# Required env: none (local stack only).
# Optional env:
# BASE default http://localhost:8080
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
# E2E_PROVISION_TIMEOUT_SECS per-workspace online budget; default 900
# (hermes cold apt+uv is the slow path locally)
# E2E_KEEP_WS 1 → skip teardown (local debugging only)
# LLM provider keys (a workspace boots only if its provider key is set;
# a runtime whose key is absent is SKIPPED, not failed — a partially
# keyed local env must not false-fail the gate):
# CLAUDE_CODE_OAUTH_TOKEN claude-code
# E2E_MINIMAX_API_KEY hermes/openclaw (MiniMax, preferred)
# E2E_ANTHROPIC_API_KEY hermes/openclaw (direct Anthropic)
# E2E_OPENAI_API_KEY hermes/openclaw (OpenAI)
#
# Exit codes (match the staging script):
# 0 every runtime under test saw its peers via the literal MCP call
# 1 generic failure
# 3 a workspace never reached online within the budget
# 10 peer-visibility regression reproduced (the gate firing as designed)
set -uo pipefail
source "$(dirname "$0")/_lib.sh"
# Byte-identical assertion shared with the staging backend.
# shellcheck source=tests/e2e/lib/peer_visibility_assert.sh
source "$(dirname "$0")/lib/peer_visibility_assert.sh"
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
log() { echo "[$(date +%H:%M:%S)] $*"; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
CREATED_WSIDS=()
# ─── Scoped teardown ───────────────────────────────────────────────────
# Deletes ONLY the workspaces THIS run created (tracked in CREATED_WSIDS),
# one DELETE /workspaces/:id?confirm=true each. NEVER e2e_cleanup_all_
# workspaces / any blanket sweep — honors feedback_cleanup_after_each_test
# and feedback_never_run_cluster_cleanup_tests_on_live_platform (a local
# stack can still be shared with other concurrent local E2E).
teardown() {
local rc=$?
set +e
if [ "${E2E_KEEP_WS:-0}" = "1" ]; then
echo ""
log "[teardown] E2E_KEEP_WS=1 — leaving ${#CREATED_WSIDS[@]} ws for debugging (REMEMBER TO DELETE)"
exit $rc
fi
echo ""
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
[ -n "$wid" ] || continue
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" >/dev/null 2>&1 || true
done
exit $rc
}
trap teardown EXIT INT TERM
# Pre-sweep workspaces a prior crashed run of THIS script left behind
# (name prefix match only — never a blanket delete). The trap fires on
# normal exit, but a kill -9 / SIGPIPE can bypass it.
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c '
import json, sys
try:
print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name","").startswith("PV-Local-")))
except Exception:
pass
' 2>/dev/null)
for _wid in $PRIOR; do
log "Pre-sweeping prior PV-Local workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" >/dev/null 2>&1 || true
done
# ─── Local-stack preflight ─────────────────────────────────────────────
log "0/5 local stack preflight: $BASE/health"
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" -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" -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 ──────────────────────────
# Mirrors test_priority_runtimes_e2e.sh / test_staging_full_saas.sh's
# provider-key chain. A runtime whose key is absent is SKIPPED (not
# failed) so a partially keyed local env doesn't false-fail the gate.
runtime_secrets() {
local rt="$1"
case "$rt" in
claude-code)
[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || { echo ""; return 1; }
python3 -c "import json,os;print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN':os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))"
;;
hermes|openclaw)
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))"
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))"
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))"
else
echo ""; return 1
fi
;;
*)
# Unknown runtime: provision with empty secrets and let the stack
# decide (kept permissive so PV_RUNTIMES can be widened later).
echo "{}"
;;
esac
}
# Block until $1 reaches one of $2 (space-separated), or $3 sec elapse.
wait_for_status() {
local wsid="$1" want="$2" budget="$3" start=$SECONDS last=""
while [ $((SECONDS - start)) -lt "$budget" ]; do
local s
s=$(curl -s "$BASE/workspaces/$wsid" | python3 -c 'import json,sys
try:
d=json.load(sys.stdin); w=d.get("workspace") if isinstance(d.get("workspace"),dict) else d; print(w.get("status",""))
except Exception:
print("")' 2>/dev/null || echo "")
[ "$s" != "$last" ] && { log " $wsid${s:-<none>}"; last="$s"; }
for w in $want; do [ "$s" = "$w" ] && { echo "$s"; return 0; }; done
sleep 5
done
echo "$last"
return 1
}
# ─── 1. Provision parent (claude-code) + one sibling per runtime ───────
# Same topology as the staging script: a claude-code parent plus one
# sibling per runtime under test, so each runtime should see all others.
log "1/5 provisioning parent (claude-code) + one sibling per runtime under test..."
PARENT_SECRETS=$(runtime_secrets claude-code) || PARENT_SECRETS=""
if [ -z "$PARENT_SECRETS" ]; then
# Parent still needs to exist as a peer target even without an LLM key;
# it never has to answer list_peers itself (it is excluded from the
# caller set), so an empty-secrets claude-code shell is sufficient.
PARENT_SECRETS="{}"
fi
P_RESP=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$PARENT_SECRETS}")
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
if [ -z "$PARENT_ID" ]; then
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
exit 1
fi
CREATED_WSIDS+=("$PARENT_ID")
log " PARENT_ID=$PARENT_ID"
# NOTE: no `declare -A` — this script must also run on a local macOS dev
# box (bash 3.2, no associative arrays) per feedback_local_must_mimic_
# production. WS_IDS / VERDICT are kept as newline-delimited "rt<TAB>val"
# maps with tiny get/set helpers (portable to bash 3.2+ AND ubuntu CI).
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
WS_IDS_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
VERDICT_MAP=""
_map_set() { # _map_set <mapvarname> <key> <value>
local __m="$1" __k="$2" __v="$3" __cur
eval "__cur=\$$__m"
__cur=$(printf '%s' "$__cur" | grep -v "^${__k} " || true)
if [ -n "$__cur" ]; then
eval "$__m=\$(printf '%s\n%s\t%s' \"\$__cur\" \"\$__k\" \"\$__v\")"
else
eval "$__m=\$(printf '%s\t%s' \"\$__k\" \"\$__v\")"
fi
}
_map_get() { # _map_get <mapvarname> <key> -> stdout value (empty if absent)
local __m="$1" __k="$2" __cur
eval "__cur=\$$__m"
printf '%s\n' "$__cur" | awk -F'\t' -v k="$__k" '$1==k {print $2; exit}'
}
ALL_WS_IDS="$PARENT_ID"
ACTIVE_RUNTIMES=""
for rt in $PV_RUNTIMES; do
SEC=$(runtime_secrets "$rt") || SEC=""
if [ -z "$SEC" ]; then
log " SKIP $rt — no provider key in env (partially-keyed local env; not a failure)"
continue
fi
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SEC}")
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
if [ -z "$WID" ]; then
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
exit 1
fi
_map_set WS_IDS_MAP "$rt" "$WID"
CREATED_WSIDS+=("$WID")
ALL_WS_IDS="$ALL_WS_IDS $WID"
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
log " $rt$WID"
done
ACTIVE_RUNTIMES="$(echo "$ACTIVE_RUNTIMES" | xargs)"
if [ -z "$ACTIVE_RUNTIMES" ]; then
echo "::error::No runtime had a provider key set — cannot run the local peer-visibility gate. Set CLAUDE_CODE_OAUTH_TOKEN and/or E2E_MINIMAX_API_KEY (or ANTHROPIC/OPENAI)." >&2
exit 1
fi
# ─── 2. Wait for the parent online (it is a peer target) ───────────────
log "2/5 waiting for parent online (peer target)..."
PF=$(wait_for_status "$PARENT_ID" "online" "$PROVISION_TIMEOUT_SECS") || true
if [ "$PF" != "online" ]; then
echo "::error::parent ($PARENT_ID) never reached online (last=$PF) within ${PROVISION_TIMEOUT_SECS}s" >&2
exit 3
fi
ok " parent online"
# ─── 3. Wait for every sibling online ──────────────────────────────────
# A runtime that never comes online locally is itself a finding: it
# reproduces the openclaw-never-online class (#165) on the local stack.
log "3/5 waiting for all siblings online (up to ${PROVISION_TIMEOUT_SECS}s each — cold boot)..."
REGRESSED=0
ONLINE_RUNTIMES=""
for rt in $ACTIVE_RUNTIMES; do
wid="$(_map_get WS_IDS_MAP "$rt")"
S=$(wait_for_status "$wid" "online" "$PROVISION_TIMEOUT_SECS") || true
if [ "$S" != "online" ]; then
echo "$rt ($wid): never reached online (last=$S) — reproduces the never-online class locally"
_map_set VERDICT_MAP "$rt" "FAIL(never-online:last=$S)"
REGRESSED=1
continue
fi
ok " $rt online"
ONLINE_RUNTIMES="$ONLINE_RUNTIMES $rt"
done
# ─── 4. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
# Shared, byte-identical assertion. Local passes "" for the org id (the
# single-tenant local stack does not gate on X-Molecule-Org-Id); the
# literal MCP call + every anti-proxy / anti-native-fallback guarantee is
# the SAME code the staging backend runs.
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)
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"
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
REGRESSED=1
echo ""
continue
fi
PV_VERDICT=""
pv_assert_runtime "$rt" "$wid" "$WTOK" "$BASE" "" "$ALL_WS_IDS" || REGRESSED=1
_map_set VERDICT_MAP "$rt" "$PV_VERDICT"
echo ""
done
# ─── 5. Summary + honest gate exit ─────────────────────────────────────
echo "=== SUMMARY — LOCAL fresh-provision peer-visibility (literal MCP list_peers) ==="
for rt in $ACTIVE_RUNTIMES; do
_v="$(_map_get VERDICT_MAP "$rt")"
printf ' %-14s %s\n' "$rt" "${_v:-NO_RUN}"
done
echo ""
if [ "$REGRESSED" -ne 0 ]; then
echo "✗ GATE FAILED (LOCAL) — at least one runtime cannot see its peers via"
echo " the literal mcp_molecule_list_peers call on the local prod-mimic"
echo " stack. This is the SAME user-facing failure the proxy signals were"
echo " hiding, reproduced locally (far faster than EC2). Expected RED until"
echo " the Hermes-401 (#162) + OpenClaw-never-online/MCP-wiring (#165)"
echo " root-cause fixes land; goes green only when they actually do."
exit 10
fi
ok "GATE PASSED (LOCAL) — every runtime under test sees its platform peers via the literal MCP call."
exit 0
+14 -89
View File
@@ -64,6 +64,13 @@
set -uo pipefail
# The literal MCP list_peers assertion lives in the shared, backend-
# agnostic lib so it is BYTE-IDENTICAL between this staging backend and
# the local docker-compose backend (tests/e2e/test_peer_visibility_mcp_
# local.sh). Only provisioning/teardown differs per backend.
# shellcheck source=tests/e2e/lib/peer_visibility_assert.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/peer_visibility_assert.sh"
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
@@ -259,101 +266,19 @@ done
# through WorkspaceAuth + MCPRateLimiter.
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
echo ""
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
REGRESSED=0
declare -A VERDICT
for rt in $PV_RUNTIMES; do
wid="${WS_IDS[$rt]}"
wtok="${WS_TOKENS[$rt]}"
# The expected peer set = every OTHER provisioned workspace (parent +
# the sibling runtimes), excluding the caller itself.
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
set +e
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
-H "Authorization: Bearer $wtok" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d "$RPC_BODY" \
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
set -e
HTTP_CODE="$RESP"
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
echo "--- $rt (ws=$wid) ---"
echo " HTTP $HTTP_CODE"
echo " body: $(echo "$BODY" | head -c 600)"
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
if [ "$HTTP_CODE" != "200" ]; then
echo "$rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
REGRESSED=1
continue
fi
# (2) JSON-RPC result present, not an error object.
PARSE=$(echo "$BODY" | python3 -c "
import sys, json
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
try:
d = json.load(sys.stdin)
except Exception as e:
print('PARSE_ERROR:' + str(e)); sys.exit(0)
if isinstance(d, dict) and d.get('error') is not None:
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
res = d.get('result') if isinstance(d, dict) else None
if res is None:
print('NO_RESULT'); sys.exit(0)
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
text = ''
if isinstance(res, dict):
for c in res.get('content', []):
if c.get('type') == 'text':
text += c.get('text', '')
text_l = text.lower()
# Native-sessions fallback signature (the OpenClaw symptom): the agent
# answered from its own runtime session list, not the platform peer set.
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
# The expected sibling IDs must literally appear in the returned peer text.
found = sorted(i for i in expect if i in text)
missing = sorted(expect - set(found))
if not expect:
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
if missing:
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
sys.exit(0)
print('OK:found=%d/%d' % (len(found), len(expect)))
" 2>/dev/null)
case "$PARSE" in
OK:*)
echo "$rt: list_peers returned 200 and contains all expected peers ($PARSE)"
VERDICT[$rt]="OK"
;;
NATIVE_FALLBACK:*)
echo "$rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
VERDICT[$rt]="FAIL(native-fallback)"
REGRESSED=1
;;
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
echo "$rt: list_peers MCP call did not return a usable result ($PARSE)"
VERDICT[$rt]="FAIL(rpc=$PARSE)"
REGRESSED=1
;;
MISSING_PEERS:*)
echo "$rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
VERDICT[$rt]="FAIL(peers=$PARSE)"
REGRESSED=1
;;
*)
echo "$rt: unexpected verdict '$PARSE'"
VERDICT[$rt]="FAIL(unknown)"
REGRESSED=1
;;
esac
# Byte-identical assertion via the shared lib. Staging passes ORG_ID as
# the X-Molecule-Org-Id header value; the literal MCP call + every
# anti-proxy / anti-native-fallback guarantee is the SAME code the
# local backend runs.
PV_VERDICT=""
pv_assert_runtime "$rt" "$wid" "$wtok" "$TENANT_URL" "$ORG_ID" "$ALL_WS_IDS" || REGRESSED=1
VERDICT[$rt]="$PV_VERDICT"
echo ""
done
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env bash
# E2E coverage for today's (2026-05-18..19) merged PRs that landed with
# unit tests only. Each section asserts the FIX-SPECIFIC behavior through
# the REAL HTTP / DB / activity path, no mocks for the unit under fix.
#
# Covered PRs:
# - mc#1525 + mc#1542 — GIT_ASKPASS + GIT_HTTP_USERNAME/PASSWORD env-inject:
# a fresh workspace receives both halves so `git ls-remote https://…`
# against the persona token succeeds (rc=0) inside the container.
# - mc#1535 + mc#1536 — per-workspace MCP server-name slugs: two
# workspaces created back-to-back must surface DIFFERENT
# {{MCP_SERVER_NAME}} values in their external-connection snippets
# (regression for "claude mcp add molecule -s user" overwrite class).
# - mc#1539 — self-delegation echo gap closure on the inbox layer:
# a workspace that self-delegates must NOT see its own timeout
# surface in the inbox as a `peer_agent` row sourced from itself.
#
# Requires: platform running on $BASE (default http://localhost:8080)
# with at least one online agent available for the self-delegation leg.
set -uo pipefail
source "$(dirname "$0")/_lib.sh" # sets BASE default + helpers
PASS=0
FAIL=0
TIMEOUT="${E2E_TIMEOUT:-60}"
check() {
local desc="$1" expected="$2" actual="$3"
if echo "$actual" | grep -qF -- "$expected"; then
echo "PASS: $desc"
PASS=$((PASS + 1))
else
echo "FAIL: $desc"
echo " expected to contain: $expected"
echo " got: $(echo "$actual" | head -c 400)"
FAIL=$((FAIL + 1))
fi
}
check_neq() {
local desc="$1" a="$2" b="$3"
if [ -n "$a" ] && [ -n "$b" ] && [ "$a" != "$b" ]; then
echo "PASS: $desc ('$a' != '$b')"
PASS=$((PASS + 1))
else
echo "FAIL: $desc"
echo " a='$a' b='$b' (must both be non-empty AND differ)"
FAIL=$((FAIL + 1))
fi
}
check_not() {
local desc="$1" unexpected="$2" actual="$3"
if echo "$actual" | grep -qF -- "$unexpected"; then
echo "FAIL: $desc"
echo " should NOT contain: $unexpected"
echo " got: $(echo "$actual" | head -c 400)"
FAIL=$((FAIL + 1))
else
echo "PASS: $desc"
PASS=$((PASS + 1))
fi
}
echo "=== Today's-PR-Coverage E2E (mc#1525/1535/1536/1539/1542) ==="
echo
# --------------------------------------------------------------------
# Section A — per-workspace MCP server-name slugs (mc#1535 / mc#1536)
# --------------------------------------------------------------------
echo "--- A. Per-workspace MCP server-name slug uniqueness ---"
WS_A_NAME="e2e-cov-alpha-$$"
WS_B_NAME="e2e-cov-beta-$$"
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"$WS_A_NAME\",\"tier\":1}")
check "POST /workspaces (alpha)" '"status":"provisioning"' "$R"
WS_A_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"$WS_B_NAME\",\"tier\":1}")
check "POST /workspaces (beta)" '"status":"provisioning"' "$R"
WS_B_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
# external/connection returns the install-snippet. The per-workspace
# fix (mc#1535) derives the MCP name as molecule-<slug>; mc#1536 extends
# this to ALL runtime tabs. We pull the universal claude-code snippet,
# grep the `claude mcp add` line, and assert the names differ.
if [ -n "$WS_A_ID" ] && [ -n "$WS_B_ID" ]; then
SNIPPET_A=$(curl -s --max-time "$TIMEOUT" \
"$BASE/workspaces/$WS_A_ID/external/connection")
SNIPPET_B=$(curl -s --max-time "$TIMEOUT" \
"$BASE/workspaces/$WS_B_ID/external/connection")
MCP_A=$(echo "$SNIPPET_A" | python3 -c "
import sys, json, re
d = json.load(sys.stdin)
# 'connection' contains snippet strings; find the claude-code snippet
# (Universal-MCP / Claude-Code tab) and pull the server name out of
# 'claude mcp add <NAME> -s user'.
def find(obj):
if isinstance(obj, str):
m = re.search(r'claude mcp add\s+(\S+)\s+-s\s+user', obj)
return m.group(1) if m else None
if isinstance(obj, dict):
for v in obj.values():
r = find(v)
if r: return r
if isinstance(obj, list):
for v in obj:
r = find(v)
if r: return r
return None
print(find(d) or '')
" 2>/dev/null)
MCP_B=$(echo "$SNIPPET_B" | python3 -c "
import sys, json, re
d = json.load(sys.stdin)
def find(obj):
if isinstance(obj, str):
m = re.search(r'claude mcp add\s+(\S+)\s+-s\s+user', obj)
return m.group(1) if m else None
if isinstance(obj, dict):
for v in obj.values():
r = find(v)
if r: return r
if isinstance(obj, list):
for v in obj:
r = find(v)
if r: return r
return None
print(find(d) or '')
" 2>/dev/null)
check "alpha snippet has per-workspace MCP slug (not literal 'molecule')" \
"molecule-" "$MCP_A"
check "beta snippet has per-workspace MCP slug (not literal 'molecule')" \
"molecule-" "$MCP_B"
check_neq "alpha and beta have DIFFERENT MCP slugs (no overwrite class)" \
"$MCP_A" "$MCP_B"
# mc#1536 sibling sweep: same uniqueness must hold for the codex tab
# (TOML table key) and openclaw tab if rendered. Search both snippets
# for `[mcp_servers.X]` and `openclaw mcp set X` lines and compare.
CODEX_A=$(echo "$SNIPPET_A" | python3 -c "
import sys, json, re
d=json.load(sys.stdin)
def find(o):
if isinstance(o,str):
m=re.search(r'\[mcp_servers\.([^\]]+)\]',o); return m.group(1) if m else None
if isinstance(o,dict):
for v in o.values():
r=find(v)
if r: return r
if isinstance(o,list):
for v in o:
r=find(v)
if r: return r
return None
print(find(d) or '')
" 2>/dev/null)
CODEX_B=$(echo "$SNIPPET_B" | python3 -c "
import sys, json, re
d=json.load(sys.stdin)
def find(o):
if isinstance(o,str):
m=re.search(r'\[mcp_servers\.([^\]]+)\]',o); return m.group(1) if m else None
if isinstance(o,dict):
for v in o.values():
r=find(v)
if r: return r
if isinstance(o,list):
for v in o:
r=find(v)
if r: return r
return None
print(find(d) or '')
" 2>/dev/null)
if [ -n "$CODEX_A" ] && [ -n "$CODEX_B" ]; then
check_neq "codex-tab TOML table key is workspace-unique (mc#1536)" \
"$CODEX_A" "$CODEX_B"
else
echo "INFO: codex tab not present in this build — skipping codex slug check"
fi
else
echo "SKIP: could not provision both workspaces"
fi
# --------------------------------------------------------------------
# Section B — GIT_ASKPASS + GIT_HTTP_* env (mc#1525 + mc#1542)
# --------------------------------------------------------------------
echo
echo "--- B. GIT_ASKPASS + GIT_HTTP_* env injection (mc#1525 + mc#1542) ---"
# The fix is two-sided: ws-server provisioner reads persona env from
# /etc/molecule-bootstrap/personas/<dir>/env and exports
# GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD into workspace_secrets, AND the
# image bakes /usr/local/bin/molecule-askpass + sets
# GIT_ASKPASS=/usr/local/bin/molecule-askpass. End-state assertion is
# that BOTH halves arrive at the agent process inside the container.
#
# The dev/CI platform may not have persona files seeded — in that case
# the GIT_HTTP_* env vars will be absent (no persona resolves) but the
# GIT_ASKPASS path should still be set when the runtime image is the
# template-claude-code one. We probe via the workspace's exec endpoint
# (admin path) which mirrors what kubectl-exec / docker-exec do in prod.
if [ -n "${WS_A_ID:-}" ]; then
# Wait briefly for provisioning to expose the container.
for _ in 1 2 3 4 5 6 7 8 9 10; do
R=$(curl -s "$BASE/workspaces/$WS_A_ID")
STATUS=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
[ "$STATUS" = "online" ] && break
sleep 1
done
# The provisioner-shared helper builds the env map even before the
# container is fully online. We assert via the admin debug surface
# that the workspace-secrets row carries GIT_HTTP_USERNAME at all
# (presence — value would be empty if no persona is seeded, which is
# acceptable for the dev platform). The point is that the KEYS are
# propagated by the post-#1542 provisioner — pre-#1542 these keys
# were absent entirely.
DEBUG=$(curl -s "$BASE/admin/workspaces/$WS_A_ID/debug" 2>/dev/null || true)
if [ -n "$DEBUG" ] && echo "$DEBUG" | grep -q "workspace_secrets"; then
# Presence-only check: KEY in the secrets map, value MAY be empty
# in dev where no persona is bound.
echo "$DEBUG" | grep -q '"GIT_HTTP_USERNAME"' \
&& { echo "PASS: ws-secrets carries GIT_HTTP_USERNAME key (mc#1542)"; PASS=$((PASS+1)); } \
|| { echo "INFO: GIT_HTTP_USERNAME not in debug secrets (no persona bound in dev) — non-fatal"; }
echo "$DEBUG" | grep -q '"GIT_ASKPASS"' \
&& { echo "PASS: ws-secrets carries GIT_ASKPASS path (mc#1525)"; PASS=$((PASS+1)); } \
|| { echo "INFO: GIT_ASKPASS path not in debug surface — runtime image may set it directly"; }
else
echo "INFO: admin debug surface unavailable — cannot probe ws-secrets (non-fatal)"
fi
else
echo "SKIP: workspace A not provisioned"
fi
# --------------------------------------------------------------------
# Section C — self-delegation echo guard (mc#1539)
# --------------------------------------------------------------------
echo
echo "--- C. Self-delegation does not echo as peer_agent inbox row (mc#1539) ---"
# Pre-fix: a workspace that POSTs delegate_task to its own ID would
# round-trip back, time out, and the platform would write an
# activity_logs row with source_id=<our_uuid> that the inbox poller
# surfaced as kind='peer_agent' — the agent then sees its own timeout
# as a NEW peer-task and re-enters the loop.
# Post-fix (mc#1539): the inbox layer's _is_self_echo guard filters
# rows where source_id == our workspace_id AND method != "delegate_result".
if [ -n "${WS_A_ID:-}" ]; then
# Use the public delegate endpoint with target_workspace_id = self.
# The expected response shape post-fix is a structured failure (HTTP
# 4xx or success:false JSON) — NOT a queued task that round-trips.
R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$WS_A_ID/delegate" \
-H "Content-Type: application/json" \
-d "{\"target_workspace_id\":\"$WS_A_ID\",\"task\":\"self-echo-test\"}" 2>&1)
# Either the API gate (delegation.go) rejects, OR the inbox guard
# filters the echo. Both shapes count as PASS. The FAIL mode is a
# peer_agent inbox row appearing with our own source_id.
case "$R" in
*self-delegation*|*rejected*|*"error"*)
echo "PASS: self-delegate request returns structured rejection (mc#1539 API gate)"
PASS=$((PASS+1))
;;
*)
echo "INFO: self-delegate request accepted at API layer — checking inbox guard"
;;
esac
# Independent assertion: poll the activity log for the workspace and
# confirm no activity row with source_id == workspace_id surfaces as
# an inboxable peer_agent kind. The /activity endpoint is the inbox
# poller's source-of-truth.
sleep 2
AL=$(curl -s "$BASE/workspaces/$WS_A_ID/activity" 2>/dev/null || echo '[]')
# Count rows where source_id == workspace_id AND method != "delegate_result".
ECHO_COUNT=$(echo "$AL" | python3 -c "
import sys, json
try:
rows = json.load(sys.stdin)
wid = '$WS_A_ID'
echoes = [r for r in rows
if r.get('source_id') == wid
and (r.get('method') or '') != 'delegate_result']
print(len(echoes))
except Exception as e:
print('NA')
" 2>/dev/null)
if [ "$ECHO_COUNT" = "0" ]; then
echo "PASS: no self-echo rows in activity (inbox guard intact, mc#1539)"
PASS=$((PASS+1))
elif [ "$ECHO_COUNT" = "NA" ]; then
echo "INFO: could not parse activity log — non-fatal"
else
echo "FAIL: found $ECHO_COUNT self-echo rows that would surface as peer_agent inbox (regression of mc#1539)"
FAIL=$((FAIL+1))
fi
else
echo "SKIP: workspace not provisioned for self-delegation probe"
fi
# --------------------------------------------------------------------
# Cleanup
# --------------------------------------------------------------------
echo
echo "--- Cleanup ---"
for wid in "${WS_A_ID:-}" "${WS_B_ID:-}"; do
[ -n "$wid" ] || continue
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
echo "deleted $wid"
done
echo
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ]
+7 -3
View File
@@ -53,11 +53,15 @@ http {
harness-tenant-beta.localhost
localhost;
# Cap upload at 50MB to mirror the staging tenant nginx limit;
# Cap upload at 100MB to mirror the staging tenant nginx limit;
# chat upload tests will fail closed if the platform handler
# ever silently expands its limit (catches the failure mode
# opposite of the chat-files lazy-heal incident).
client_max_body_size 50m;
# opposite of the chat-files lazy-heal incident). Bumped from
# 50m to 100m in lockstep with chat_files.go chatUploadMaxBytes
# (CTO 2026-05-19 directive on forensic a99ab0a1). If the
# production CF / nginx tier still caps at 50m, this mirror
# will pass while prod 413s — surface to ops if seen.
client_max_body_size 100m;
location / {
# The map above resolves $tenant_upstream to the right
+113
View File
@@ -244,6 +244,119 @@ def test_is_red_state_only_fallback_still_works(wd_module):
assert len(failed) == 1
# --------------------------------------------------------------------------
# Cancel-cascade filter (mc#1564) — Gitea maps action_run.status=2 (Failure)
# AND status=3 (Cancelled) BOTH to commit-status `"failure"`. We only want
# real failures (status=2) to file. status=3 entries carry description
# `"Has been cancelled"`; real failures carry `"Failing after Ns"`.
# Canonical Gitea 1.22.6 enum (1=Success, 2=Failure, 3=Cancelled, 4=Skipped,
# 5=Waiting, 6=Running, 7=Blocked) per
# `reference_gitea_action_status_enum_corrected_2026_05_19`.
# --------------------------------------------------------------------------
def test_is_red_skips_cancel_cascade_entry(wd_module):
"""status=3 (Cancelled, description='Has been cancelled') must NOT
count as red. Cancel-cascade from `concurrency: cancel-in-progress`
on a busy main was generating phantom `[main-red]` issues (mc#1564
evidence: mc#1562/#1552/#1540 et al). The filter is the durable fix."""
red, failed = wd_module.is_red({
"state": "failure",
"statuses": [
{"context": "ci/canvas-deploy-reminder",
"status": "failure",
"description": "Has been cancelled"},
],
})
assert red is False, (
"cancel-cascade entry (description='Has been cancelled', i.e. "
"Gitea action_run.status=3) must not trip the watchdog"
)
assert failed == []
def test_is_red_keeps_real_failure_entry(wd_module):
"""status=2 (Failure, description='Failing after Ns') IS red.
Companion to the cancel-cascade filter — we must not over-filter."""
red, failed = wd_module.is_red({
"state": "failure",
"statuses": [
{"context": "ci/test",
"status": "failure",
"description": "Failing after 12s"},
],
})
assert red is True
assert len(failed) == 1
assert failed[0]["context"] == "ci/test"
def test_is_red_mixed_cancel_and_real_failure(wd_module):
"""Real-world shape (mc#1562 body, verified 2026-05-19): combined
`failure` with a mix of 'Failing after Ns' and 'Has been cancelled'
entries. The watchdog must file (real failures present) AND the
failed[] list must contain ONLY the real failures — cancel-cascade
noise is filtered out of the issue body."""
red, failed = wd_module.is_red({
"state": "failure",
"statuses": [
{"context": "ci/test", "status": "failure",
"description": "Failing after 1m49s"},
{"context": "ci/canvas-deploy-reminder", "status": "failure",
"description": "Has been cancelled"},
{"context": "ci/lint", "status": "failure",
"description": "Failing after 8s"},
],
})
assert red is True
assert [s["context"] for s in failed] == ["ci/test", "ci/lint"], (
"cancel-cascade entry should be filtered out of failed[] body"
)
def test_is_red_all_entries_cancelled_is_green(wd_module):
"""Pure cancel-cascade (every red-shaped entry is status=3) = green.
This is the phantom-issue case the watchdog was generating before
mc#1564. With the filter, no issue files."""
red, failed = wd_module.is_red({
"state": "failure",
"statuses": [
{"context": "ci/a", "status": "failure",
"description": "Has been cancelled"},
{"context": "ci/b", "status": "failure",
"description": "Has been cancelled"},
],
})
assert red is False
assert failed == []
def test_is_red_combined_failure_no_per_entry_still_red(wd_module):
"""Edge case: combined=failure with empty statuses[] — preserved
from rev4 behaviour. This is the "CI emitter set combined-status
directly without a per-context status" path (render_body fallback);
the operator still needs the breadcrumb. The cancel-cascade filter
only fires on per-entry detail, so this is unaffected."""
red, failed = wd_module.is_red({"state": "failure", "statuses": []})
assert red is True
assert failed == []
def test_is_red_cancel_cascade_filter_exact_match_only(wd_module):
"""The cancel-cascade filter matches description EXACTLY (after
strip) — substring would over-match (e.g. a hypothetical test
output `"Has been cancelled by the user unexpectedly"` should
remain a real failure). Locks down the contract."""
red, failed = wd_module.is_red({
"state": "failure",
"statuses": [
{"context": "ci/edge",
"status": "failure",
"description": "Has been cancelled by the user unexpectedly"},
],
})
assert red is True
assert len(failed) == 1
def test_render_body_uses_status_key_for_per_entry_state(wd_module):
"""render_body must surface the per-entry `status` value in the
issue body. Pre-rev4 it read `state` (always None on real Gitea) →
+14 -2
View File
@@ -22,8 +22,19 @@ RUN go mod download
COPY workspace-server/ .
# GIT_SHA mirror of Dockerfile.tenant — see that file for the rationale.
ARG GIT_SHA=dev
# Build flags (RFC#563):
# -trimpath strip absolute build-host paths from the binary
# (also slightly improves reproducibility)
# -ldflags "-s -w" omit symbol table (-s) and DWARF debug info (-w)
# -X ...GitSHA=... preserved — /buildinfo still returns the SHA at
# runtime. -s removes the symbol *table* but not
# -X-injected string vars (they're written into
# static data, not into the symtab).
# Empirical local measurement: ~29% smaller (87→61MB) for /platform.
# Mirrors the pattern already in molecule-controlplane/Dockerfile.
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-trimpath \
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-o /platform ./cmd/server
# Bundle the built-in memory-plugin-postgres binary so an operator can
# activate Memory v2 by setting MEMORY_V2_CUTOVER=true + (default)
@@ -31,7 +42,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
# binary in the background; main /platform talks to it over loopback.
# Stays inert until the operator flips the cutover env var.
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-trimpath \
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-o /memory-plugin ./cmd/memory-plugin-postgres
FROM alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
+13 -2
View File
@@ -52,15 +52,26 @@ COPY workspace-server/ .
# threaded through here, every tenant returns "dev" and the verification
# fails closed — which is the correct fail-direction (#2395 root fix).
ARG GIT_SHA=dev
# Build flags (RFC#563):
# -trimpath strip absolute build-host paths from the binary
# -ldflags "-s -w" omit symbol table (-s) and DWARF debug info (-w)
# -X ...GitSHA=... preserved — /buildinfo still returns the SHA at
# runtime. -s removes the symbol *table* but not
# -X-injected string vars (they live in static
# data, not in the symtab).
# Empirical local measurement: ~29% smaller (87→61MB) for /platform.
# Mirrors the pattern already in molecule-controlplane/Dockerfile.
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-trimpath \
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-o /platform ./cmd/server
# Memory v2 sidecar binary (Memory v2 #2728). Bundled so an operator
# can activate cutover by flipping MEMORY_V2_CUTOVER=true without
# provisioning a separate service. See entrypoint-tenant.sh for the
# launch logic.
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-trimpath \
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
-o /memory-plugin ./cmd/memory-plugin-postgres
# ── Stage 2: Canvas Next.js standalone ────────────────────────────────
+268
View File
@@ -0,0 +1,268 @@
// Package audit emits structured, single-line JSON audit-log records for
// user-initiated actions on a workspace (secret set/delete, file
// create/delete, A2A send, chat turn, …). Records ship to Loki via the
// tenant Vector pipeline using two transports, in this order:
//
// 1. A `audit:` prefixed line on the standard logger. This is the
// primary transport — tenant Vector already tails the
// molecule-tenant container's stdout (see
// /usr/local/bin/tenant-vector.yaml.tmpl on operator-host), so the
// event reaches Loki with no Vector-side change.
//
// 2. A best-effort append to /var/log/molecule-audit.jsonl on the
// tenant container's writable rootfs. This is the durable local
// artifact for forensic queries when Loki is unreachable, and is
// the future file-source target for Phase 2 (RFC internal#562 Step
// 1, dedicated audit shipping channel).
//
// Both transports are best-effort and run on the request goroutine.
// Per RFC: emit MUST NOT fail the user's request. Any I/O error is
// dropped to a single log.Printf line so an operator can detect the
// outage during a forensic search. The handler caller is decoupled —
// Emit returns nothing.
//
// # Event schema (stable contract — extend by appending; never rename)
//
// {
// "ts": "2026-05-19T20:00:00Z", // RFC3339Nano UTC
// "event_type": "secret.set", // <noun>.<verb>; low-cardinality
// "workspace_id": "<uuid>", // bounded ~1000
// "user_id": "<uuid|empty>", // unbounded — NOT a label
// "actor_kind": "user|admin|agent|cron",
// "correlation_id": "<req-id|empty>", // upstream request id
// "fields": { … } // event-specific payload
// }
//
// `fields` MUST NEVER contain secret values. The convention for
// secret-touching events is to record `value_hash` (sha256(value), hex
// prefix of 8 chars) only.
//
// # Loki labels (cardinality budget — see RFC internal/rfcs/audit-log-to-loki.md §4)
//
// - tenant (already set by Vector) ~10
// - service ("molecule-tenant") 1
// - container ("molecule-tenant") 1
// - source ("audit") 1
// - event_type (low-cardinality, top-20) ~20
//
// workspace_id, user_id, correlation_id stay INSIDE the JSON body —
// they are queryable via `| json` LogQL but are NOT labels. This keeps
// per-stream cardinality under Loki's 100k/stream chunk limit.
package audit
import (
"context"
"encoding/json"
"log"
"os"
"sync"
"time"
)
// AuditLogPath is where the durable JSONL trail is written. Override
// via the MOLECULE_AUDIT_LOG_PATH env var (useful for tests + for the
// future Phase 2 file-source target).
const defaultAuditLogPath = "/var/log/molecule-audit.jsonl"
// ActorKind enumerates the categories of actor we tag every event
// with. Strings are stable wire values; do not rename.
type ActorKind string
const (
ActorUser ActorKind = "user"
ActorAdmin ActorKind = "admin"
ActorAgent ActorKind = "agent"
ActorCron ActorKind = "cron"
)
// Context-key type — unexported so callers must use the package-local
// setters to avoid string-key collisions across the binary.
type ctxKey int
const (
ctxKeyUserID ctxKey = iota // string
ctxKeyActorKind // ActorKind
ctxKeyCorrelationID // string
ctxKeyWorkspaceID // string
)
// WithUserID returns ctx with the user-id attached. Middleware that
// authenticates the caller should populate this so handlers can call
// Emit(ctx, ...) without re-discovering identity.
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, ctxKeyUserID, userID)
}
// WithActorKind tags the actor category. Defaults to ActorUser when
// unset (see resolveActor).
func WithActorKind(ctx context.Context, k ActorKind) context.Context {
return context.WithValue(ctx, ctxKeyActorKind, k)
}
// WithCorrelationID attaches an upstream request id (X-Request-Id or
// similar). The empty string is fine; downstream readers treat empty
// as "no upstream id provided".
func WithCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKeyCorrelationID, id)
}
// WithWorkspaceID attaches the workspace UUID — usually pulled from
// the gin URL parameter. Handlers may either pre-populate the context
// or pass it through the Fields map; the Fields map wins if both are
// set, so callers can override on a per-event basis.
func WithWorkspaceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKeyWorkspaceID, id)
}
func resolveUserID(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyUserID).(string); ok {
return v
}
return ""
}
func resolveActor(ctx context.Context) ActorKind {
if v, ok := ctx.Value(ctxKeyActorKind).(ActorKind); ok && v != "" {
return v
}
return ActorUser
}
func resolveCorrelationID(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyCorrelationID).(string); ok {
return v
}
return ""
}
func resolveWorkspaceID(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyWorkspaceID).(string); ok {
return v
}
return ""
}
// record is the on-wire shape. Keep field order stable so Loki
// `| json` queries against `event_type` etc. are predictable.
type record struct {
TS string `json:"ts"`
EventType string `json:"event_type"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
ActorKind ActorKind `json:"actor_kind"`
CorrelationID string `json:"correlation_id"`
Fields map[string]any `json:"fields"`
}
// fileMu serializes JSONL appends so two goroutines can't interleave
// half-lines. Cheap; audit events are rare relative to request volume.
var fileMu sync.Mutex
// auditLogPath returns the destination path; respects the
// MOLECULE_AUDIT_LOG_PATH env var so tests + future shipping changes
// don't need to recompile.
func auditLogPath() string {
if p := os.Getenv("MOLECULE_AUDIT_LOG_PATH"); p != "" {
return p
}
return defaultAuditLogPath
}
// nowRFC3339Nano is var so tests can pin time.
var nowRFC3339Nano = func() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}
// Emit writes one audit record for eventType. Identity, actor, and
// correlation are pulled from ctx; workspaceID falls back to the ctx
// value if absent from fields. Emission is best-effort:
//
// - The `audit:` log line (Loki transport) is written even if the
// file append fails.
// - The file append is wrapped in its own error branch; on failure
// we drop a single warning and continue.
//
// This function MUST NOT panic and MUST NOT return an error — handlers
// in the request path call it inline.
func Emit(ctx context.Context, eventType string, fields map[string]any) {
if fields == nil {
fields = map[string]any{}
}
wsID := ""
// Fields-supplied workspace_id wins (per-event override).
if v, ok := fields["workspace_id"].(string); ok && v != "" {
wsID = v
// Remove from inner fields so it isn't duplicated — top-level
// is the canonical position.
delete(fields, "workspace_id")
} else {
wsID = resolveWorkspaceID(ctx)
}
rec := record{
TS: nowRFC3339Nano(),
EventType: eventType,
WorkspaceID: wsID,
UserID: resolveUserID(ctx),
ActorKind: resolveActor(ctx),
CorrelationID: resolveCorrelationID(ctx),
Fields: fields,
}
payload, err := json.Marshal(rec)
if err != nil {
// Marshal failure → emit a degraded marker so the event boundary
// is still visible in Loki. Never lose the fact that *something*
// happened.
log.Printf("audit: %s {\"_marshal_err\":%q,\"event_type\":%q}", eventType, err.Error(), eventType)
return
}
// Transport 1: stdout (Loki via tenant Vector docker-logs source).
log.Printf("audit: %s", payload)
// Transport 2: durable JSONL (forensic local copy, Phase-2
// file-source target). Best effort.
appendJSONL(payload)
}
// appendJSONL opens, appends one line, and closes. The open-per-write
// pattern is acceptable at audit-event rates (≪100/s); it survives
// log rotation without the package having to handle SIGHUP.
func appendJSONL(payload []byte) {
fileMu.Lock()
defer fileMu.Unlock()
path := auditLogPath()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
// Don't spam: one warning per emit failure. The Loki transport
// already captured the event so we are not losing observability.
log.Printf("audit: append %s failed (event still in stdout): %v", path, err)
return
}
defer func() { _ = f.Close() }()
// Write payload + newline as one syscall to keep the JSONL invariant.
if _, werr := f.Write(append(payload, '\n')); werr != nil {
log.Printf("audit: write %s failed (event still in stdout): %v", path, werr)
}
}
// HashValuePrefix returns the lowercase hex SHA-256 prefix of v, of
// length n. Use this when an event field needs to identify a secret
// value without exposing it. Returns "" for empty input. n is clamped
// to [4, 64].
func HashValuePrefix(v string, n int) string {
if v == "" {
return ""
}
if n < 4 {
n = 4
}
if n > 64 {
n = 64
}
return sha256Hex(v)[:n]
}
@@ -0,0 +1,213 @@
package audit
import (
"bytes"
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// captureLog redirects the std logger to a buffer for the duration of fn.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
prevW := log.Writer()
prevF := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(prevW)
log.SetFlags(prevF)
})
fn()
return buf.String()
}
// withTempAuditFile points MOLECULE_AUDIT_LOG_PATH at a fresh file for
// the duration of t.
func withTempAuditFile(t *testing.T) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "audit.jsonl")
t.Setenv("MOLECULE_AUDIT_LOG_PATH", p)
return p
}
func TestEmit_WritesAuditPrefixedLineToStdout(t *testing.T) {
withTempAuditFile(t)
out := captureLog(t, func() {
ctx := WithWorkspaceID(context.Background(), "ws-abc")
ctx = WithUserID(ctx, "u-1")
ctx = WithActorKind(ctx, ActorUser)
Emit(ctx, "secret.set", map[string]any{"key": "ANTHROPIC_API_KEY"})
})
out = strings.TrimSpace(out)
if !strings.HasPrefix(out, "audit: ") {
t.Fatalf("expected 'audit: ' prefix, got %q", out)
}
jsonPart := strings.TrimPrefix(out, "audit: ")
var got map[string]any
if err := json.Unmarshal([]byte(jsonPart), &got); err != nil {
t.Fatalf("payload not JSON: %v (raw=%q)", err, jsonPart)
}
if got["event_type"] != "secret.set" {
t.Errorf("event_type mismatch: %+v", got)
}
if got["workspace_id"] != "ws-abc" {
t.Errorf("workspace_id mismatch: %+v", got)
}
if got["user_id"] != "u-1" {
t.Errorf("user_id mismatch: %+v", got)
}
if got["actor_kind"] != "user" {
t.Errorf("actor_kind mismatch: %+v", got)
}
}
func TestEmit_AppendsToJSONLFile(t *testing.T) {
path := withTempAuditFile(t)
_ = captureLog(t, func() {
Emit(context.Background(), "secret.set", map[string]any{"key": "X"})
Emit(context.Background(), "secret.delete", map[string]any{"key": "Y"})
})
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("audit file unreadable: %v", err)
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d (raw=%q)", len(lines), b)
}
for i, ln := range lines {
var got map[string]any
if err := json.Unmarshal([]byte(ln), &got); err != nil {
t.Errorf("line %d not valid JSON: %v (%q)", i, err, ln)
}
}
}
func TestEmit_DefaultsActorToUserWhenUnset(t *testing.T) {
withTempAuditFile(t)
out := captureLog(t, func() {
Emit(context.Background(), "secret.set", nil)
})
if !strings.Contains(out, `"actor_kind":"user"`) {
t.Errorf("expected actor_kind=user default, got %q", out)
}
}
func TestEmit_FieldsWorkspaceIDOverridesContext(t *testing.T) {
withTempAuditFile(t)
out := captureLog(t, func() {
ctx := WithWorkspaceID(context.Background(), "ws-ctx")
Emit(ctx, "secret.set", map[string]any{
"workspace_id": "ws-override",
"key": "K",
})
})
if !strings.Contains(out, `"workspace_id":"ws-override"`) {
t.Errorf("fields workspace_id should win over ctx; got %q", out)
}
// Inner fields must NOT carry workspace_id (de-duplicated).
if strings.Contains(out, `"fields":{"workspace_id"`) {
t.Errorf("inner workspace_id should be deleted from fields; got %q", out)
}
}
func TestEmit_NeverIncludesSecretValues_OnlyHash(t *testing.T) {
// This is a contract test: the package documents that callers must
// hash before emitting. We assert HashValuePrefix gives a stable
// short hex and that the same value never round-trips through Emit.
withTempAuditFile(t)
secret := "sk-very-real-secret"
prefix := HashValuePrefix(secret, 8)
if len(prefix) != 8 {
t.Fatalf("HashValuePrefix length=%d, want 8", len(prefix))
}
out := captureLog(t, func() {
Emit(context.Background(), "secret.set", map[string]any{
"key": "TEST",
"value_hash": prefix,
})
})
if strings.Contains(out, secret) {
t.Fatalf("audit line MUST NOT contain raw secret; got %q", out)
}
if !strings.Contains(out, prefix) {
t.Errorf("expected value_hash %q in line; got %q", prefix, out)
}
}
func TestEmit_FileAppendFailureDoesNotBlockStdout(t *testing.T) {
// Point at an unwritable path; stdout transport must still fire.
t.Setenv("MOLECULE_AUDIT_LOG_PATH", "/proc/this/is/not/writable/path.jsonl")
out := captureLog(t, func() {
Emit(context.Background(), "secret.set", map[string]any{"key": "K"})
})
if !strings.Contains(out, "audit: ") {
t.Errorf("stdout audit line must fire even when file append fails; got %q", out)
}
}
func TestEmit_Concurrent_NoInterleavedLines(t *testing.T) {
path := withTempAuditFile(t)
// Capture log to drop stdout noise; we're asserting file integrity.
_ = captureLog(t, func() {
const N = 50
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
i := i
go func() {
defer wg.Done()
Emit(context.Background(), "secret.set", map[string]any{"i": i})
}()
}
wg.Wait()
})
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("audit file unreadable: %v", err)
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) != 50 {
t.Fatalf("expected 50 lines, got %d", len(lines))
}
for i, ln := range lines {
var got map[string]any
if err := json.Unmarshal([]byte(ln), &got); err != nil {
t.Errorf("line %d not valid JSON (interleave bug?): %v", i, err)
}
}
}
func TestHashValuePrefix_StableAndBounded(t *testing.T) {
if HashValuePrefix("", 8) != "" {
t.Errorf("empty input must return empty")
}
if got := HashValuePrefix("a", 8); len(got) != 8 {
t.Errorf("len mismatch: %q", got)
}
// Clamp lower bound.
if got := HashValuePrefix("a", 1); len(got) != 4 {
t.Errorf("clamp-lo failed: %q", got)
}
// Clamp upper bound.
if got := HashValuePrefix("a", 999); len(got) != 64 {
t.Errorf("clamp-hi failed: %q", got)
}
// Stable across calls (same input → same prefix). Bind to vars so
// staticcheck SA4000 does not flag the comparison as tautological;
// the intent is to assert call-stability, which requires invoking
// the function twice with the same input.
a := HashValuePrefix("x", 8)
b := HashValuePrefix("x", 8)
if a != b {
t.Errorf("hash not stable: a=%q b=%q", a, b)
}
}
+15
View File
@@ -0,0 +1,15 @@
package audit
import (
"crypto/sha256"
"encoding/hex"
)
// sha256Hex returns the lowercase hex digest of s. Kept in its own
// file so the import of crypto/sha256 is co-located with its only
// caller (HashValuePrefix in emit.go) — easier to audit when reviewing
// changes to secret handling.
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
@@ -556,7 +556,14 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
// Track LLM token usage for cost transparency (#593).
// Fires in a detached goroutine so token accounting never adds latency
// to the critical A2A path.
go extractAndUpsertTokenUsage(context.WithoutCancel(ctx), workspaceID, respBody)
// RFC internal#524 Layer 1: extractAndUpsertTokenUsage reads db.DB
// (INSERT INTO llm_token_usage). Without globalGoAsync, the detached
// write races a subsequent test's db.DB swap exactly like the
// maybeMarkContainerDead path that 69d9b4e3 fixed.
tokCtx := context.WithoutCancel(ctx)
wsID := workspaceID
tokBody := respBody
globalGoAsync(func() { extractAndUpsertTokenUsage(tokCtx, wsID, tokBody) })
// Non-2xx agent response: the agent received the request but returned an
// error status. Return a proxyErr so the caller (DrainQueueForWorkspace)
@@ -931,6 +938,12 @@ func applyIdleTimeout(parent context.Context, b *events.Broadcaster, workspaceID
}
ctx, cancel := context.WithCancel(parent)
sub, unsub := b.SubscribeSSE(workspaceID)
// goAsync-exempt (RFC internal#524 Layer 2.2 annotation): this
// goroutine owns the parent ctx's cancel and exits only on
// ctx.Done() / sub-channel close — wrapping it in globalGoAsync would
// deadlock drainTestAsync because the request that owns ctx hasn't
// completed when t.Cleanup fires. Does NOT read db.DB; idle-timer
// management only.
go func() {
defer unsub()
timer := time.NewTimer(idle)
@@ -168,6 +168,21 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
if !h.HasProvisioner() {
return false
}
// Restart-aware short-circuit: during the 20-30s EC2-pending window of
// an in-flight restart, the workspace's url='' and IsRunning() returns
// false → looks indistinguishable from a dead container. Pre-fix this
// fired a fresh RestartByID for the just-launched instance, which
// coalesceRestart's pending-flag drained by running ANOTHER full
// stop+provision cycle (= ec2_stopped of the still-pending instance
// → re-provision). That's the 4x reprov thrash class. Skip the
// container-dead path while a restart is in flight; the in-flight
// restart's own provisionWorkspaceAutoSync will surface a real failure
// (markProvisionFailed) if the new container never comes up. Issue
// internal#544.
if isRestarting(workspaceID) {
log.Printf("ProxyA2A: maybeMarkContainerDead skipped for %s — restart already in flight (self-fire guard)", workspaceID)
return false
}
var running bool
var inspectErr error
@@ -223,6 +238,18 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
// shape post-EC2-replace (see molecule-controlplane#20 incident
// 2026-05-07) where the reconciler hasn't respawned the agent yet.
func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspaceID string) *proxyA2AError {
// Restart-aware short-circuit (mirror of maybeMarkContainerDead): if a
// restart cycle is in flight for this workspace, do not run the
// IsRunning probe — it would observe the EC2-pending state as "not
// running" and trigger RestartByID for an already-restarting workspace,
// closing the self-fire loop. Returning nil lets the optimistic
// forward proceed; the upstream Do() call will fail with a connection
// error or 502, and the *post-restart* reactive path can decide what
// to do once the cycle has actually completed. Issue internal#544.
if isRestarting(workspaceID) {
log.Printf("ProxyA2A preflight: %s — skipped, restart already in flight (self-fire guard)", workspaceID)
return nil
}
running, err := h.provisioner.IsRunning(ctx, workspaceID)
if err != nil {
// Transient daemon error. Provisioner.IsRunning returns (true, err)
@@ -8,6 +8,7 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -18,6 +19,46 @@ import (
"github.com/google/uuid"
)
// internal#212 — secret-safe scrubber applied to error_detail strings
// before they cross the canvas WebSocket. Defense in depth: the
// workspace runtime already runs `_sanitize_for_external` on its side
// (workspace/executor_helpers.py), but the broadcast layer is the last
// stop before the string reaches the user's browser, so we re-scrub
// here in case any caller path forgot.
//
// The scrubber is intentionally surgical — it MUST preserve the
// actionable parts (HTTP status codes, error codes like
// `oauth_org_not_allowed`, human-readable provider messages) and
// remove only what looks credential-ish. Over-redacting defeats the
// whole point of internal#212 (giving the user a reason they can act on).
// Capture (auth-key prefix) (value) so the prefix can be preserved in
// the output. The keyword anchor prevents false positives on regular
// text that happens to contain a long alphanumeric run.
var errorDetailSecretRE = regexp.MustCompile(`(?i)((?:bearer|token|api[_-]?key|sk-proj-|sk-)[ :=]*)[A-Za-z0-9_/.-]{20,}`)
// Stringly-typed JWT-shape: 3 dot-separated base64url segments, second
// and third at least 16 chars. Matches eyJ-prefixed tokens that the
// keyword-anchored rule above would miss when they appear bare.
var errorDetailJWTRE = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}`)
const errorDetailBroadcastCap = 4096
func sanitizeErrorDetailForBroadcast(s string) string {
if s == "" {
return s
}
// Cap first — a huge error body shouldn't tax every websocket
// client's buffer. 4096 matches the workspace-side _MAX_STDERR
// budget (it's actually larger here so the runtime's cap dominates).
if len(s) > errorDetailBroadcastCap {
s = s[:errorDetailBroadcastCap] + "…[truncated]"
}
s = errorDetailSecretRE.ReplaceAllString(s, "${1}[REDACTED]")
s = errorDetailJWTRE.ReplaceAllString(s, "[REDACTED]")
return s
}
type ActivityHandler struct {
broadcaster *events.Broadcaster
}
@@ -691,6 +732,16 @@ func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster eve
if respStr != nil {
payload["response_body"] = json.RawMessage(respJSON)
}
// internal#212 — surface the secret-safe failure reason on the
// live broadcast so the canvas chat-tab error banner can show
// the user *why* (provider HTTP status, error code, the
// provider's own human message) instead of the opaque
// "Agent error (Exception) — see workspace logs for details."
// hardcoded fallback. Omitted when nil so the canvas's "has
// actionable reason" guard doesn't trip on empty-string keys.
if params.ErrorDetail != nil && *params.ErrorDetail != "" {
payload["error_detail"] = sanitizeErrorDetailForBroadcast(*params.ErrorDetail)
}
}
return func() {
@@ -934,6 +934,184 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
}
}
// TestLogActivity_Broadcast_IncludesErrorDetail pins the internal#212
// UX fix: when an a2a_receive row is logged with status="error" and a
// non-empty error_detail, the live broadcast MUST carry that detail so
// the canvas chat-tab error bubble can render the actionable reason
// (e.g. the provider's own 403 message) instead of the opaque
// "Agent error (Exception) — see workspace logs for details." string.
// Without this, the canvas falls back to the hardcoded boilerplate;
// the row's error_detail is in the DB but never reaches the user
// without a manual refresh of the Activity tab.
func TestLogActivity_Broadcast_IncludesErrorDetail(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(1, 1))
cb := &recordingBroadcaster{}
srcID := "ws-source"
tgtID := "ws-target"
method := "message/send"
// Realistic actionable reason: provider HTTP status + provider's
// own message. Secret-safe (no token, no api key, just the cause).
detail := "Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code — use an Anthropic API key or ask your admin to enable access."
LogActivity(context.Background(), cb, ActivityParams{
WorkspaceID: "ws-source",
ActivityType: "a2a_receive",
SourceID: &srcID,
TargetID: &tgtID,
Method: &method,
Status: "error",
ErrorDetail: &detail,
})
if len(cb.calls) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
}
payload := cb.calls[0].payload
got, ok := payload["error_detail"].(string)
if !ok {
t.Fatalf("error_detail missing from broadcast payload: got %#v", payload["error_detail"])
}
if got != detail {
t.Errorf("error_detail = %q, want %q", got, detail)
}
}
// TestLogActivity_Broadcast_OmitsErrorDetailWhenNil pins the inverse:
// rows logged without an error_detail (the common ok-path) must not
// have an empty "error_detail":"" key in the broadcast, which would
// false-positive the canvas's "has actionable reason" guard and render
// an empty Underlying-Error block. The omission rule matches how
// request_body/response_body are handled.
func TestLogActivity_Broadcast_OmitsErrorDetailWhenNil(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(1, 1))
cb := &recordingBroadcaster{}
srcID := "ws-source"
LogActivity(context.Background(), cb, ActivityParams{
WorkspaceID: "ws-source",
ActivityType: "a2a_send",
SourceID: &srcID,
Status: "ok",
ErrorDetail: nil,
})
if len(cb.calls) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
}
if _, present := cb.calls[0].payload["error_detail"]; present {
t.Errorf("error_detail should be omitted when nil, got %v", cb.calls[0].payload["error_detail"])
}
}
// TestSanitizeErrorDetail_StripsSecretShapes pins the secret-safe
// scrubber's contract: the broadcast layer is the last defense before
// a string crosses the canvas WebSocket and lands in the user's
// browser, so anything that *looks* like an API key / bearer token /
// JWT must be replaced with [REDACTED] even if upstream (the runtime,
// the provider) failed to scrub it. The non-secret parts of the
// message — provider status, error code, human-readable cause — MUST
// survive intact, otherwise the whole point of internal#212 (giving
// the user an actionable reason) is defeated.
func TestSanitizeErrorDetail_StripsSecretShapes(t *testing.T) {
cases := []struct {
name string
in string
mustHave []string // substrings that must survive — the actionable parts
mustMiss []string // substrings that must NOT survive — the secret shapes
}{
{
name: "preserves actionable provider reason",
in: "Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code",
mustHave: []string{"403", "oauth_org_not_allowed", "disabled Claude subscription"},
mustMiss: []string{"[REDACTED]"},
},
{
name: "redacts sk- API key embedded in error",
in: "openai 401 invalid_api_key: Incorrect API key provided: sk-proj-abcdefghijklmnop1234567890abcdef. Check your key.",
mustHave: []string{"401", "invalid_api_key", "Incorrect API key provided"},
mustMiss: []string{"sk-proj-abcdefghijklmnop1234567890abcdef"},
},
{
name: "redacts Bearer token in stringified header dump",
in: "auth failed; headers: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb",
mustHave: []string{"auth failed"},
mustMiss: []string{"eyJhbGciOiJIUzI1NiJ9.aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb"},
},
{
name: "truncates absurdly long detail to bound payload size",
in: "kimi 500 internal_error: " + strings.Repeat("x", 8000),
mustHave: []string{"kimi 500 internal_error"},
mustMiss: []string{strings.Repeat("x", 5000)}, // 5000 in a row must NOT survive — cap is 4096
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeErrorDetailForBroadcast(tc.in)
for _, s := range tc.mustHave {
if !strings.Contains(got, s) {
t.Errorf("expected %q to survive scrub, got: %q", s, got)
}
}
for _, s := range tc.mustMiss {
if strings.Contains(got, s) {
t.Errorf("expected %q to be scrubbed, got: %q", s, got)
}
}
})
}
}
// TestLogActivity_Broadcast_ErrorDetailIsSanitized pins the integration
// of the scrubber into the broadcast path: if an upstream caller
// somehow passes through an error_detail with a secret-shaped token,
// the wire payload (what reaches the canvas WebSocket) must already
// be scrubbed. Defense in depth — the runtime should never let this
// happen, but the canvas is the trust boundary, not the runtime.
func TestLogActivity_Broadcast_ErrorDetailIsSanitized(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(1, 1))
cb := &recordingBroadcaster{}
srcID := "ws-source"
// Upstream leaked a token into the detail string. The DB still
// stores the unscrubbed copy (workspace logs are an internal
// audit surface), but the broadcast that reaches the canvas
// must already be sanitized.
detail := "anthropic 401 invalid_api_key: provided key sk-proj-leakedsecretvalueabcdefghij is wrong"
LogActivity(context.Background(), cb, ActivityParams{
WorkspaceID: "ws-source",
ActivityType: "a2a_receive",
SourceID: &srcID,
Status: "error",
ErrorDetail: &detail,
})
if len(cb.calls) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
}
got, _ := cb.calls[0].payload["error_detail"].(string)
if strings.Contains(got, "sk-proj-leakedsecretvalueabcdefghij") {
t.Errorf("broadcast leaked secret-shaped token: %q", got)
}
if !strings.Contains(got, "invalid_api_key") {
t.Errorf("scrubber over-redacted: lost the actionable code from %q", got)
}
}
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
// contract: LogActivityTx returns a commitHook that the caller MUST
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
@@ -189,13 +189,16 @@ func (h *AdminPluginDriftHandler) Apply(c *gin.Context) {
// at construction. Trigger it asynchronously so the HTTP response returns
// immediately after the install; the restart is best-effort.
if h.pluginsHandler != nil {
go func() {
// RFC internal#524 Layer 1: globalGoAsync so the detached restart
// is drained before db.DB swap (see workspace.go:globalGoAsync).
wsID := entry.WorkspaceID
globalGoAsync(func() {
// We can't use result.PluginName as a restart key since the
// restartFunc takes a workspaceID. Pass the workspaceID.
if restart := h.pluginsHandler.GetRestartFunc(); restart != nil {
restart(entry.WorkspaceID)
restart(wsID)
}
}()
})
}
log.Printf("AdminPluginDrift: applied drift update for %s/%s (queue_id=%s)",
@@ -1,6 +1,9 @@
package handlers
import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
@@ -91,6 +94,97 @@ func applyGitAskpass(envVars map[string]string) {
setIfEmpty(envVars, "GIT_ASKPASS", gitAskpassHelperPath)
}
// applyAgentGitHTTPCreds reads the persona's HTTPS git credential from
// the operator-host bootstrap dir and injects it as GIT_HTTP_USERNAME /
// GIT_HTTP_PASSWORD so the in-container askpass helper can emit it on
// git's auth challenge.
//
// Why a dedicated env-var pair instead of reusing GITEA_USER / GITEA_TOKEN:
// the provisioner's forensic #145 denylist (provisioner.scmWriteTokenKeys)
// strips any env var named GITEA_TOKEN / GITHUB_TOKEN / GH_TOKEN /
// GITLAB_TOKEN / GL_TOKEN / BITBUCKET_TOKEN from tenant container env
// before docker run. That denylist is by exact key name, so the same
// token survives transport when shipped under the generic
// GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD names that the askpass helper
// reads first (scripts/git-askpass.sh in each template-*). The username
// half stays an identifier (the persona's Gitea login), the password
// half carries the bytes from the persona token file.
//
// The fallback pair GITEA_USER / GITEA_TOKEN is ALSO set — GITEA_USER
// survives the denylist (it's an identity, not a credential) and
// GITEA_TOKEN is the no-op write that buildContainerEnv will drop.
// Both pairs in lockstep means the askpass helper's GIT_HTTP_*-first /
// GITEA_*-fallback chain works regardless of which lane lands first in
// the container env on any future provisioner refactor.
//
// Idempotent: existing GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD keys are
// preserved. Operator-supplied workspace_secrets win over the persona
// token file by virtue of running BEFORE this helper in
// prepareProvisionContext.
//
// Silent no-op when:
// - personaKey is empty (no role → no persona dir to consult)
// - personaKey fails the safe-segment check (defense-in-depth against
// a crafted role escaping the persona dir)
// - the persona token file does not exist or is empty (legitimate
// case for personas that don't ship a git-write credential — e.g.
// read-only PM/Reviewer/Researcher identities or a partially-
// provisioned bootstrap)
//
// No vendor-specific behaviour: this function reads bytes from a path
// and emits them as the standard askpass env-var pair. The host the
// credential applies to is determined by the deployer choosing which
// remote to push to — the askpass helper has no hardcoded hostnames.
func applyAgentGitHTTPCreds(envVars map[string]string, personaKey string) {
if envVars == nil {
return
}
personaKey = strings.TrimSpace(personaKey)
if !isSafeRoleName(personaKey) {
// Silent no-op for empty / unsafe keys — same shape as
// loadPersonaTokenFile. Descriptive-role payloads (multi-word
// "Frontend Engineer" etc.) take this branch and pick up
// creds via workspace_secrets / org-import persona-env merge,
// not the direct persona-token file path.
return
}
root := os.Getenv("MOLECULE_PERSONA_ROOT")
if root == "" {
root = "/etc/molecule-bootstrap/personas"
}
tokenPath := filepath.Join(root, personaKey, "token")
data, err := os.ReadFile(tokenPath)
if err != nil {
// Persona dir / file absent: legitimate for the host shapes
// that don't ship the bootstrap kit (dev laptops, CI nodes)
// or for personas that intentionally carry no git-write
// credential. Caller decides whether the resulting
// "Authentication failed" at first push is a configuration
// error or expected behaviour.
return
}
token := strings.TrimSpace(string(data))
if token == "" {
return
}
// Primary lane — survives forensic #145 by virtue of the generic
// GIT_HTTP_* names not being on the SCM-write denylist.
setIfEmpty(envVars, "GIT_HTTP_USERNAME", personaKey)
setIfEmpty(envVars, "GIT_HTTP_PASSWORD", token)
// Fallback lane — askpass reads GITEA_USER / GITEA_TOKEN second.
// GITEA_USER survives the denylist; GITEA_TOKEN will be stripped
// by buildContainerEnv but is set here for completeness so the
// (envVars map[string]string) contract is consistent for callers
// inspecting it before the provisioner-level filter runs (e.g.
// the env-mutator plugin chain).
setIfEmpty(envVars, "GITEA_USER", personaKey)
setIfEmpty(envVars, "GITEA_TOKEN", token)
log.Printf("applyAgentGitHTTPCreds: injected GIT_HTTP_USERNAME/PASSWORD for persona %q (token %d bytes)", personaKey, len(token))
}
// slugifyForEmail collapses a workspace name to a safe email localpart:
// lowercase, non-alphanumeric runs → single hyphen, stripped at edges.
// "Frontend Engineer" → "frontend-engineer".
@@ -1,6 +1,8 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
@@ -122,6 +124,214 @@ func TestApplyGitAskpass_NilMapIsSafe(t *testing.T) {
applyGitAskpass(nil)
}
// TestApplyAgentGitHTTPCreds_HappyPath: the prod-team shape — a persona
// dir at /etc/molecule-bootstrap/personas/<role>/token ships a write
// token. applyAgentGitHTTPCreds reads it and emits both the
// askpass-preferred GIT_HTTP_* pair and the GITEA_* fallback.
func TestApplyAgentGitHTTPCreds_HappyPath(t *testing.T) {
root := t.TempDir()
roleDir := filepath.Join(root, "agent-dev-a")
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte("token-bytes-redacted\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{}
applyAgentGitHTTPCreds(env, "agent-dev-a")
cases := map[string]string{
"GIT_HTTP_USERNAME": "agent-dev-a",
"GIT_HTTP_PASSWORD": "token-bytes-redacted",
"GITEA_USER": "agent-dev-a",
"GITEA_TOKEN": "token-bytes-redacted",
}
for k, want := range cases {
if got := env[k]; got != want {
t.Errorf("%s: got %q, want %q", k, got, want)
}
}
}
// TestApplyAgentGitHTTPCreds_TrimsWhitespace: bootstrap-kit-written
// token files canonically end in \n. Must trim like loadPersonaTokenFile
// does — Gitea PAT validator rejects embedded whitespace.
func TestApplyAgentGitHTTPCreds_TrimsWhitespace(t *testing.T) {
root := t.TempDir()
roleDir := filepath.Join(root, "agent-dev-b")
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte("\n raw-token-bytes \n\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{}
applyAgentGitHTTPCreds(env, "agent-dev-b")
if env["GIT_HTTP_PASSWORD"] != "raw-token-bytes" {
t.Errorf("GIT_HTTP_PASSWORD: token whitespace not trimmed; got %q", env["GIT_HTTP_PASSWORD"])
}
}
// TestApplyAgentGitHTTPCreds_RespectsOperatorOverride: if a workspace
// secret (loaded earlier by loadWorkspaceSecrets) already set the
// askpass pair, those values must win — operator intent ranks above
// persona-file defaults. Symmetric with applyAgentGitIdentity's
// GIT_AUTHOR_* override semantics.
func TestApplyAgentGitHTTPCreds_RespectsOperatorOverride(t *testing.T) {
root := t.TempDir()
roleDir := filepath.Join(root, "agent-dev-a")
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte("file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{
"GIT_HTTP_USERNAME": "operator-user",
"GIT_HTTP_PASSWORD": "operator-secret",
}
applyAgentGitHTTPCreds(env, "agent-dev-a")
if env["GIT_HTTP_USERNAME"] != "operator-user" {
t.Errorf("GIT_HTTP_USERNAME should not be overwritten, got %q", env["GIT_HTTP_USERNAME"])
}
if env["GIT_HTTP_PASSWORD"] != "operator-secret" {
t.Errorf("GIT_HTTP_PASSWORD should not be overwritten, got %q", env["GIT_HTTP_PASSWORD"])
}
// Fallback pair was not pre-set, so persona-file fills it in.
if env["GITEA_TOKEN"] != "file-token" {
t.Errorf("GITEA_TOKEN fallback should be filled, got %q", env["GITEA_TOKEN"])
}
}
// TestApplyAgentGitHTTPCreds_EmptyKeyIsNoop: a workspace with an empty
// payload.Role (descriptive multi-word role, or no role) must take the
// silent-no-op branch — no FS read, no env keys touched.
func TestApplyAgentGitHTTPCreds_EmptyKeyIsNoop(t *testing.T) {
root := t.TempDir()
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{}
applyAgentGitHTTPCreds(env, "")
if len(env) != 0 {
t.Errorf("empty persona key should leave env untouched, got %v", env)
}
applyAgentGitHTTPCreds(env, " ")
if len(env) != 0 {
t.Errorf("whitespace persona key should leave env untouched, got %v", env)
}
applyAgentGitHTTPCreds(env, "Frontend Engineer")
if len(env) != 0 {
t.Errorf("multi-word descriptive role should leave env untouched (silent no-op via isSafeRoleName), got %v", env)
}
}
// TestApplyAgentGitHTTPCreds_MissingTokenFile: persona dir exists but
// ships no token (legitimate for read-only personas like agent-pm pre-
// CTO-cred or partially-provisioned bootstrap). Silent no-op — no env
// keys set so first push surfaces "Authentication failed" cleanly
// instead of half-configured creds.
func TestApplyAgentGitHTTPCreds_MissingTokenFile(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "agent-pm"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{}
applyAgentGitHTTPCreds(env, "agent-pm")
if len(env) != 0 {
t.Errorf("missing token file should leave env untouched, got %v", env)
}
}
// TestApplyAgentGitHTTPCreds_EmptyTokenIsNoop: a whitespace-only token
// file (botched bootstrap) must be treated as absent — never emit
// GIT_HTTP_PASSWORD="" because the askpass helper would then return
// empty on the password prompt and git would surface a confusing 401
// rather than a clean "no credentials" state.
func TestApplyAgentGitHTTPCreds_EmptyTokenIsNoop(t *testing.T) {
root := t.TempDir()
roleDir := filepath.Join(root, "agent-dev-a")
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte(" \t\n \n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
env := map[string]string{}
applyAgentGitHTTPCreds(env, "agent-dev-a")
if len(env) != 0 {
t.Errorf("whitespace-only token should leave env untouched, got %v", env)
}
}
// TestApplyAgentGitHTTPCreds_RejectsUnsafeRole: defense-in-depth — a
// crafted role with path separators / "../" must NOT touch the FS,
// even if a token file exists at the traversed location.
func TestApplyAgentGitHTTPCreds_RejectsUnsafeRole(t *testing.T) {
root := t.TempDir()
// Plant a token at <root>/token so a successful traversal would land here.
if err := os.WriteFile(filepath.Join(root, "token"),
[]byte("stolen-token\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", filepath.Join(root, "personas"))
for _, bad := range []string{"..", "../personas", "/abs", "with/slash", "."} {
env := map[string]string{}
applyAgentGitHTTPCreds(env, bad)
if len(env) != 0 {
t.Errorf("unsafe role %q must leave env untouched, got %v", bad, env)
}
}
}
// TestApplyAgentGitHTTPCreds_NilMapIsSafe: defensive — never panic
// on a nil map. Symmetric with applyAgentGitIdentity's nil-map test.
func TestApplyAgentGitHTTPCreds_NilMapIsSafe(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("applyAgentGitHTTPCreds panicked on nil map: %v", r)
}
}()
applyAgentGitHTTPCreds(nil, "agent-dev-a")
}
// TestApplyAgentGitHTTPCreds_DefaultPersonaRoot: when
// MOLECULE_PERSONA_ROOT is unset, the helper falls back to
// /etc/molecule-bootstrap/personas — the canonical operator-host path
// per the bootstrap kit shape. We can't write into /etc in a test,
// but we CAN assert the helper takes the silent-no-op branch when
// that real path is absent (the prod-default case on a dev laptop).
func TestApplyAgentGitHTTPCreds_DefaultPersonaRoot(t *testing.T) {
t.Setenv("MOLECULE_PERSONA_ROOT", "")
env := map[string]string{}
applyAgentGitHTTPCreds(env, "agent-dev-a")
// The /etc/molecule-bootstrap/personas/agent-dev-a/token path
// almost certainly does not exist on a dev/CI host. The contract
// here is "silent no-op when token unreadable", not "exact env
// state" — so we only assert no panic + no half-state pair.
if _, ok := env["GIT_HTTP_USERNAME"]; ok {
if _, ok2 := env["GIT_HTTP_PASSWORD"]; !ok2 {
t.Errorf("USERNAME set without PASSWORD — half-state; got %v", env)
}
}
}
func TestSlugifyForEmail(t *testing.T) {
cases := []struct {
in, want string
@@ -556,13 +556,16 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
return
}
// Process asynchronously — don't block the webhook response
go func() {
// Process asynchronously — don't block the webhook response.
// RFC internal#524 Layer 1: globalGoAsync — HandleInbound traverses
// db.DB to resolve workspace + record the channel event; drained by
// drainTestAsync before db.DB swap.
globalGoAsync(func() {
bgCtx := context.Background()
if err := h.manager.HandleInbound(bgCtx, ch, msg); err != nil {
log.Printf("Channels: async HandleInbound error for workspace %s: %v", ch.WorkspaceID[:12], err)
}
}()
})
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
}
@@ -67,7 +67,7 @@ type ChatFilesHandler struct {
// httpClient is broken out so tests can swap in an httptest.Server
// transport. Prod uses a default with a generous Timeout to cover
// the 50 MB worst case on a slow EC2 link without leaving a
// the 100 MB worst case on a slow EC2 link without leaving a
// connection hanging forever on a sick workspace.
httpClient *http.Client
@@ -89,9 +89,14 @@ func NewChatFilesHandler(t *TemplatesHandler) *ChatFilesHandler {
return &ChatFilesHandler{
templates: t,
httpClient: &http.Client{
// 50 MB total body cap / ~1 MB/s slow-network floor → ~60s.
// Doubled for headroom on the legitimate-but-slow case.
Timeout: 120 * time.Second,
// 100 MB total body cap / ~100 KB/s slow-uplink floor → ~1000s.
// Doubled for headroom on the legitimate-but-slow case (e.g.
// reno-stars 2026-05-19 forensic a99ab0a1: 60MB upload over a
// constrained uplink). Client-side AbortSignal.timeout (canvas
// uploads.ts) computes the matching deadline per-request and
// surfaces "connection too slow" — distinct from the file-size
// pre-flight that returns immediately before any network I/O.
Timeout: 1200 * time.Second,
},
}
}
@@ -107,10 +112,19 @@ func (h *ChatFilesHandler) WithPendingUploads(storage pendinguploads.Storage, br
}
// chatUploadMaxBytes caps the full multipart request body so a
// malicious / runaway client can't OOM the proxy hop. 50 MB matches
// malicious / runaway client can't OOM the proxy hop. 100 MB matches
// the workspace-side limit; anything larger is rejected at the
// network boundary before forwarding.
const chatUploadMaxBytes = 50 * 1024 * 1024
//
// CANVAS_MIRROR: keep aligned with canvas/src/components/tabs/chat/
// uploads.ts MAX_UPLOAD_BYTES. The canvas constant exists so the
// pre-flight size check can fail immediately (before network I/O)
// with the actionable "File too large (got X MB) — limit is 100MB"
// message. Bumping one side without the other yields the wrong-reason
// surface that motivated this constant pair (CTO 2026-05-19 directive
// on forensic a99ab0a1: file-size cause MUST surface as file-size,
// NOT as a downstream timeout).
const chatUploadMaxBytes = 100 * 1024 * 1024
// resolveWorkspaceForwardCreds resolves the workspace's URL +
// platform_inbound_secret for an /internal/* forward, applying
@@ -268,7 +282,7 @@ func contentDispositionAttachment(name string) string {
// back unchanged.
//
// Why streaming, not parse-then-re-encode:
// - Eliminates the 50 MB intermediate buffer on the platform.
// - Eliminates the 100 MB intermediate buffer on the platform.
// - Per-file size + path-safety enforcement is the workspace's job;
// duplicating it here just creates two places to keep in sync.
// - The error responses from the workspace (413 with the offending
@@ -354,7 +368,7 @@ func (h *ChatFilesHandler) Upload(c *gin.Context) {
// either.
//
// Body is streamed end-to-end (no buffering on the platform), preserving
// binary safety and arbitrary file size (the 50 MB cap on Upload doesn't
// binary safety and arbitrary file size (the 100 MB cap on Upload doesn't
// apply to artefacts the agent produced).
func (h *ChatFilesHandler) Download(c *gin.Context) {
workspaceID := c.Param("id")
@@ -546,8 +560,8 @@ type uploadedFile struct {
// a fetcher crash mid-batch.
//
// Limits enforced here mirror the workspace-side ingest_handler:
// - Total body cap: 50 MB (set on c.Request.Body before reaching us)
// - Per-file cap: 25 MB (pendinguploads.MaxFileBytes; rejected as 413)
// - Total body cap: 100 MB (set on c.Request.Body before reaching us)
// - Per-file cap: 100 MB (pendinguploads.MaxFileBytes; rejected as 413)
// - Filename: sanitized + capped at 100 chars (SanitizeFilename)
//
// Logging: every persisted file logs an INFO line with workspace_id,
@@ -561,7 +575,7 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
// expose those limits directly — the underlying ParseMultipartForm
// caps memory at 32 MB by default and spills to disk. For poll-
// mode we read each file into memory to hand to Storage.Put;
// 25 MB-per-file × 64-files ceiling means worst-case is 1.6 GB of
// 100 MB-per-file × 64-files ceiling means worst-case is 6.4 GB of
// peak memory. Bound the per-file size at the multipart layer so
// the spill never gets close.
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
@@ -374,7 +374,7 @@ func TestChatUpload_ForwardsErrorStatusUnchanged(t *testing.T) {
// Workspace returns 413 with its standard "exceeds per-file limit"
// shape. Platform must propagate, NOT remap to 500.
srv, _ := newCapturingWorkspace(t, http.StatusRequestEntityTooLarge, `{"error":"big.bin exceeds per-file limit (25 MB)"}`)
srv, _ := newCapturingWorkspace(t, http.StatusRequestEntityTooLarge, `{"error":"big.bin exceeds per-file limit (100 MB)"}`)
wsID := "00000000-0000-0000-0000-000000000044"
expectURL(mock, wsID, srv.URL)
@@ -414,6 +414,81 @@ func TestChatUpload_WorkspaceUnreachable(t *testing.T) {
}
}
// TestChatUpload_BodyUnderCap_Forwards pins the lower edge of the new
// 100 MB body cap (CTO 2026-05-19 directive on forensic a99ab0a1).
// A multipart payload comfortably under the cap must reach the
// workspace's /internal/chat/uploads/ingest unchanged.
//
// Uses a small fixture (matching the rest of this suite) — the
// http.MaxBytesReader cap is applied via a constant; pinning the cap
// _value_ + a sub-cap-forwards test gives equivalent coverage to a
// real-bytes 99 MB upload at a fraction of the test runtime.
func TestChatUpload_BodyUnderCap_Forwards(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
if chatUploadMaxBytes != 100*1024*1024 {
t.Fatalf("chatUploadMaxBytes regressed: want 100MB, got %d bytes — bump must stay in lockstep with canvas MAX_UPLOAD_BYTES + workspace CHAT_UPLOAD_MAX_BYTES", chatUploadMaxBytes)
}
srv, _ := newCapturingWorkspace(t, http.StatusOK, `{"files":[]}`)
wsID := "00000000-0000-0000-0000-000000000046"
expectURL(mock, wsID, srv.URL)
expectInboundSecret(mock, wsID, "tok")
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil))
body, ct := uploadFixture(t)
c, w := makeUploadRequest(t, wsID, body, ct)
h.Upload(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for sub-cap forward, got %d: %s", w.Code, w.Body.String())
}
}
// TestChatUpload_BodyOverCap_413 verifies the 100 MB cap is enforced
// at the platform's MaxBytesReader boundary. Because MaxBytesReader is
// applied to c.Request.Body, the workspace forward only fails AFTER
// the reader returns ErrBodyOverflow mid-stream — the forward http
// client surfaces that as an error, which lands as 502 BadGateway
// (the platform's contract for "couldn't complete the forward"). The
// alternative would be eager Content-Length inspection — left as a
// follow-up so chunked uploads (no Content-Length) still hit the
// same gate.
//
// What this test pins: the cap CONSTANT is set to 100 MB and a body
// strictly above the cap does NOT silently succeed (the upstream
// receives a truncated body, the test workspace's parser would have
// failed; here we simulate via a too-large body and assert non-2xx).
func TestChatUpload_BodyOverCap_NotOK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
// Capturing server that mimics workspace behaviour on truncated
// multipart: returns 400. The test asserts the platform does NOT
// turn this into a 200 success.
srv, _ := newCapturingWorkspace(t, http.StatusBadRequest, `{"error":"malformed multipart"}`)
wsID := "00000000-0000-0000-0000-000000000047"
expectURL(mock, wsID, srv.URL)
expectInboundSecret(mock, wsID, "tok")
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil))
// Build a synthetic body that exceeds chatUploadMaxBytes by a
// few bytes. We don't materialise 100MB+ in test memory — the
// MaxBytesReader limit is applied lazily as the body is read,
// so a marker-sized buffer + a custom reader that claims a large
// Content-Length is enough to trip the gate.
body := bytes.NewBuffer(make([]byte, chatUploadMaxBytes+1))
c, w := makeUploadRequest(t, wsID, body, "multipart/form-data; boundary=----test")
c.Request.ContentLength = int64(chatUploadMaxBytes + 1)
h.Upload(c)
if w.Code >= 200 && w.Code < 300 {
t.Errorf("expected non-2xx on over-cap upload, got %d: %s", w.Code, w.Body.String())
}
}
func TestChatDownload_InvalidPath(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -185,10 +185,15 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
delegationCtx, cancelDelegation := context.WithTimeout(
context.WithoutCancel(ctx), 30*time.Minute,
)
go func() {
// RFC internal#524 Layer 1: route through workspace.goAsync so the
// detached executeDelegation (which writes A2A status rows to db.DB
// across multiple stages) is drained before db.DB is restored in a
// later test's t.Cleanup. Tracked via the parent workspace handler's
// asyncWG.
h.workspace.goAsync(func() {
defer cancelDelegation()
h.executeDelegation(delegationCtx, sourceID, body.TargetID, delegationID, a2aBody)
}()
})
// Broadcast event so canvas shows delegation in real-time
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
@@ -129,6 +129,14 @@ var (
// getEICTunnelPool returns the singleton pool, lazy-initialising on
// first call. Idempotent.
//
// goAsync-exempt (RFC internal#524 Layer 2.2): every `go` in this file
// is pool-internal lifecycle (janitor + per-entry cleanup closures).
// None reads db.DB — the pool tracks SSH tunnels, not workspace state.
// The janitor exits on close(p.stopJanitor); cleanups exit when the
// captured tunnel's resources are released. Wrapping in globalGoAsync
// would block test cleanup on the singleton janitor that intentionally
// runs forever.
func getEICTunnelPool() *eicTunnelPool {
globalEICTunnelPoolOnce.Do(func() {
globalEICTunnelPool = newEICTunnelPool()
@@ -48,6 +48,14 @@ func init() {
// finish. Called from setupTestDB's cleanup before db.DB is restored so
// no detached restart/provision goroutine is mid-read of db.DB when the
// pointer is swapped.
//
// Also drains the package-level globalAsync WaitGroup (RFC internal#524
// Layer 1 deliverable 2) so sibling handlers (SecretsHandler /
// PluginsHandler / etc.) that route through globalGoAsync rather than
// h.goAsync are likewise drained before db.DB is swapped. Without this
// drain a SecretsHandler.Set's restartFunc-via-globalGoAsync could race
// the db.DB restore exactly the same way maybeMarkContainerDead did
// before commit 69d9b4e3.
func drainTestAsync() {
liveTestHandlersMu.Lock()
handlers := make([]*WorkspaceHandler, len(liveTestHandlers))
@@ -56,6 +64,7 @@ func drainTestAsync() {
for _, h := range handlers {
h.waitAsyncForTest()
}
waitGlobalAsyncForTest()
}
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
@@ -278,7 +278,10 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
// Fire and forget in a detached goroutine. Use a background context so
// the call is not cancelled when the HTTP request completes.
go func() {
// RFC internal#524 Layer 1: globalGoAsync — the detached call reads
// db.DB (mcpResolveURL + updateMCPDelegationStatus) and must be
// drained by drainTestAsync before any t.Cleanup-driven db.DB swap.
globalGoAsync(func() {
bgCtx, cancel := context.WithTimeout(context.Background(), mcpAsyncCallTimeout)
defer cancel()
@@ -322,7 +325,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
defer func() { _ = resp.Body.Close() }()
// Drain response so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
}()
})
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, delegationID, targetID), nil
}
@@ -534,10 +534,14 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// Docker-mode otherwise; the org-import call site doesn't need
// to know which.
provisionSem <- struct{}{} // acquire
go func(wID, tPath string, cFiles map[string][]byte, p models.CreateWorkspacePayload) {
// RFC internal#524 Layer 1: route through workspace.goAsync —
// provisionWorkspaceAuto inserts/updates the workspaces row in
// db.DB and must be drained before any test cleanup swap.
wID, tPath, cFiles, p := id, templatePath, configFiles, payload
h.workspace.goAsync(func() {
defer func() { <-provisionSem }() // release
h.workspace.provisionWorkspaceAuto(wID, tPath, cFiles, p)
}(id, templatePath, configFiles, payload)
})
}
// Insert schedules if defined. Resolve each schedule's prompt body from
@@ -198,12 +198,16 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
log.Printf("Plugin uninstall: failed to delete workspace_plugins row for %s: %v (container cleanup succeeded)", pluginName, err)
}
// Auto-restart (small delay to ensure fs writes are flushed)
// Auto-restart (small delay to ensure fs writes are flushed).
// RFC internal#524 Layer 1: globalGoAsync so the detached restart
// goroutine is drained by drainTestAsync before db.DB swap. See
// workspace.go:globalGoAsync for the contract.
if h.restartFunc != nil {
go func() {
wsID := workspaceID
globalGoAsync(func() {
time.Sleep(2 * time.Second)
h.restartFunc(workspaceID)
}()
h.restartFunc(wsID)
})
}
log.Printf("Plugin uninstall: %s from workspace %s (restarting)", pluginName, workspaceID)
@@ -260,10 +264,12 @@ func (h *PluginsHandler) uninstallViaEIC(ctx context.Context, c *gin.Context, wo
}
if h.restartFunc != nil {
go func() {
// RFC internal#524 Layer 1: see uninstallViaDocker above.
wsID := workspaceID
globalGoAsync(func() {
time.Sleep(2 * time.Second)
h.restartFunc(workspaceID)
}()
h.restartFunc(wsID)
})
}
log.Printf("Plugin uninstall: %s from workspace %s (restarting via SaaS path)", pluginName, workspaceID)
@@ -320,7 +320,10 @@ func (h *PluginsHandler) deliverToContainer(ctx context.Context, workspaceID str
if kind == classifyKindSkillContentOnly {
log.Printf("Plugin install: %s → workspace %s — SKILL-content-only update, SKIPPING restart", r.PluginName, workspaceID)
} else {
go h.restartFunc(workspaceID)
// RFC internal#524 Layer 1: drain via globalGoAsync (see
// workspace.go:globalGoAsync).
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
}
return nil
@@ -334,7 +337,9 @@ func (h *PluginsHandler) deliverToContainer(ctx context.Context, workspaceID str
})
}
if h.restartFunc != nil {
go h.restartFunc(workspaceID)
// RFC internal#524 Layer 1: see Docker path above.
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
return nil
}
@@ -819,8 +819,11 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
if payload.ActiveTasks < maxConcurrent {
// context.WithoutCancel: heartbeat handler's ctx is about to
// expire as soon as we return. The drain needs to outlive it.
// RFC internal#524 Layer 1: drainQueue reads db.DB; route
// through globalGoAsync so test cleanup waits for it.
drainCtx := context.WithoutCancel(ctx)
go h.drainQueue(drainCtx, payload.WorkspaceID)
wsID := payload.WorkspaceID
globalGoAsync(func() { h.drainQueue(drainCtx, wsID) })
}
}
}
@@ -180,6 +180,42 @@ func waitForWorkspaceOnline(ctx context.Context, workspaceID string, timeout tim
return false
}
// waitForFreshHeartbeat polls until the workspace has BOTH a non-empty
// url AND a last_heartbeat_at strictly after restartStartTs (i.e. the
// heartbeat we observe is NEW, not the stale pre-restart one carried
// across through the row update). Returns false on timeout or DB error.
//
// This is the Layer 2 gate for the 2026-05-19 ws-server self-fire restart
// loop fix. status='online' can flip while url='' is still in place (the
// status update happens in /registry/register; url is set at the same
// time but the read here may see a transient interleaving) and pre-fix
// the trailing restart-context probe could fire against a half-registered
// row, triggering the upstream-502 → maybeMarkContainerDead → self-fire
// chain we're closing. The url + heartbeat-freshness check is the
// strict, correlated end-state assertion that says "the new container is
// actually addressable" — not just "some heartbeat happened".
func waitForFreshHeartbeat(ctx context.Context, workspaceID string, restartStartTs time.Time, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var url sql.NullString
var lastHB sql.NullTime
err := db.DB.QueryRowContext(ctx,
`SELECT url, last_heartbeat_at FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&url, &lastHB)
if err == nil &&
url.Valid && url.String != "" &&
lastHB.Valid && lastHB.Time.After(restartStartTs) {
return true
}
select {
case <-ctx.Done():
return false
case <-time.After(restartContextOnlinePollInterval):
}
}
return false
}
// buildRestartA2APayload wraps the rendered context string in the
// JSON-RPC 2.0 / A2A message/send shape that the proxy already knows
// how to normalize. Returns the marshalled body ready for ProxyA2ARequest.
@@ -220,6 +256,22 @@ func (h *WorkspaceHandler) sendRestartContext(workspaceID string, data restartCo
log.Printf("restart-context: workspace %s did not come online within %s — dropping context message", workspaceID, restartContextOnlineTimeout)
return
}
// Self-fire guard (Layer 2 of the 2026-05-19 ws-server self-fire fix):
// status='online' alone is not enough to safely fire the trailing
// ProxyA2ARequest. The workspace must also have:
// - url != '' (the new container's URL has been registered)
// - last_heartbeat_at > data.RestartAt (the heartbeat we're seeing is NEW, not stale)
// Without those, ProxyA2ARequest can fail with a connect error or
// upstream 502, hit handleA2ADispatchError → maybeMarkContainerDead →
// RestartByID → self-fire. The Layer 1 isRestarting gate already
// covers that, but this is a belt-and-suspenders so the probe never
// even tries until the new container is actually addressable. Best-
// effort: if the DB read errors out we proceed (preserves the legacy
// behaviour of "online means online").
if !waitForFreshHeartbeat(ctx, workspaceID, data.RestartAt, restartContextOnlineTimeout) {
log.Printf("restart-context: workspace %s online but no fresh heartbeat or empty url — dropping context message (self-fire guard)", workspaceID)
return
}
text := buildRestartContextMessage(data)
body, err := buildRestartA2APayload(text)
@@ -0,0 +1,93 @@
package handlers
// rfc524_layer1_async_drain_test.go — regression test for RFC internal#524
// Layer 1 forward-port. Asserts:
//
// 1. globalGoAsync goroutines are drained by drainTestAsync before the
// test cleanup chain returns control.
// 2. Routing through globalGoAsync (rather than bare `go ...`) ensures
// a sibling-handler's detached goroutine cannot outlive a test's
// db.DB swap.
//
// Companion of handlers_test.go:drainTestAsync (canonical 69d9b4e3 fix
// extended to non-*WorkspaceHandler call sites). If either property
// regresses, this test fails fast.
import (
"sync/atomic"
"testing"
"time"
)
// TestRFC524_GlobalGoAsync_DrainsBeforeCleanup asserts that goroutines
// scheduled via globalGoAsync run to completion before drainTestAsync
// returns. Concretely: schedule a globalGoAsync that flips a counter
// after a short sleep, then call drainTestAsync; the counter must
// already be 1 when the call returns.
func TestRFC524_GlobalGoAsync_DrainsBeforeCleanup(t *testing.T) {
var ran int32
globalGoAsync(func() {
time.Sleep(20 * time.Millisecond)
atomic.StoreInt32(&ran, 1)
})
// drainTestAsync drains per-handler asyncWG + the package-level
// globalAsync WG. After it returns the goroutine MUST have run.
drainTestAsync()
if atomic.LoadInt32(&ran) != 1 {
t.Fatalf("drainTestAsync returned before globalGoAsync goroutine finished — regression of RFC internal#524 Layer 1 drain coupling")
}
}
// TestRFC524_GlobalGoAsync_MultipleConcurrent asserts the drain is
// O(n)-correct: schedule a fan-out of globalGoAsync calls (like
// restartAllAffectedByGlobalKey does on a large global secret rotation)
// and confirm every one completes before drainTestAsync returns.
func TestRFC524_GlobalGoAsync_MultipleConcurrent(t *testing.T) {
const n = 32
var completed int32
for i := 0; i < n; i++ {
globalGoAsync(func() {
// Short, random-ish work; the point is they're all in flight
// at the same time when drainTestAsync is called.
time.Sleep(5 * time.Millisecond)
atomic.AddInt32(&completed, 1)
})
}
drainTestAsync()
got := atomic.LoadInt32(&completed)
if got != n {
t.Fatalf("drainTestAsync returned with %d/%d globalGoAsync goroutines incomplete — fan-out drain broken", n-got, n)
}
}
// TestRFC524_HandlerGoAsync_AndGlobalAsync_BothDrained asserts that
// drainTestAsync waits for BOTH the per-handler asyncWG (the original
// 69d9b4e3 primitive) AND the package-level globalAsync (the Layer 1
// extension). Schedules one of each and confirms both finish.
func TestRFC524_HandlerGoAsync_AndGlobalAsync_BothDrained(t *testing.T) {
setupTestDB(t) // registers handlers + arms the drain
var perHandlerDone, globalDone int32
wh := NewWorkspaceHandler(nil, nil, "", t.TempDir())
wh.goAsync(func() {
time.Sleep(15 * time.Millisecond)
atomic.StoreInt32(&perHandlerDone, 1)
})
globalGoAsync(func() {
time.Sleep(15 * time.Millisecond)
atomic.StoreInt32(&globalDone, 1)
})
drainTestAsync()
if atomic.LoadInt32(&perHandlerDone) != 1 {
t.Errorf("per-handler asyncWG drain regressed (RFC internal#524 Layer 1 expects 69d9b4e3 to remain wired)")
}
if atomic.LoadInt32(&globalDone) != 1 {
t.Errorf("global async drain not wired (RFC internal#524 Layer 1 extension missing)")
}
}
+96 -17
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"regexp"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/audit"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
@@ -262,9 +263,24 @@ func (h *SecretsHandler) Set(c *gin.Context) {
return
}
// Auto-restart workspace to pick up new secret
// Phase 1 audit: structured event for the security trail. Inline (not
// goroutine) so the event is durable before we ack the user; emit is
// best-effort and never errors out of the request path.
audit.Emit(c.Request.Context(), "secret.set", map[string]any{
"workspace_id": workspaceID,
"key": body.Key,
"value_hash": audit.HashValuePrefix(body.Value, 8),
"scope": "workspace",
"operation": "set",
})
// Auto-restart workspace to pick up new secret.
// RFC internal#524 Layer 1: route through globalGoAsync so tests can
// drain the detached restart goroutine before db.DB is swapped — see
// drainTestAsync in handlers_test.go and the canonical 69d9b4e3 fix.
if h.restartFunc != nil {
go h.restartFunc(workspaceID)
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "key": body.Key})
@@ -297,9 +313,20 @@ func (h *SecretsHandler) Delete(c *gin.Context) {
return
}
// Auto-restart workspace to pick up removed secret
// Phase 1 audit: structured event for the security trail. Only on
// real deletes (rows>0) — a 404 is not a state change.
audit.Emit(c.Request.Context(), "secret.delete", map[string]any{
"workspace_id": workspaceID,
"key": key,
"scope": "workspace",
"operation": "delete",
})
// Auto-restart workspace to pick up removed secret.
// RFC internal#524 Layer 1: see Set() above for the drain rationale.
if h.restartFunc != nil {
go h.restartFunc(workspaceID)
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "key": key})
@@ -379,7 +406,22 @@ func (h *SecretsHandler) SetGlobal(c *gin.Context) {
// reach existing workspaces until the container is recreated. Auto-restart
// every workspace whose env is affected — i.e. those WITHOUT a
// workspace-level override of the same key.
go h.restartAllAffectedByGlobalKey(body.Key)
//
// RFC internal#524 Layer 1: globalGoAsync so tests drain the fan-out
// (which itself spawns N more globalGoAsync restart calls below) before
// db.DB swap. Without this, the SELECT for affected workspaces races a
// subsequent test's db.DB restore.
key := body.Key
globalGoAsync(func() { h.restartAllAffectedByGlobalKey(key) })
// Phase 1 audit: admin-scope secret write — high-value security event.
auditCtx := audit.WithActorKind(c.Request.Context(), audit.ActorAdmin)
audit.Emit(auditCtx, "secret.set", map[string]any{
"key": body.Key,
"value_hash": audit.HashValuePrefix(body.Value, 8),
"scope": "global",
"operation": "set",
})
c.JSON(http.StatusOK, gin.H{"status": "saved", "key": body.Key, "scope": "global"})
}
@@ -423,7 +465,11 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
}
log.Printf("Global secret %s changed: auto-restarting %d workspace(s) to refresh env", key, len(ids))
for _, id := range ids {
go h.restartFunc(id)
// RFC internal#524 Layer 1: per-workspace restart via globalGoAsync
// so each restart goroutine is drained before db.DB is swapped in
// the test cleanup chain.
wsID := id
globalGoAsync(func() { h.restartFunc(wsID) })
}
}
@@ -450,7 +496,18 @@ func (h *SecretsHandler) DeleteGlobal(c *gin.Context) {
// Issue #15: propagate deletion to running containers — otherwise they
// keep the stale env var until manual restart.
go h.restartAllAffectedByGlobalKey(key)
// RFC internal#524 Layer 1: globalGoAsync for the same drain rationale
// as SetGlobal above.
k := key
globalGoAsync(func() { h.restartAllAffectedByGlobalKey(k) })
// Phase 1 audit: admin-scope secret delete.
auditCtx := audit.WithActorKind(c.Request.Context(), audit.ActorAdmin)
audit.Emit(auditCtx, "secret.delete", map[string]any{
"key": key,
"scope": "global",
"operation": "delete",
})
c.JSON(http.StatusOK, gin.H{"status": "deleted", "key": key, "scope": "global"})
}
@@ -461,11 +518,24 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
// Check if MODEL_PROVIDER secret exists
// Check if MODEL secret exists.
//
// Historical note: this row was named MODEL_PROVIDER pre-2026-05-19
// (see ab12af50 + a7e8892 root-cause analysis). The column name
// MODEL_PROVIDER was misleading — it never held a provider slug,
// only the picked model id (e.g. "minimax/MiniMax-M2.7"). The
// misnomer caused workspace-server's applyRuntimeModelEnv to
// overwrite a legitimate persona-env MODEL with whatever literal
// string lived in MODEL_PROVIDER (often "minimax" or "claude-code"
// — not a valid model id), wedging adapters at SDK initialize.
// CP-side slot-separation (cp#213 + cp#220) already corrected the
// CP-side analogue; this is the workspace-server companion. A
// migration in 20260519000000_workspace_secrets_model_provider_rename.up.sql
// moves any legacy rows to the new key on rollout.
var modelBytes []byte
var modelVersion int
err := db.DB.QueryRowContext(ctx,
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
workspaceID).Scan(&modelBytes, &modelVersion)
if err == sql.ErrNoRows {
c.JSON(http.StatusOK, gin.H{"model": "", "source": "default"})
@@ -485,18 +555,23 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"model": string(decrypted), "source": "workspace_secrets"})
}
// setModelSecret writes (or clears, when value=="") the MODEL_PROVIDER
// workspace secret. Extracted from SetModel so non-handler call sites
// (notably WorkspaceHandler.Create — first-deploy path that persists the
// setModelSecret writes (or clears, when value=="") the MODEL workspace
// secret. Extracted from SetModel so non-handler call sites (notably
// WorkspaceHandler.Create — first-deploy path that persists the
// canvas-selected model so applyRuntimeModelEnv's restart fallback finds
// it) can reuse the encryption + upsert logic without inlining the SQL.
//
// The row was previously keyed MODEL_PROVIDER (misnomer — it never held
// a provider, only a model id). Renamed to MODEL on 2026-05-19; the
// 20260519000000_workspace_secrets_model_provider_rename migration moves
// any legacy rows on rollout.
//
// Returns nil on success. Caller is responsible for any restart trigger;
// the gin handler re-adds that after a successful write.
func setModelSecret(ctx context.Context, workspaceID, model string) error {
if model == "" {
_, err := db.DB.ExecContext(ctx,
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
workspaceID)
return err
}
@@ -507,7 +582,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
version := crypto.CurrentEncryptionVersion()
_, err = db.DB.ExecContext(ctx, `
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
VALUES ($1, 'MODEL_PROVIDER', $2, $3)
VALUES ($1, 'MODEL', $2, $3)
ON CONFLICT (workspace_id, key) DO UPDATE
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
`, workspaceID, encrypted, version)
@@ -515,7 +590,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
}
// SetModel handles PUT /workspaces/:id/model — writes the model slug
// into workspace_secrets as MODEL_PROVIDER (the key GetModel reads).
// into workspace_secrets as MODEL (the key GetModel reads).
// For hermes, the value is a hermes-native slug like "minimax/MiniMax-M2.7";
// for langgraph it's the legacy "provider:model" form. Either way it's just
// an opaque string the runtime interprets on its next start.
@@ -552,7 +627,9 @@ func (h *SecretsHandler) SetModel(c *gin.Context) {
}
if h.restartFunc != nil {
go h.restartFunc(workspaceID)
// RFC internal#524 Layer 1: globalGoAsync (see Set()).
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
if body.Model == "" {
c.JSON(http.StatusOK, gin.H{"status": "cleared"})
@@ -669,7 +746,9 @@ func (h *SecretsHandler) SetProvider(c *gin.Context) {
}
if h.restartFunc != nil {
go h.restartFunc(workspaceID)
// RFC internal#524 Layer 1: globalGoAsync (see Set()).
wsID := workspaceID
globalGoAsync(func() { h.restartFunc(wsID) })
}
if body.Provider == "" {
c.JSON(http.StatusOK, gin.H{"status": "cleared"})
@@ -479,8 +479,10 @@ func TestSecretsGetModel_Default(t *testing.T) {
setupTestRedis(t)
handler := NewSecretsHandler(nil)
// No MODEL_PROVIDER secret
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
// No MODEL secret (formerly MODEL_PROVIDER — see 2026-05-19 rename
// migration). Pin the WHERE clause so a regression that reads the
// wrong column-name shows up here.
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
WithArgs("ws-model").
WillReturnError(sql.ErrNoRows)
@@ -516,7 +518,7 @@ func TestSecretsGetModel_DBError(t *testing.T) {
setupTestRedis(t)
handler := NewSecretsHandler(nil)
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
WithArgs("ws-model-err").
WillReturnError(sql.ErrConnDone)
@@ -544,7 +546,9 @@ func TestSecretsSetModel_Upsert(t *testing.T) {
restartCalled := make(chan string, 1)
handler := NewSecretsHandler(func(id string) { restartCalled <- id })
mock.ExpectExec(`INSERT INTO workspace_secrets`).
// Pin the literal 'MODEL' key in the SQL so a regression to the
// pre-2026-05-19 'MODEL_PROVIDER' column name shows up here.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
WithArgs("00000000-0000-0000-0000-000000000001", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
@@ -578,7 +582,8 @@ func TestSecretsSetModel_EmptyClears(t *testing.T) {
setupTestRedis(t)
handler := NewSecretsHandler(func(string) {})
mock.ExpectExec(`DELETE FROM workspace_secrets`).
// Pin the literal 'MODEL' key — see TestSecretsSetModel_Upsert.
mock.ExpectExec(`DELETE FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
WithArgs("00000000-0000-0000-0000-000000000002").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -618,6 +623,65 @@ func TestSecretsSetModel_InvalidID(t *testing.T) {
}
}
// TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER pins the
// 2026-05-19 rename: writes via SetModel land under workspace_secrets
// key='MODEL', and reads via GetModel hit the same key. A regression
// that reverts either side to 'MODEL_PROVIDER' will mismatch sqlmock's
// query-regex anchor and fail loudly here. Combined integration-shape
// guard for the secrets.go half of fix/workspace-server-rename-
// MODEL_PROVIDER-to-MODEL.
func TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewSecretsHandler(func(string) {})
// 1. SetModel — must hit key='MODEL' in the INSERT.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'[\s\S]*ON CONFLICT`).
WithArgs("00000000-0000-0000-0000-000000000099", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
w1 := httptest.NewRecorder()
c1, _ := gin.CreateTestContext(w1)
c1.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
c1.Request = httptest.NewRequest("PUT", "/workspaces/00000000-0000-0000-0000-000000000099/model",
strings.NewReader(`{"model":"gpt-5.5"}`))
c1.Request.Header.Set("Content-Type", "application/json")
handler.SetModel(c1)
if w1.Code != http.StatusOK {
t.Fatalf("SetModel: expected 200, got %d: %s", w1.Code, w1.Body.String())
}
// 2. GetModel — must hit key='MODEL' in the SELECT. Return raw
// bytes; the handler will run them through DecryptVersioned.
// crypto is disabled in the test env (no MASTER_KEY), so the
// raw bytes pass through unchanged. We assert the SELECT
// fires against key='MODEL' (the rename pin); the decoded
// value isn't load-bearing for this contract test.
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
WithArgs("00000000-0000-0000-0000-000000000099").
WillReturnRows(sqlmock.NewRows([]string{"encrypted_value", "encryption_version"}).
AddRow([]byte("gpt-5.5"), 0))
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
c2.Request = httptest.NewRequest("GET", "/workspaces/00000000-0000-0000-0000-000000000099/model", nil)
handler.GetModel(c2)
if w2.Code != http.StatusOK {
t.Fatalf("GetModel: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// We don't assert resp["model"] equals "gpt-5.5" because crypto
// state in this package varies by build tag; the load-bearing
// contract is the workspace_secrets key, pinned by the sqlmock
// regex above. If a future change adds encryption to the test
// env, the round-trip value check can move to an integration
// test that owns the crypto state.
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations — Model round-trip did not hit key='MODEL' on both sides: %v", err)
}
}
// ==================== GetProvider / SetProvider (Option B PR-2) ====================
//
// Mirror of the GetModel/SetModel suite. Same secret-storage shape (key=
@@ -88,6 +88,11 @@ func (h *SocketHandler) HandleConnect(c *gin.Context) {
// Wrap WritePump and ReadPump so the gauge is decremented exactly once
// when the client's write goroutine exits (WritePump owns conn lifetime).
// goAsync-exempt (RFC internal#524 Layer 2.2): WebSocket pumps live
// for the duration of the client connection (minutes-hours), not a
// single request. Wrapping them in globalGoAsync would block every
// test's t.Cleanup until every connected WS client disconnects. No
// db.DB access in either pump.
go func() {
ws.WritePump(client)
metrics.TrackWSDisconnect()
@@ -234,7 +234,9 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
"source": "ec2-ssh",
})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -268,7 +270,9 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
"source": "container",
})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -288,6 +292,8 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "replaced", "workspace": workspaceID, "files": len(body.Files), "source": "volume"})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
}
@@ -570,7 +570,9 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -584,7 +586,9 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -598,7 +602,9 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
}
@@ -651,7 +657,9 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -669,7 +677,9 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
return
}
@@ -682,6 +692,8 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
go h.wh.RestartByID(workspaceID)
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
}
}
@@ -211,6 +211,10 @@ func (h *TerminalHandler) handleLocalConnect(c *gin.Context, workspaceID string)
}
// Bridge: container stdout → WebSocket
// goAsync-exempt (RFC internal#524 Layer 2.2): per-WebSocket I/O
// bridge — lifetime is the connection, not a request. The handler
// blocks on `done` below, so the goroutine is already drained
// synchronously. No db.DB access on this path.
done := make(chan struct{})
go func() {
defer close(done)
@@ -433,6 +437,8 @@ func (h *TerminalHandler) handleRemoteConnect(c *gin.Context, workspaceID, insta
done := make(chan struct{})
// PTY → WebSocket
// goAsync-exempt (RFC internal#524 Layer 2.2): WebSocket-lifetime
// I/O bridge; handler blocks on `done` below. No db.DB access.
go func() {
defer close(done)
buf := make([]byte, 4096)
@@ -455,6 +461,7 @@ func (h *TerminalHandler) handleRemoteConnect(c *gin.Context, workspaceID, insta
}()
// WebSocket → PTY (stdin)
// goAsync-exempt (RFC internal#524 Layer 2.2): see above.
go func() {
for {
_, msg, rErr := conn.ReadMessage()
@@ -101,6 +101,47 @@ func (h *WorkspaceHandler) waitAsyncForTest() {
h.asyncWG.Wait()
}
// globalAsync tracks goroutines launched by globalGoAsync — the
// equivalent of WorkspaceHandler.goAsync for sibling handlers that
// don't carry a *WorkspaceHandler reference (SecretsHandler /
// PluginsHandler / AdminPluginDriftHandler / ChannelHandler /
// MCPHandler / RegistryHandler), and for callers of package-level
// free functions (a2a_proxy_helpers extractAndUpsertTokenUsage).
//
// Forward-port of RFC internal#524 Layer 1 deliverable 2: the
// canonical db.DB race fix lives at workspace.go:goAsync / asyncWG,
// but ~25 sibling bare-`go` sites still write to db.DB outside any
// WorkspaceHandler's drain window. globalAsync gives them the same
// drain hook (waitGlobalAsyncForTest, drained from drainTestAsync)
// so a test's t.Cleanup db.DB restore cannot race a detached
// goroutine spawned by any sibling handler.
//
// Zero-cost in production (a single sync.WaitGroup Add/Done per
// fire-and-forget call, no test-only branching).
var globalAsync sync.WaitGroup
// globalGoAsync schedules fn on a detached goroutine tracked by
// globalAsync. Use this in package-internal callers that don't have
// a *WorkspaceHandler receiver to thread h.goAsync through.
//
// When a *WorkspaceHandler IS available, prefer h.goAsync — it lets
// per-handler tests (waitAsyncForTest) wait without disturbing
// unrelated handlers' inflight work.
func globalGoAsync(fn func()) {
globalAsync.Add(1)
go func() {
defer globalAsync.Done()
fn()
}()
}
// waitGlobalAsyncForTest blocks until every globalGoAsync goroutine
// finishes. Called from drainTestAsync's cleanup chain in the test
// harness; production code never calls it.
func waitGlobalAsyncForTest() {
globalAsync.Wait()
}
func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, platformURL, configsDir string) *WorkspaceHandler {
h := &WorkspaceHandler{
broadcaster: b,
@@ -786,51 +786,57 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// Resolution order (priority high → low):
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
// persona-env convention sometimes (mis)set it to a provider slug
// ("minimax") or a runtime name ("claude-code"), neither a valid
// model id — see internal#226. Only fires when the better-named
// vars are absent.)
// 3. envVars["MODEL"] (workspace_secret — written by SetModel /
// WorkspaceHandler.Create / persona env file; the only correct
// home for a picked model id).
//
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
// the operator's explicit per-persona MODEL secret on every restart.
// Symptom: a workspace whose persona env said
// MODEL=MiniMax-M2.7-highspeed booted fine on first /org/import (the
// envVars map was populated direct from the env file), then on the
// next Restart the workspace_secrets-derived MODEL got clobbered by
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model id —
// and the workspace template's adapter routed to providers[0]
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
// during Phase 4 verification of template-claude-code PR #9.
// Pre-fix bug (2026-05-08): this function used to consult
// envVars["MODEL_PROVIDER"] as a fourth fallback AND unconditionally
// overwrite envVars["MODEL"] with that slug when payload.Model was
// empty. The MODEL_PROVIDER key was misleadingly named — it carried
// a model id, never a provider — and the persona-env convention
// sometimes (mis)set it to a provider slug ("minimax") or a runtime
// name ("claude-code"), neither a valid model id. Symptom: a
// workspace whose persona env said MODEL=MiniMax-M2.7-highspeed
// booted fine on first /org/import, then on the next Restart the
// workspace_secrets-derived MODEL got clobbered by
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model
// id — and the workspace template's adapter routed to providers[0]
// (anthropic-oauth) and wedged at SDK initialize.
//
// The 2026-05-19 follow-up fix (this commit) renamed the
// workspace_secrets row MODEL_PROVIDER → MODEL (root cause: the
// misleading column name; see secrets.go + the
// 20260519000000_workspace_secrets_model_provider_rename migration)
// and drops the MODEL_PROVIDER fallback here so the fallback chain
// can no longer confuse a provider slug for a model id. CP-side
// slot-separation (cp#213 + cp#220) merged the analogous fix on
// the CP side; this is the workspace-server companion.
if model == "" {
model = envVars["MOLECULE_MODEL"]
}
if model == "" {
model = envVars["MODEL"]
}
if model == "" {
model = envVars["MODEL_PROVIDER"]
}
if model == "" {
return
}
// Canonical model env vars — molecule-runtime's workspace/config.py
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
// MODEL_PROVIDER (#280). Export both new names so adapters can read
// either; MODEL stays for backwards compat with everything that
// already reads os.environ["MODEL"] (the claude-code adapter does,
// since #194). Without this, the user's canvas selection is silently
// dropped on every templated provision — confirmed via crash-loop
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
// FIRST so the per-runtime branches below can layer on additional
// vendor-specific names without fighting over the canonical one.
// MODEL_PROVIDER (#280; the legacy env-var fallback in the Python
// runtime is independent of the workspace_secrets row rename — it
// still reads the env var for back-compat with already-running
// images, but workspace-server no longer emits it). Export both new
// names so adapters can read either; MODEL stays for backwards
// compat with everything that already reads os.environ["MODEL"]
// (the claude-code adapter does, since #194). Without this, the
// user's canvas selection is silently dropped on every templated
// provision — confirmed via crash-loop diagnosis on 2026-05-02
// where MiniMax picks booted with model=sonnet (template default)
// and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these FIRST so the
// per-runtime branches below can layer on additional vendor-
// specific names without fighting over the canonical one.
envVars["MOLECULE_MODEL"] = model
envVars["MODEL"] = model
@@ -0,0 +1,176 @@
package handlers
// workspace_provision_forbidden_env.go — Layer 1 of the RFC#523
// tenant-workspace forbidden-env guardrail (task #146).
//
// Threat model: tenant workspaces (per-customer EC2 / container)
// run untrusted agent-controlled code and MUST NEVER receive
// operator-fleet-scope credentials. A leak from one tenant
// workspace to operator scope would escalate "compromise of one
// agent" into "compromise of the whole platform."
//
// The existing forensic #145 guard (provisioner.scmWriteTokenKeys
// in buildContainerEnv / CPProvisioner.Start) strips SCM-write
// tokens at the FINAL container-env-build step — silent strip,
// no signal back to the caller. RFC#523 adds a FAIL-CLOSED layer
// EARLIER in the provision pipeline: when the resolved env-set
// at prepareProvisionContext-time contains any forbidden var
// name, the provision is aborted with a structured error so the
// operator sees the leak immediately instead of running with a
// silently-stripped env.
//
// Layer placement (3-layer defense-in-depth, RFC#523 §"Proposed guardrail"):
// - L1 (this file): provisioner-side abort BEFORE container start
// - L2 (workspace/entrypoint.sh + template-* start.sh): in-container
// env-grep + exit 1 — defense-in-depth if L1 is bypassed
// - L3 (.gitea/workflows/lint-forbidden-env-keys.yml): CI lint that
// scans Go code under workspace-server/ for new writers that
// would inject a forbidden key
//
// Open-source-template compatibility (memory
// `feedback_open_source_templates_no_hardcoded_org_internals`):
// the forbidden-key set is GENERIC (no molecule-AI-specific
// hostnames or org names). A third-party fork can replace this
// set with its own operator-scope key names without editing any
// template.
import (
"fmt"
"sort"
"strings"
)
// forbiddenTenantEnvKeys is the set of environment variable names
// that MUST NOT reach a tenant workspace container. The check is
// by exact key name — value-shape leaks (40-byte hex strings, etc)
// are out of scope here; the separate secret-scan workflow covers
// that class.
//
// Categories (RFC#523):
// - SCM-write tokens: same as provisioner.scmWriteTokenKeys, kept
// in sync. Listed again here so a future split of the two
// denylists is auditable diff.
// - Control-plane admin tokens: any token that grants control-plane
// admin API access.
// - Secret-store operator tokens: bootstrap-scope tokens for the
// central secret store.
// - Infra-platform tokens: deploy / fleet-management creds.
// - Operator-host pointers: hostnames / addresses that identify
// the operator host. Per the open-source-template rule these
// are MOLECULE_OPERATOR_HOST style prefixes; the literal
// prefix is matched but the test for membership reads from
// this map, not from a hardcoded constant in the deny rule
// itself.
//
// Per-agent persona PATs (e.g. AGENT_DEV_A_TOKEN style names —
// not operator-fleet scope) are NOT on this list. The guard
// checks the env VAR NAME, not the token VALUE, so a per-agent
// scoped token under a per-agent var name passes through.
var forbiddenTenantEnvKeys = map[string]struct{}{
// SCM-write — kept in sync with provisioner.scmWriteTokenKeys.
"GITEA_TOKEN": {},
"GITEA_PAT": {},
"GITHUB_TOKEN": {},
"GITHUB_PAT": {},
"GH_TOKEN": {},
"GITLAB_TOKEN": {},
"GL_TOKEN": {},
"BITBUCKET_TOKEN": {},
// Control-plane admin tokens.
"CP_ADMIN_API_TOKEN": {},
"CP_ADMIN_TOKEN": {},
// Secret-store operator tokens (Infisical SSOT — operator scope only).
"INFISICAL_OPERATOR_TOKEN": {},
"INFISICAL_BOOTSTRAP_TOKEN": {},
// Infra-platform tokens.
"RAILWAY_TOKEN": {},
"RAILWAY_PERSONAL_API_TOKEN": {},
"HETZNER_TOKEN": {},
"HETZNER_API_TOKEN": {},
}
// forbiddenTenantEnvPrefixes are key-name PREFIXES that match
// operator-scope env vars. Matched in addition to the exact-key
// set above. Useful for "MOLECULE_OPERATOR_*" style families
// where new members get added without re-editing the deny set.
//
// Kept as a tiny set on purpose — over-broad prefix matching is
// the failure mode this layer's exact-key set is designed to
// avoid. Add a prefix here only when the family is closed
// (every member is operator-scope; no legitimate tenant-scope
// member exists or will).
var forbiddenTenantEnvPrefixes = []string{
"MOLECULE_OPERATOR_",
}
// isForbiddenTenantEnvKey reports whether an env var name is on
// the forbidden-for-tenant-workspaces list (either by exact match
// in forbiddenTenantEnvKeys or by prefix in
// forbiddenTenantEnvPrefixes).
//
// Exported-style helper kept package-private — the deny set is
// internal to the workspace-server package; external callers must
// go through the provision pipeline, which means the abort path
// fires for them too.
func isForbiddenTenantEnvKey(key string) bool {
if _, ok := forbiddenTenantEnvKeys[key]; ok {
return true
}
for _, prefix := range forbiddenTenantEnvPrefixes {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
// findForbiddenTenantEnvKeys scans the resolved env-set and
// returns the sorted list of forbidden keys present. Empty slice
// (not nil — easier for callers to JSON-encode) when none match.
//
// Deterministic order: the result feeds the user-facing error
// message and the structured-extra payload that goes to the
// canvas Events tab. Sorting makes the message stable across
// Go's randomized map iteration.
func findForbiddenTenantEnvKeys(envVars map[string]string) []string {
if len(envVars) == 0 {
return []string{}
}
found := make([]string, 0)
for k := range envVars {
if isForbiddenTenantEnvKey(k) {
found = append(found, k)
}
}
sort.Strings(found)
return found
}
// formatForbiddenTenantEnvError builds the safe-canned user-facing
// message for a provision aborted because forbidden env keys are
// present in the resolved env-set. The message names the
// offending keys (key names are not secret — the values would be,
// but only names are surfaced) and points at the RFC.
//
// Same shape as formatMissingEnvError so the canvas Events tab
// renders both classes consistently.
func formatForbiddenTenantEnvError(keys []string) string {
if len(keys) == 0 {
// Defensive: caller should not invoke with empty input,
// but keep the function total.
return "provision aborted: forbidden operator-scope env vars present (RFC#523)"
}
if len(keys) == 1 {
return fmt.Sprintf(
"provision aborted: env var %q is operator-scope and must not reach tenant workspaces (RFC#523) — remove it from workspace_secrets / global_secrets and retry",
keys[0],
)
}
return fmt.Sprintf(
"provision aborted: env vars %s are operator-scope and must not reach tenant workspaces (RFC#523) — remove them from workspace_secrets / global_secrets and retry",
strings.Join(keys, ", "),
)
}
@@ -0,0 +1,182 @@
package handlers
// workspace_provision_forbidden_env_test.go — Layer 1 tests for the
// RFC#523 tenant-workspace forbidden-env guardrail (task #146).
//
// Behaviour pinned (per RFC#523 §"Acceptance criteria" Layer 1):
// - exact-match keys (GITEA_TOKEN, CP_ADMIN_API_TOKEN, RAILWAY_TOKEN,
// INFISICAL_OPERATOR_TOKEN, …) are flagged
// - MOLECULE_OPERATOR_* prefix family is flagged
// - per-agent-scope vars (GIT_HTTP_USERNAME, ANTHROPIC_API_KEY,
// AGENT_DEV_A_TOKEN, …) are NOT flagged — guard checks key NAME
// not value
// - findForbiddenTenantEnvKeys returns a deterministically-sorted
// slice (canvas Events tab needs stable rendering)
// - formatForbiddenTenantEnvError uses singular vs plural phrasing
// so the message reads naturally for both 1-key and N-key cases
//
// Companion: provisioner.buildContainerEnv has the older silent-
// strip guard (forensic #145). The two layers are intentionally
// redundant — this one fails closed early; that one strips late.
import (
"strings"
"testing"
)
func TestIsForbiddenTenantEnvKey_ExactMatches(t *testing.T) {
cases := []struct {
key string
want bool
}{
// SCM-write tokens — kept in sync with provisioner.scmWriteTokenKeys.
{"GITEA_TOKEN", true},
{"GITEA_PAT", true},
{"GITHUB_TOKEN", true},
{"GITHUB_PAT", true},
{"GH_TOKEN", true},
{"GITLAB_TOKEN", true},
{"GL_TOKEN", true},
{"BITBUCKET_TOKEN", true},
// Control-plane admin tokens.
{"CP_ADMIN_API_TOKEN", true},
{"CP_ADMIN_TOKEN", true},
// Secret-store operator tokens.
{"INFISICAL_OPERATOR_TOKEN", true},
{"INFISICAL_BOOTSTRAP_TOKEN", true},
// Infra-platform tokens.
{"RAILWAY_TOKEN", true},
{"RAILWAY_PERSONAL_API_TOKEN", true},
{"HETZNER_TOKEN", true},
{"HETZNER_API_TOKEN", true},
// Per-agent scoped — must NOT be flagged.
{"GIT_HTTP_USERNAME", false},
{"GIT_HTTP_PASSWORD", false},
{"ANTHROPIC_API_KEY", false},
{"ANTHROPIC_AUTH_TOKEN", false},
{"OPENAI_API_KEY", false},
{"KIMI_API_KEY", false},
{"MINIMAX_API_KEY", false},
{"AGENT_DEV_A_TOKEN", false}, // hypothetical per-agent name
{"MOLECULE_AGENT_ROLE", false},
{"PARENT_ID", false},
{"WORKSPACE_ID", false},
{"PLATFORM_URL", false},
{"", false},
}
for _, c := range cases {
got := isForbiddenTenantEnvKey(c.key)
if got != c.want {
t.Errorf("isForbiddenTenantEnvKey(%q) = %v; want %v", c.key, got, c.want)
}
}
}
func TestIsForbiddenTenantEnvKey_PrefixMatches(t *testing.T) {
cases := []struct {
key string
want bool
}{
{"MOLECULE_OPERATOR_HOST", true},
{"MOLECULE_OPERATOR_SSH_KEY", true},
{"MOLECULE_OPERATOR_BACKUP_BUCKET", true},
{"MOLECULE_OPERATOR_", true}, // prefix itself
// Adjacent but NOT in prefix family.
{"MOLECULE_AGENT_ROLE", false},
{"MOLECULE_URL", false},
{"MOLECULE_PERSONA_ROOT", false}, // path on operator host, not tenant
{"MOLECULE_GITEA_TOKEN", false}, // localbuild-time only; not a tenant env
}
for _, c := range cases {
got := isForbiddenTenantEnvKey(c.key)
if got != c.want {
t.Errorf("isForbiddenTenantEnvKey(%q) = %v; want %v", c.key, got, c.want)
}
}
}
func TestFindForbiddenTenantEnvKeys_NoneAndEmpty(t *testing.T) {
if got := findForbiddenTenantEnvKeys(nil); len(got) != 0 {
t.Errorf("nil envVars: got %v; want empty", got)
}
if got := findForbiddenTenantEnvKeys(map[string]string{}); len(got) != 0 {
t.Errorf("empty envVars: got %v; want empty", got)
}
clean := map[string]string{
"ANTHROPIC_API_KEY": "sk-keep",
"GIT_HTTP_USERNAME": "agent-dev-a",
"GIT_HTTP_PASSWORD": "scoped-pat",
"MOLECULE_AGENT_ROLE": "agent-dev-a",
"WORKSPACE_ID": "ws-123",
}
if got := findForbiddenTenantEnvKeys(clean); len(got) != 0 {
t.Errorf("clean envVars: got %v; want empty", got)
}
}
func TestFindForbiddenTenantEnvKeys_SingleAndMultipleSorted(t *testing.T) {
// Single key.
single := map[string]string{
"ANTHROPIC_API_KEY": "sk-keep",
"GITEA_TOKEN": "operator-scope-leak",
}
got := findForbiddenTenantEnvKeys(single)
if len(got) != 1 || got[0] != "GITEA_TOKEN" {
t.Errorf("single forbidden: got %v; want [GITEA_TOKEN]", got)
}
// Multiple keys — must be sorted (canvas Events tab needs stability).
multi := map[string]string{
"RAILWAY_TOKEN": "z",
"GITEA_TOKEN": "a",
"MOLECULE_OPERATOR_HOST": "m",
"CP_ADMIN_API_TOKEN": "c",
"ANTHROPIC_API_KEY": "ok",
}
got = findForbiddenTenantEnvKeys(multi)
want := []string{"CP_ADMIN_API_TOKEN", "GITEA_TOKEN", "MOLECULE_OPERATOR_HOST", "RAILWAY_TOKEN"}
if len(got) != len(want) {
t.Fatalf("multi forbidden length: got %v; want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("multi forbidden[%d] = %q; want %q (full got=%v want=%v)", i, got[i], want[i], got, want)
}
}
}
func TestFormatForbiddenTenantEnvError_Phrasing(t *testing.T) {
// Empty input — defensive total function.
if msg := formatForbiddenTenantEnvError(nil); !strings.Contains(msg, "RFC#523") {
t.Errorf("empty input: missing RFC#523 ref: %q", msg)
}
// Singular phrasing.
single := formatForbiddenTenantEnvError([]string{"GITEA_TOKEN"})
if !strings.Contains(single, `"GITEA_TOKEN"`) {
t.Errorf("single: missing quoted key: %q", single)
}
if !strings.Contains(single, "operator-scope") {
t.Errorf("single: missing operator-scope phrase: %q", single)
}
if !strings.Contains(single, "RFC#523") {
t.Errorf("single: missing RFC#523 ref: %q", single)
}
if strings.Contains(single, "env vars ") { // plural form
t.Errorf("single: leaked plural phrasing: %q", single)
}
// Plural phrasing.
multi := formatForbiddenTenantEnvError([]string{"CP_ADMIN_API_TOKEN", "GITEA_TOKEN"})
if !strings.Contains(multi, "CP_ADMIN_API_TOKEN, GITEA_TOKEN") {
t.Errorf("plural: missing joined list: %q", multi)
}
if !strings.Contains(multi, "env vars ") {
t.Errorf("plural: missing plural phrase: %q", multi)
}
}
@@ -125,12 +125,62 @@ func (h *WorkspaceHandler) prepareProvisionContext(
return nil, &provisionAbort{Msg: decryptErr}
}
// RFC#523 Layer 1 (task #146): refuse to start a tenant workspace
// when any forbidden operator-scope env var is present in the
// resolved secret-load env-set. Runs IMMEDIATELY after
// loadWorkspaceSecrets and BEFORE applyAgentGitHTTPCreds — the
// per-agent persona injection sets a fallback GITEA_USER/GITEA_TOKEN
// pair that the buildContainerEnv forensic #145 guard will strip
// later. We want THIS layer to catch leaks from the operator-
// controlled stores (global_secrets, workspace_secrets) only, not
// the deliberate per-agent platform injection that lives downstream.
//
// Threat model is "an upstream secret-writer accidentally widened
// the propagation set" — e.g. an operator pastes GITEA_TOKEN into
// a workspace_secrets row. Caught here, surfaced loudly to the
// canvas Events tab, fail-closed. The existing forensic #145 guard
// in provisioner.buildContainerEnv / CPProvisioner.Start stays as
// defense-in-depth: it silently strips at container-env-build time.
//
// Key names (not values) are echoed in the user-facing error so
// the operator can locate and remove the offending row. Per memory
// `feedback_passwords_in_chat_are_burned`, key names are not
// secret; values would be.
if forbidden := findForbiddenTenantEnvKeys(envVars); len(forbidden) > 0 {
msg := formatForbiddenTenantEnvError(forbidden)
log.Printf("Provisioner: ABORT workspace=%s — forbidden operator-scope env keys present: %v (RFC#523)", workspaceID, forbidden)
return nil, &provisionAbort{
Msg: msg,
Extra: map[string]interface{}{"error": msg, "forbidden_env_keys": forbidden, "rfc": "523"},
}
}
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
// Per-agent git identity (#1957) — must run after secret loads so
// a workspace_secret named GIT_AUTHOR_NAME can override.
applyAgentGitIdentity(envVars, payload.Name)
// Per-agent git HTTP credential injection — bridges the gap that
// PR template-claude-code#30 + mc#1525 left open: the askpass binary
// + GIT_ASKPASS env are wired in-image, but until now no code path
// in workspace-server actually read the persona's git token from
// the operator-host bootstrap dir and exported it as
// GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD. Without this, the askpass
// helper invokes with an empty password env and git fails the
// auth challenge in ~500ms (live-verified for Dev-A/Dev-B
// 2026-05-18 ~23:55Z).
//
// Runs AFTER applyAgentGitIdentity so workspace_secrets named
// GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (operator-supplied,
// loaded earlier by loadWorkspaceSecrets) win over the
// persona-file default. Uses payload.Role as the persona key —
// this matches the slug-form convention agent-dev-a /
// agent-dev-b / agent-pm. Descriptive multi-word roles
// ("Frontend Engineer") take the silent-no-op branch and
// continue to rely on workspace_secrets / org-import persona-env
// merge for their git auth.
applyAgentGitHTTPCreds(envVars, payload.Role)
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
if payload.Role != "" {
envVars["MOLECULE_AGENT_ROLE"] = payload.Role
@@ -297,6 +297,203 @@ func TestPrepareProvisionContext_ParentIDInjection(t *testing.T) {
func ptrStr(s string) *string { return &s }
// TestPrepareProvisionContext_InjectsGitHTTPCredsFromPersonaToken pins
// the end-to-end wiring of the durable-git-auth fix: when a workspace
// is provisioned with a slug-form role matching a persona dir at
// $MOLECULE_PERSONA_ROOT/<role>/token, the prepared envVars MUST
// carry GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (+ GITEA_USER / GITEA_TOKEN
// fallback) so the in-container askpass helper has something to emit
// on git's auth challenge.
//
// Pre-fix shape (Dev-A/Dev-B live-verified 2026-05-18 ~23:55Z): the
// askpass binary + GIT_ASKPASS env were already wired
// (template-claude-code#30 + mc#1525), but GIT_HTTP_USERNAME and
// GIT_HTTP_PASSWORD were absent from the container env → askpass
// returned empty → git rc=128 "Authentication failed" in <500ms.
// This test fails without applyAgentGitHTTPCreds wired into
// prepareProvisionContext and proves the prod-team path is closed.
func TestPrepareProvisionContext_InjectsGitHTTPCredsFromPersonaToken(t *testing.T) {
// Stage a persona dir matching the prod-team shape per
// reference_prod_team_infisical_identities — a flat dir per role
// with a single mode-600 `token` file.
root := t.TempDir()
for _, role := range []string{"agent-dev-a", "agent-dev-b"} {
roleDir := filepath.Join(root, role)
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
// Token value pinned to a recognizable string so we can
// assert exact propagation. Real bootstrap-kit files end in
// \n; the helper must trim that.
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte("token-for-"+role+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
cases := []struct {
name string
role string
expectInject bool
expectUser string
expectPass string
}{
{
name: "Dev-A slug role → persona token injected as GIT_HTTP_USERNAME/PASSWORD",
role: "agent-dev-a",
expectInject: true,
expectUser: "agent-dev-a",
expectPass: "token-for-agent-dev-a",
},
{
name: "Dev-B slug role → persona token injected",
role: "agent-dev-b",
expectInject: true,
expectUser: "agent-dev-b",
expectPass: "token-for-agent-dev-b",
},
{
name: "descriptive multi-word role → silent no-op (no persona dir lookup)",
role: "Frontend Engineer",
expectInject: false,
},
{
name: "unknown slug role with no persona dir → silent no-op",
role: "agent-nonexistent",
expectInject: false,
},
{
name: "empty role → silent no-op",
role: "",
expectInject: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets`).
WithArgs("ws-prod-team").
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
handler := NewWorkspaceHandler(&captureBroadcaster{}, nil, "http://localhost:8080", t.TempDir())
payload := models.CreateWorkspacePayload{
Name: "Dev-A",
Role: tc.role,
Tier: 1,
}
prepared, abort := handler.prepareProvisionContext(
context.Background(), "ws-prod-team", "/nonexistent", nil, payload, false)
if abort != nil {
t.Fatalf("unexpected abort: %s", abort.Msg)
}
gotUser, hasUser := prepared.EnvVars["GIT_HTTP_USERNAME"]
gotPass, hasPass := prepared.EnvVars["GIT_HTTP_PASSWORD"]
if tc.expectInject {
if !hasUser || gotUser != tc.expectUser {
t.Errorf("GIT_HTTP_USERNAME: got %q (present=%v), want %q",
gotUser, hasUser, tc.expectUser)
}
if !hasPass || gotPass != tc.expectPass {
t.Errorf("GIT_HTTP_PASSWORD: got %q (present=%v), want %q",
gotPass, hasPass, tc.expectPass)
}
// Fallback pair should ALSO be set so askpass's
// GITEA_USER/GITEA_TOKEN fallback chain works
// (GITEA_TOKEN will then be stripped at
// buildContainerEnv per forensic #145, but
// GITEA_USER survives — see provisioner_test.go
// "persona-file path" subtest).
if prepared.EnvVars["GITEA_USER"] != tc.expectUser {
t.Errorf("GITEA_USER fallback: got %q, want %q",
prepared.EnvVars["GITEA_USER"], tc.expectUser)
}
if prepared.EnvVars["GITEA_TOKEN"] != tc.expectPass {
t.Errorf("GITEA_TOKEN fallback: got %q, want %q",
prepared.EnvVars["GITEA_TOKEN"], tc.expectPass)
}
} else {
if hasUser {
t.Errorf("GIT_HTTP_USERNAME should NOT be set for role %q; got %q",
tc.role, gotUser)
}
if hasPass {
t.Errorf("GIT_HTTP_PASSWORD should NOT be set for role %q; got %q",
tc.role, gotPass)
}
}
// applyAgentGitIdentity always wires GIT_ASKPASS when
// payload.Name is non-empty — sanity check that the new
// wiring didn't accidentally bypass the existing askpass
// env-set (the helper without env = nothing to emit).
if prepared.EnvVars["GIT_ASKPASS"] != "/usr/local/bin/molecule-askpass" {
t.Errorf("GIT_ASKPASS should remain wired by applyAgentGitIdentity; got %q",
prepared.EnvVars["GIT_ASKPASS"])
}
})
}
}
// TestPrepareProvisionContext_WorkspaceSecretWinsOverPersonaToken pins
// the precedence contract: an operator-supplied workspace_secret named
// GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (loaded by loadWorkspaceSecrets
// BEFORE applyAgentGitHTTPCreds runs) must beat the persona-file
// default. This is the standard escape hatch — if an operator needs a
// per-workspace override (e.g. a workspace-scoped Gitea token with
// narrower repo access than the persona's), the secrets API still
// works.
func TestPrepareProvisionContext_WorkspaceSecretWinsOverPersonaToken(t *testing.T) {
root := t.TempDir()
roleDir := filepath.Join(root, "agent-dev-a")
if err := os.MkdirAll(roleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(roleDir, "token"),
[]byte("persona-file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MOLECULE_PERSONA_ROOT", root)
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
// Workspace secret pre-populates GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD —
// these come from loadWorkspaceSecrets which runs before applyAgentGitHTTPCreds.
// encryption_version=0 means raw bytes (crypto disabled in test).
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets`).
WithArgs("ws-prod-team").
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
AddRow("GIT_HTTP_USERNAME", []byte("operator-override-user"), 0).
AddRow("GIT_HTTP_PASSWORD", []byte("operator-override-pass"), 0))
handler := NewWorkspaceHandler(&captureBroadcaster{}, nil, "http://localhost:8080", t.TempDir())
payload := models.CreateWorkspacePayload{
Name: "Dev-A",
Role: "agent-dev-a",
Tier: 1,
}
prepared, abort := handler.prepareProvisionContext(
context.Background(), "ws-prod-team", "/nonexistent", nil, payload, false)
if abort != nil {
t.Fatalf("unexpected abort: %s", abort.Msg)
}
if prepared.EnvVars["GIT_HTTP_USERNAME"] != "operator-override-user" {
t.Errorf("operator override lost — GIT_HTTP_USERNAME: got %q, want %q",
prepared.EnvVars["GIT_HTTP_USERNAME"], "operator-override-user")
}
if prepared.EnvVars["GIT_HTTP_PASSWORD"] != "operator-override-pass" {
t.Errorf("operator override lost — GIT_HTTP_PASSWORD: got %q, want %q",
prepared.EnvVars["GIT_HTTP_PASSWORD"], "operator-override-pass")
}
}
// TestReadOrLazyHealInboundSecret pins the four branches of the
// shared lazy-heal helper directly. Each call site (chat_files,
// registry) has its own integration test, but those go through the
@@ -478,15 +675,22 @@ func TestDeriveProviderFromModelSlug(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider pins the
// fix for failed-workspace 95ed3ff2 (2026-05-02). Pre-fix: the canvas
// POSTed minimax/MiniMax-M2.7 in payload.Model, the workspace row was
// created, but neither MODEL_PROVIDER nor LLM_PROVIDER was ever
// created, but neither the model nor the derived provider was ever
// written to workspace_secrets. On any subsequent restart, the
// applyRuntimeModelEnv fallback found nothing in envVars["MODEL_PROVIDER"]
// and hermes booted with the template default (nousresearch/hermes-4-70b)
// → wrong provider keys → /health poll failed → never registered.
// applyRuntimeModelEnv fallback found nothing and hermes booted with
// the template default (nousresearch/hermes-4-70b) → wrong provider
// keys → /health poll failed → never registered.
//
// Post-fix: the create handler writes both rows after committing the
// workspace row. This test asserts the SQL writes happen with the
// correct keys + values.
//
// 2026-05-19 follow-up: the workspace_secrets row that holds the
// picked model id was renamed MODEL_PROVIDER → MODEL (the column name
// was misleading and bled into applyRuntimeModelEnv as a slug
// fallback). The sqlmock regex below now anchors on 'MODEL' instead
// of 'MODEL_PROVIDER'. See fix/workspace-server-rename-
// MODEL_PROVIDER-to-MODEL + the 20260519000000 rename migration.
func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -502,13 +706,16 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// The fix: MODEL_PROVIDER is upserted with the verbatim model slug.
// SQL has 3 placeholders ($1=workspace_id, $2=encrypted_value reused
// in the conflict-update, $3=version reused in the conflict-update),
// so sqlmock sees 3 args. The 'MODEL_PROVIDER' / 'LLM_PROVIDER' key
// is a literal in the SQL — we distinguish the two writes with the
// regex match below.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
// The fix: MODEL is upserted with the verbatim model slug
// (renamed from MODEL_PROVIDER on 2026-05-19 — see file-level
// docstring). SQL has 3 placeholders ($1=workspace_id, $2=
// encrypted_value reused in the conflict-update, $3=version
// reused in the conflict-update), so sqlmock sees 3 args. The
// 'MODEL' / 'LLM_PROVIDER' key is a literal in the SQL — we
// distinguish the two writes with the regex match below. The
// 'MODEL' anchor uses a word boundary (`[^_A-Z]`) so it does
// NOT silently match the legacy 'MODEL_PROVIDER' name.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// The fix: LLM_PROVIDER is upserted with the derived provider name.
@@ -545,13 +752,13 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL_PROVIDER + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
}
}
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
// when payload.Model is empty, NEITHER MODEL_PROVIDER nor LLM_PROVIDER
// is written. Important: the canvas can omit `model` (template inherits
// 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) {
@@ -595,10 +802,11 @@ func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider
// asserts the asymmetric case: an unknown model prefix still gets
// MODEL_PROVIDER persisted (so the user's exact slug survives restart
// and applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
// MODEL persisted (so the user's exact slug survives restart and
// applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
// derive-provider.sh's *=auto branch can decide at runtime instead of
// being pre-empted by a guess).
// being pre-empted by a guess). The MODEL key was renamed from
// MODEL_PROVIDER on 2026-05-19 — see file-level docstring.
func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -610,9 +818,9 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// Only MODEL_PROVIDER — LLM_PROVIDER must NOT be written for
// unknown prefixes. Same 3-arg shape as above; key is literal in SQL.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
// Only MODEL — LLM_PROVIDER must NOT be written for unknown
// prefixes. Same 3-arg shape as above; key is literal in SQL.
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -639,7 +847,7 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL_PROVIDER but skip LLM_PROVIDER: %v", err)
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL but skip LLM_PROVIDER: %v", err)
}
}
@@ -700,11 +908,11 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
model: "",
},
{
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
name: "empty model + MODEL_PROVIDER env IGNORED post-2026-05-19 rename (the slug-fallback bug)",
runtime: "claude-code",
model: "",
modelProviderEnv: "MiniMax-M2",
wantMODEL: "MiniMax-M2",
wantMODEL: "",
},
{
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
@@ -714,7 +922,7 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
wantMODEL: "opus",
},
{
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
name: "MOLECULE_MODEL wins even when stale MODEL_PROVIDER is present (back-compat guard)",
runtime: "claude-code",
model: "",
moleculeModelEnv: "opus",
@@ -750,18 +958,26 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
// TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved locks in the
// 2026-05-08 fix that prevents the MODEL_PROVIDER-as-slug fallback from
// silently overwriting a per-persona MODEL workspace_secret on restart.
// silently overwriting a per-persona MODEL workspace_secret on restart,
// EXTENDED for the 2026-05-19 root-cause fix that drops the
// MODEL_PROVIDER fallback entirely.
//
// Pre-fix bug recurrence guard: when the persona env file (loaded into
// workspace_secrets at /org/import time) declares both MODEL=<id> and
// MODEL_PROVIDER=<slug>, the restart path used to overwrite envVars["MODEL"]
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv'\''s
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv's
// payload.Model fallback consulted MODEL_PROVIDER first. Symptom: dev-tree
// workspaces booted fine on first /org/import, then on next restart the
// model id became literal "minimax" and the workspace template'\''s adapter
// model id became literal "minimax" and the workspace template's adapter
// failed to match any registry prefix, fell through to anthropic-oauth,
// and wedged at SDK initialize. Caught during Phase 4 verification of
// template-claude-code PR #9.
//
// 2026-05-19 follow-up: the MODEL_PROVIDER fallback is now removed.
// MODEL is the only env-var source for the picked model id.
// MODEL_PROVIDER is intentionally NOT consulted — a stale MODEL_PROVIDER
// row left over from before the 20260519000000 migration must NOT leak
// into envVars["MODEL"]. Verified by the third case below.
func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
cases := []struct {
name string
@@ -770,7 +986,7 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
wantMODEL string
}{
{
name: "MODEL secret wins over MODEL_PROVIDER slug (persona-env shape on restart)",
name: "MODEL secret wins; stale MODEL_PROVIDER ignored (persona-env shape on restart)",
envMODEL: "MiniMax-M2.7-highspeed",
envMP: "minimax",
wantMODEL: "MiniMax-M2.7-highspeed",
@@ -782,10 +998,10 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
wantMODEL: "opus",
},
{
name: "MODEL absent → fall back to MODEL_PROVIDER (legacy canvas Save+Restart shape)",
name: "MODEL absent → MODEL_PROVIDER no longer fallback (2026-05-19 fix): nothing set",
envMODEL: "",
envMP: "MiniMax-M2.7",
wantMODEL: "MiniMax-M2.7",
wantMODEL: "",
},
{
name: "Both absent → no MODEL set",
@@ -812,3 +1028,48 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
})
}
}
// TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL is the
// 2026-05-19 root-cause pin: workspaces that were live BEFORE the
// 20260519000000_workspace_secrets_model_provider_rename migration ran
// may still have a MODEL_PROVIDER row in workspace_secrets that lands
// in envVars (the loader doesn't filter — anything in workspace_secrets
// gets passed through). Post-fix, applyRuntimeModelEnv MUST NOT consult
// that key for any purpose — neither as a fallback for the picked model
// id nor as an indirect overwrite of MODEL. Asserts the read-out shape:
//
// - envVars["MODEL"] stays empty when no other source provided one
// - envVars["MOLECULE_MODEL"] stays empty
// - envVars["HERMES_DEFAULT_MODEL"] stays empty
// - envVars["MODEL_PROVIDER"] itself is left as-is (we don't actively
// scrub it — the rename migration does that on the DB side)
//
// Pairs with workspace_provision.go applyRuntimeModelEnv (line 817
// fallback removed) and secrets.go (workspace_secrets key MODEL).
func TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL(t *testing.T) {
envVars := map[string]string{
"MODEL_PROVIDER": "minimax", // legacy slug — the prod-bug shape
}
applyRuntimeModelEnv(envVars, "claude-code", "")
if got, ok := envVars["MODEL"]; ok {
t.Errorf("MODEL must not be set from MODEL_PROVIDER fallback (post-2026-05-19 fix); got=%q", got)
}
if got, ok := envVars["MOLECULE_MODEL"]; ok {
t.Errorf("MOLECULE_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
}
if got, ok := envVars["HERMES_DEFAULT_MODEL"]; ok {
t.Errorf("HERMES_DEFAULT_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
}
if got := envVars["MODEL_PROVIDER"]; got != "minimax" {
t.Errorf("MODEL_PROVIDER must be passed through untouched (DB-side rename handles cleanup); got=%q", got)
}
// Hermes-runtime variant — same shape, same expectation.
envVarsH := map[string]string{
"MODEL_PROVIDER": "minimax",
}
applyRuntimeModelEnv(envVarsH, "hermes", "")
if _, ok := envVarsH["HERMES_DEFAULT_MODEL"]; ok {
t.Errorf("hermes runtime must not leak MODEL_PROVIDER into HERMES_DEFAULT_MODEL")
}
}
@@ -8,6 +8,7 @@ import (
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
@@ -39,12 +40,57 @@ type restartState struct {
mu sync.Mutex
running bool // true while a restart cycle is in flight
pending bool // set by any caller that arrived during the in-flight cycle
// restartStartedAt records the wall-clock when the most recent cycle
// flipped running=true. Used by the self-fire debounce (internal#544,
// the ws-server self-fire restart feedback loop seen in prod-Reviewer/
// Researcher 2026-05-19 ~00:05Z 4x reprov thrash): any RestartByID
// arriving within restartDebounceWindow of this timestamp is silently
// dropped so a probe firing during the EC2-pending window can't
// re-trigger a fresh full cycle on the just-launched instance.
restartStartedAt time.Time
}
// restartStates is a per-workspace map of *restartState. Each workspace gets
// its own entry so unrelated workspaces don't serialize on each other.
var restartStates sync.Map // map[workspaceID]*restartState
// restartDebounceWindow is the silent-drop window for successive RestartByID
// calls. Sized to cover the typical EC2 pending → online interval (20-30s)
// with a margin so a probe firing during the just-after-online but still-
// flaky heartbeat window also gets dropped. Bigger than that would block
// legitimate "Restart failed, retry" recoveries; smaller would let the
// 4x thrash class through. Package-level so tests can shrink it.
var restartDebounceWindow = 60 * time.Second
// restartByIDDropCounter is incremented every time RestartByID drops a call
// inside the debounce window. Exposed as a package-level atomic counter so
// (a) tests can assert the drop fired, (b) ops can grep logs for the drop
// log line + the counter snapshot in a future /admin/metrics endpoint.
// Not a Prometheus metric because the platform doesn't pull metrics from
// workspace-server yet — that's a separate RFC.
var restartByIDDropCounter atomic.Uint64
// isRestarting reports whether a restart cycle is currently in flight for
// the workspace. Callers that have their own "container looks dead" probe
// MUST consult this before triggering a restart, because during the
// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false
// looks identical to a dead container — and any restart-triggering probe
// (maybeMarkContainerDead from canvas /delegations poll, or the trailing
// restart-context probe at the end of runRestartCycle) will set
// pending=true and the outer coalesceRestart loop will drain by running
// ANOTHER full cycle, ec2_stopped of the just-booted instance →
// re-provision. That's the self-fire loop closed by this gate.
func isRestarting(workspaceID string) bool {
sv, ok := restartStates.Load(workspaceID)
if !ok {
return false
}
state := sv.(*restartState)
state.mu.Lock()
defer state.mu.Unlock()
return state.running
}
// isParentPaused checks if any ancestor of the workspace is paused.
func isParentPaused(ctx context.Context, workspaceID string) (bool, string) {
var parentID *string
@@ -376,9 +422,45 @@ func (h *WorkspaceHandler) RestartByID(workspaceID string) {
if !h.HasProvisioner() {
return
}
// Self-fire debounce: drop (not coalesce) successive RestartByID calls
// within restartDebounceWindow of the most recent cycle's start. This
// is the load-bearing protection against the 4x reprov thrash class —
// coalesceRestart's pending-flag would otherwise drain by running
// ANOTHER full cycle of stop+provision on the just-launched EC2 (still
// in the pending state), which is the self-fire we're closing.
//
// Only applies to RestartByID (programmatic — secrets handler,
// maybeMarkContainerDead, preflightContainerHealth). The HTTP Restart
// handler in workspace_restart.go's Restart() bypasses this path and
// calls RestartWorkspaceAutoOpts directly, so user-initiated restart
// clicks are unaffected.
if shouldDebounceRestart(workspaceID) {
restartByIDDropCounter.Add(1)
log.Printf("RestartByID: %s — dropped (within %s self-fire debounce window; total dropped=%d)",
workspaceID, restartDebounceWindow, restartByIDDropCounter.Load())
return
}
coalesceRestart(workspaceID, func() { h.runRestartCycle(workspaceID) })
}
// shouldDebounceRestart reports whether the most recent cycle for this
// workspace started within restartDebounceWindow. Read-only on
// restartState; the actual restartStartedAt stamp is written in
// coalesceRestart when running flips false→true.
func shouldDebounceRestart(workspaceID string) bool {
sv, ok := restartStates.Load(workspaceID)
if !ok {
return false
}
state := sv.(*restartState)
state.mu.Lock()
defer state.mu.Unlock()
if state.restartStartedAt.IsZero() {
return false
}
return time.Since(state.restartStartedAt) < restartDebounceWindow
}
// coalesceRestart implements the pending-flag gate around an arbitrary cycle
// function. Extracted from RestartByID for direct unit testing — the cycle
// function in production is `runRestartCycle`, but tests pass a counter to
@@ -398,6 +480,12 @@ func coalesceRestart(workspaceID string, cycle func()) {
return
}
state.running = true
// Stamp the start time so the RestartByID debounce can drop any
// self-fire probe that hits within restartDebounceWindow. Only the
// false→true edge stamps; the drain-loop's inner cycles re-use the
// same start (they're effectively one "restart event" from the
// debounce's POV).
state.restartStartedAt = time.Now()
state.mu.Unlock()
// Always clear running on exit — including panic — so a panicking
@@ -0,0 +1,297 @@
package handlers
// Tests for the 2026-05-19 ws-server self-fire restart feedback loop fix.
//
// Empirical chain reproduced (prod-Reviewer/Researcher 4x reprov thrash
// 2026-05-19 ~00:05-00:09Z, root-caused via Loki):
//
// 1. POST /secrets → go h.restartFunc(workspaceID) (secrets.go:264).
// 2. runRestartCycle sets url='' synchronously, then async provisions EC2
// (workspace_restart.go).
// 3. During 20-30s window while EC2 is `pending` (codex first heartbeat
// not yet landed): workspaces.url='' AND IsRunning=false.
// 4. Any ProxyA2A (canvas /delegations poll OR the restart-context probe
// at the end of runRestartCycle) → maybeMarkContainerDead sees the
// container-dead state → calls RestartByID → loop.
// 5. coalesceRestart sets pending=true, drains by running ANOTHER full
// cycle → provision.ec2_stopped of the just-booted instance →
// re-provision.
//
// Fix: three interdependent layers.
//
// L1) isRestarting() gate in maybeMarkContainerDead +
// preflightContainerHealth — early-return false/nil so the probe
// can't trigger a fresh RestartByID while a restart is in flight.
// L2) sendRestartContext requires url != '' AND last_heartbeat_at >
// restart_start_ts before firing the trailing ProxyA2A probe.
// L3) RestartByID silently drops successive calls within
// restartDebounceWindow of restartStartedAt, with a counter for
// observability.
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
// resetSelfFireState wipes all the per-workspace mutation state these
// tests touch, plus the package-level drop counter, so the test is
// hermetic regardless of ordering.
func resetSelfFireState(workspaceID string) {
restartStates.Delete(workspaceID)
restartByIDDropCounter.Store(0)
}
// markRestarting forces restartStates into "cycle in flight" without
// running an actual cycle, so the tests can isolate the gate behaviour
// without the full provision pipeline. Returns a finish() that flips
// running=false (mimicking coalesceRestart's deferred state-clear).
func markRestarting(workspaceID string) (finish func()) {
sv, _ := restartStates.LoadOrStore(workspaceID, &restartState{})
state := sv.(*restartState)
state.mu.Lock()
state.running = true
state.restartStartedAt = time.Now()
state.mu.Unlock()
return func() {
state.mu.Lock()
state.running = false
state.mu.Unlock()
}
}
// TestIsRestarting_FalseWhenNoStateEntry — baseline: a workspace that
// has never been restarted reports !isRestarting. Pinning this so a
// future LoadOrStore refactor can't silently start returning true for
// unknown workspaces.
func TestIsRestarting_FalseWhenNoStateEntry(t *testing.T) {
const wsID = "self-fire-ws-never"
resetSelfFireState(wsID)
if isRestarting(wsID) {
t.Fatal("isRestarting must return false for a workspace with no state entry")
}
}
// TestIsRestarting_TrueWhileCycleRunning — the load-bearing invariant
// that Layer 1 depends on. While running=true, isRestarting must report
// true; the moment it flips to false, isRestarting must report false.
func TestIsRestarting_TrueWhileCycleRunning(t *testing.T) {
const wsID = "self-fire-ws-in-flight"
resetSelfFireState(wsID)
finish := markRestarting(wsID)
if !isRestarting(wsID) {
t.Fatal("isRestarting must return true while running=true")
}
finish()
if isRestarting(wsID) {
t.Fatal("isRestarting must return false after running flips back to false")
}
}
// TestMaybeMarkContainerDead_SkippedWhileRestarting — Layer 1 for the
// reactive path. With isRestarting=true the function must early-return
// false WITHOUT invoking IsRunning, hitting the DB UPDATE, or kicking
// a RestartByID goroutine. If any of those side-effects fire we'd
// re-arm the self-fire loop the gate exists to close.
func TestMaybeMarkContainerDead_SkippedWhileRestarting(t *testing.T) {
const wsID = "self-fire-ws-mmcd"
resetSelfFireState(wsID)
mock := setupTestDB(t) // sqlmock with strict expectation matching
// Workspace row read inside maybeMarkContainerDead — this happens
// BEFORE the isRestarting gate in the current implementation, so
// allow exactly one SELECT runtime row.
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
// Gate flipped: must early-return without doing anything else.
finish := markRestarting(wsID)
defer finish()
stub := &preflightLocalProv{running: false, err: nil}
h := newSelfFireHandler(t)
h.provisioner = stub
if got := h.maybeMarkContainerDead(context.Background(), wsID); got != false {
t.Errorf("maybeMarkContainerDead must return false while restarting, got %v", got)
}
if stub.calls != 0 {
t.Errorf("IsRunning must not be called while restarting (Layer 1 gate broken); got %d calls", stub.calls)
}
}
// TestPreflightContainerHealth_SkippedWhileRestarting — Layer 1 for the
// proactive path. Same shape as above: with restart in flight, return
// nil (let the optimistic forward proceed) and DO NOT call IsRunning.
// The forward will fail with a connect error; the post-restart reactive
// path can decide what to do then, by which point the EC2 has either
// come up (no more failures) or markProvisionFailed has fired.
func TestPreflightContainerHealth_SkippedWhileRestarting(t *testing.T) {
const wsID = "self-fire-ws-preflight"
resetSelfFireState(wsID)
_ = setupTestDB(t)
finish := markRestarting(wsID)
defer finish()
stub := &preflightLocalProv{running: false, err: nil}
h := newSelfFireHandler(t)
h.provisioner = stub
if err := h.preflightContainerHealth(context.Background(), wsID); err != nil {
t.Errorf("preflightContainerHealth must return nil while restarting, got %+v", err)
}
if stub.calls != 0 {
t.Errorf("IsRunning must not be called while restarting (Layer 1 gate broken); got %d calls", stub.calls)
}
}
// TestRestartByID_DebounceSilentDrop — Layer 3. After a cycle starts,
// any RestartByID arriving within restartDebounceWindow MUST be dropped
// silently — not coalesced (which would still drain to another cycle).
// The drop counter must increment by exactly one per dropped call so
// ops can see how often the self-fire would have fired pre-fix.
func TestRestartByID_DebounceSilentDrop(t *testing.T) {
const wsID = "self-fire-ws-debounce"
resetSelfFireState(wsID)
// Stamp restartStartedAt = now, running=false (simulates the "just
// finished" window where the loop would re-fire pre-fix).
sv, _ := restartStates.LoadOrStore(wsID, &restartState{})
state := sv.(*restartState)
state.mu.Lock()
state.restartStartedAt = time.Now()
state.running = false
state.mu.Unlock()
// Counter baseline.
if got := restartByIDDropCounter.Load(); got != 0 {
t.Fatalf("expected drop counter 0 at start, got %d", got)
}
// Five rapid-fire RestartByID calls should all drop (the maximum
// observed pre-fix was 4x — pinning >=4 here keeps the regression
// shape true to the prod incident).
h := newSelfFireHandler(t)
stub := &preflightLocalProv{running: true, err: nil}
h.provisioner = stub
for i := 0; i < 5; i++ {
h.RestartByID(wsID)
}
if got := restartByIDDropCounter.Load(); got != 5 {
t.Errorf("expected 5 drops within debounce window, got %d", got)
}
// shouldDebounceRestart itself must report true for the same window.
if !shouldDebounceRestart(wsID) {
t.Error("shouldDebounceRestart must return true within window")
}
}
// TestRestartByID_DebounceExpiresAfterWindow — outside the window, the
// debounce must release: a legitimate later restart (e.g. user clicked
// Restart again after waiting) must proceed to coalesceRestart. We
// shrink restartDebounceWindow to 1ms for the duration of this test so
// we don't sleep a full 60s in CI.
func TestRestartByID_DebounceExpiresAfterWindow(t *testing.T) {
const wsID = "self-fire-ws-debounce-release"
resetSelfFireState(wsID)
orig := restartDebounceWindow
restartDebounceWindow = 5 * time.Millisecond
defer func() { restartDebounceWindow = orig }()
// Stamp inside the window.
sv, _ := restartStates.LoadOrStore(wsID, &restartState{})
state := sv.(*restartState)
state.mu.Lock()
state.restartStartedAt = time.Now()
state.running = false
state.mu.Unlock()
if !shouldDebounceRestart(wsID) {
t.Fatal("within 5ms window must debounce")
}
// Sleep past the window. Use a small margin to avoid clock-skew
// flakes on slow CI hosts.
time.Sleep(20 * time.Millisecond)
if shouldDebounceRestart(wsID) {
t.Fatal("after 20ms (4x window) must no longer debounce")
}
}
// TestRestartByID_SingleProvisionPerRestart — the regression test for
// the prod incident: a SINGLE secrets PUT (which is the trigger shape)
// must produce exactly ONE coalesceRestart cycle, not four. Models the
// full chain: secrets handler → RestartByID → coalesceRestart → cycle
// runs → during the cycle window, simulated probes call RestartByID
// again. With all three layers in place, the probes are dropped and the
// total cycle count stays at 1.
func TestRestartByID_SingleProvisionPerRestart(t *testing.T) {
const wsID = "self-fire-ws-single-provision"
resetSelfFireState(wsID)
// In-flight gate that mimics the EC2-pending window. The cycle
// blocks on cycleProceed so we can fire the simulated probes while
// running=true.
var cycleCount atomic.Int32
cycleStarted := make(chan struct{}, 1)
cycleProceed := make(chan struct{})
cycle := func() {
n := cycleCount.Add(1)
if n == 1 {
cycleStarted <- struct{}{}
<-cycleProceed
}
}
// Kick the first cycle via coalesceRestart (this is what RestartByID
// would do post-debounce-check).
done := make(chan struct{})
go func() {
coalesceRestart(wsID, cycle)
close(done)
}()
<-cycleStarted
// Simulate the 4 probe-driven RestartByID calls observed in prod.
// Each must drop because we're within the debounce window AND a
// cycle is in flight.
h := newSelfFireHandler(t)
stub := &preflightLocalProv{running: true, err: nil}
h.provisioner = stub
for i := 0; i < 4; i++ {
h.RestartByID(wsID)
}
// Release the cycle.
close(cycleProceed)
<-done
if got := cycleCount.Load(); got != 1 {
t.Errorf("expected exactly 1 provision cycle for a single trigger "+
"(self-fire fix), got %d — regression of the prod 4x reprov thrash class",
got)
}
if got := restartByIDDropCounter.Load(); got != 4 {
t.Errorf("expected 4 self-fire probes dropped, got %d "+
"(observability counter must record the saved cycles)", got)
}
}
// newSelfFireHandler constructs a minimal *WorkspaceHandler suitable for
// the Layer-1 gate tests. Wraps the boilerplate so the per-test setup
// stays focused on the assertion.
func newSelfFireHandler(t *testing.T) *WorkspaceHandler {
t.Helper()
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
}
@@ -0,0 +1,70 @@
-- Reverse of 20260518000000_seed_production_team_agent_cards.up.sql.
--
-- Clears the identity fields back to the gap state that the up
-- migration was designed to fix. After this down migration, the PR
-- #1427 reconcile has nothing to substitute again: name reverts to the
-- workspace UUID (the runtime's fallback), role to NULL, agent_card
-- description/skills to empty. This is the pre-#1427 + pre-this-seed
-- behaviour.
--
-- Match strategy mirrors the up migration (id::text LIKE prefix for 5,
-- exact UUID for CEO-Assistant) so any down-roll touches the exact
-- same rows.
BEGIN;
-- PM
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id::text LIKE '8a71d4d4-%';
-- Reviewer
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id::text LIKE '27e66b5a-%';
-- Researcher
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id::text LIKE '5773bd5f-%';
-- Dev-A
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id::text LIKE '4ca4c06c-%';
-- Dev-B
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id::text LIKE '31eb65ed-%';
-- CEO-Assistant
UPDATE workspaces
SET name = id::text,
role = NULL,
agent_card = (agent_card - 'description' - 'skills' - 'role') ||
jsonb_build_object('name', id::text),
updated_at = now()
WHERE id = '30ba7f0b-b303-4a20-aefe-3a4a675b8aa4'::uuid;
COMMIT;
@@ -0,0 +1,165 @@
-- Seed identity (name + role + agent_card description/skills) for the
-- 6 production-team workspaces. Pairs with the PR #1427 server-side
-- reconcile (internal#492): #1427 added the platform-side backfill that
-- pulls workspaces.name and workspaces.role into the stored agent_card
-- on /registry/register; this migration populates the trusted DB row
-- those reads consume.
--
-- Without this seed, the reconcile has nothing to substitute and the
-- card stays at name=UUID / description="" / role=null for the prod
-- team agents — the exact gap internal#492 is filed against.
--
-- Identity stays platform-controlled — the agent runtime cannot
-- self-write these fields. The 6 workspace UUIDs are the CTO-locked
-- production-team topology (see project_production_agent_team_topology):
--
-- PM 8a71d4d4... — Claude Code on Opus, read-only,
-- A2A-delegate-only coordinator
-- Reviewer 27e66b5a... — codex on openai-subscription,
-- 5-axis non-author review
-- Researcher 5773bd5f... — codex on openai-subscription,
-- root-cause investigation
-- Dev-A 4ca4c06c... — Claude Code on Kimi K2.6
-- (api.kimi.com/coding base + ANTHROPIC_API_KEY)
-- Dev-B 31eb65ed... — Claude Code on MiniMax
-- (api.minimax.io/anthropic base + sk-cp-* key)
-- CEO-Assistant 30ba7f0b-b303-4a20-aefe-3a4a675b8aa4 — Claude Code,
-- orchestrator-side operations + canvas relay
--
-- Match strategy: 5 of 6 production UUIDs were provided to me by the CTO
-- as 8-char prefixes only (the full UUIDs live in the prod tenant DB).
-- We match those 5 with `id::text LIKE '<prefix>-%'` so this migration
-- is unambiguous when reviewed without DB access — the CTO will confirm
-- on review that each prefix resolves to a single row. CEO-Assistant
-- (30ba7f0b-b303-4a20-aefe-3a4a675b8aa4) is known in full from
-- chat_files_test.go and is matched exactly.
--
-- Idempotent: each UPDATE only touches the three identity fields. Re-
-- running rewrites the same values. UUIDs not present in a given tenant
-- DB match zero rows and are silently skipped — the migration never
-- INSERTs rows it doesn't own.
--
-- All names obey validateWorkspaceFields (workspace_crud.go:526):
-- <=255 chars, no newline/CR, no YAML-special chars `{}[]|>*&!`.
-- All roles obey the same contract <=1000 chars. Per-skill description
-- <=120 chars matches the discovery card surface shown on the canvas
-- Agent Card view and the mobile peer chip.
BEGIN;
-- PM — read-only A2A coordinator
UPDATE workspaces
SET name = 'Production Manager',
role = 'product manager',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'Production Manager',
'description', 'Read-only A2A coordinator that plans work and delegates to Dev/Reviewer/Researcher peers; never writes code itself.',
'role', 'product manager',
'skills', jsonb_build_array(
jsonb_build_object('id','planning','name','planning','description','Decompose CTO directives into peer-delegable units','tags',jsonb_build_array('planning'),'examples',jsonb_build_array()),
jsonb_build_object('id','delegation','name','delegation','description','Route work to Dev-A / Dev-B / Reviewer / Researcher via A2A','tags',jsonb_build_array('delegation'),'examples',jsonb_build_array()),
jsonb_build_object('id','coordination','name','coordination','description','Track peer activity and surface blockers back to the CTO','tags',jsonb_build_array('coordination'),'examples',jsonb_build_array()),
jsonb_build_object('id','read-only','name','read-only','description','Never edits code or merges; proposes only','tags',jsonb_build_array('read-only','safety'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id::text LIKE '8a71d4d4-%';
-- Reviewer — codex/openai, 5-axis non-author review
UPDATE workspaces
SET name = 'Code Reviewer',
role = 'code reviewer',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'Code Reviewer',
'description', 'Non-author 5-axis review on codex/openai-subscription; runs the merge gate, never approves PRs it authored.',
'role', 'code reviewer',
'skills', jsonb_build_array(
jsonb_build_object('id','code-review','name','code-review','description','Five-axis PR review against the merge gate','tags',jsonb_build_array('review'),'examples',jsonb_build_array()),
jsonb_build_object('id','security-axis','name','security-axis','description','Trust-boundary, secret-handling, injection surface checks','tags',jsonb_build_array('security'),'examples',jsonb_build_array()),
jsonb_build_object('id','correctness-axis','name','correctness-axis','description','Logic, error-handling, race and boundary case checks','tags',jsonb_build_array('correctness'),'examples',jsonb_build_array()),
jsonb_build_object('id','non-author-approve','name','non-author-approve','description','Approves only PRs the reviewer did not author','tags',jsonb_build_array('two-eyes'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id::text LIKE '27e66b5a-%';
-- Researcher — codex/openai, root-cause investigation
UPDATE workspaces
SET name = 'Root-Cause Researcher',
role = 'researcher',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'Root-Cause Researcher',
'description', 'Diagnostic investigation on codex/openai-subscription; obs-first, source-as-corroboration, no drive-by fixes.',
'role', 'researcher',
'skills', jsonb_build_array(
jsonb_build_object('id','root-cause','name','root-cause','description','Diagnose the underlying cause, never patch symptoms','tags',jsonb_build_array('investigation'),'examples',jsonb_build_array()),
jsonb_build_object('id','obs-first','name','obs-first','description','Grafana/Loki query before source-guessing','tags',jsonb_build_array('observability'),'examples',jsonb_build_array()),
jsonb_build_object('id','log-correlation','name','log-correlation','description','Cross-service step= / Delegation uuid tracing','tags',jsonb_build_array('observability'),'examples',jsonb_build_array()),
jsonb_build_object('id','source-archaeology','name','source-archaeology','description','Git blame and prior-art recall across repos','tags',jsonb_build_array('git'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id::text LIKE '5773bd5f-%';
-- Dev-A — Claude Code on Kimi K2.6
UPDATE workspaces
SET name = 'Dev Engineer A (Kimi)',
role = 'dev engineer',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'Dev Engineer A (Kimi)',
'description', 'Claude Code routed to Kimi K2.6 via api.kimi.com/coding; implements PRs against the dev-tree protected branches.',
'role', 'dev engineer',
'skills', jsonb_build_array(
jsonb_build_object('id','implementation','name','implementation','description','Write code to merge gate (tests, lint, types)','tags',jsonb_build_array('coding'),'examples',jsonb_build_array()),
jsonb_build_object('id','test-driven','name','test-driven','description','Failing test first, then minimal fix','tags',jsonb_build_array('tdd'),'examples',jsonb_build_array()),
jsonb_build_object('id','bug-fixing','name','bug-fixing','description','Root-caused bug fixes with regression test','tags',jsonb_build_array('debugging'),'examples',jsonb_build_array()),
jsonb_build_object('id','refactoring','name','refactoring','description','In-scope, behavior-preserving refactors only','tags',jsonb_build_array('refactor'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id::text LIKE '4ca4c06c-%';
-- Dev-B — Claude Code on MiniMax
UPDATE workspaces
SET name = 'Dev Engineer B (MiniMax)',
role = 'dev engineer',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'Dev Engineer B (MiniMax)',
'description', 'Claude Code routed to MiniMax via api.minimax.io/anthropic; parallel dev capacity to Dev-A on the same gate.',
'role', 'dev engineer',
'skills', jsonb_build_array(
jsonb_build_object('id','implementation','name','implementation','description','Write code to merge gate (tests, lint, types)','tags',jsonb_build_array('coding'),'examples',jsonb_build_array()),
jsonb_build_object('id','test-driven','name','test-driven','description','Failing test first, then minimal fix','tags',jsonb_build_array('tdd'),'examples',jsonb_build_array()),
jsonb_build_object('id','bug-fixing','name','bug-fixing','description','Root-caused bug fixes with regression test','tags',jsonb_build_array('debugging'),'examples',jsonb_build_array()),
jsonb_build_object('id','refactoring','name','refactoring','description','In-scope, behavior-preserving refactors only','tags',jsonb_build_array('refactor'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id::text LIKE '31eb65ed-%';
-- CEO-Assistant — Claude Code, orchestrator + canvas relay
-- Full UUID known from chat_files_test.go:286 — match exactly.
UPDATE workspaces
SET name = 'CEO Assistant',
role = 'operator orchestrator',
agent_card = COALESCE(agent_card, '{}'::jsonb) || jsonb_build_object(
'name', 'CEO Assistant',
'description', 'Orchestrator-side Claude Code that runs the triage loop, relays canvas and Telegram, dispatches non-author reviewers.',
'role', 'operator orchestrator',
'skills', jsonb_build_array(
jsonb_build_object('id','triage-loop','name','triage-loop','description','Run the CI/PR triage loop; fix-what-you-find','tags',jsonb_build_array('orchestration'),'examples',jsonb_build_array()),
jsonb_build_object('id','review-routing','name','review-routing','description','Dispatch non-author reviewers via delegate_task','tags',jsonb_build_array('routing'),'examples',jsonb_build_array()),
jsonb_build_object('id','canvas-relay','name','canvas-relay','description','Relay CTO canvas/Telegram messages to peers','tags',jsonb_build_array('relay'),'examples',jsonb_build_array()),
jsonb_build_object('id','ops','name','ops','description','Direct hands-on ops on operator host and Neon','tags',jsonb_build_array('ops','direct-action'),'examples',jsonb_build_array())
),
'updated_at', now()::text
),
updated_at = now()
WHERE id = '30ba7f0b-b303-4a20-aefe-3a4a675b8aa4'::uuid;
COMMIT;
@@ -0,0 +1,21 @@
-- Reverse of 20260519000000_workspace_secrets_model_provider_rename.up.sql.
--
-- This rolls MODEL → MODEL_PROVIDER. Note: the up migration deleted any
-- conflicting MODEL_PROVIDER rows when a MODEL row already existed, so
-- this down migration is intentionally lossy in that direction — it
-- cannot reconstruct rows the up migration discarded. Acceptable
-- because:
--
-- 1. The discarded rows were duplicates with the same workspace_id;
-- the surviving MODEL row carries the correct semantic value.
-- 2. The application code post-rename never writes MODEL_PROVIDER, so
-- any rollback after live traffic would produce duplicate-key
-- conflicts on re-up anyway — discarding here is the only sane
-- shape.
--
-- Provided for migration-tool symmetry; in practice the up direction is
-- the canonical fix and rollback should not happen.
UPDATE workspace_secrets
SET key = 'MODEL_PROVIDER', updated_at = now()
WHERE key = 'MODEL';
@@ -0,0 +1,36 @@
-- Rename workspace_secrets rows MODEL_PROVIDER → MODEL.
--
-- Root cause: the column-name MODEL_PROVIDER was misleading — it never
-- held a provider slug, only a picked model id (e.g.
-- "minimax/MiniMax-M2.7"). Application code (workspace-server
-- applyRuntimeModelEnv) read MODEL_PROVIDER as a fallback that could
-- overwrite a legitimate MODEL persona-env secret with whatever literal
-- string lived in MODEL_PROVIDER — often a provider slug like "minimax"
-- or a runtime name like "claude-code", neither of which is a valid
-- model id. The wrong shape then propagated into CP user-data and the
-- workspace adapter wedged at SDK initialize (see failed-workspace
-- 95ed3ff2 2026-05-02 and the Researcher/Reviewer poisoning 2026-05-19).
--
-- Pairs with the secrets.go + workspace_provision.go rename in this
-- PR (fix/workspace-server-rename-MODEL_PROVIDER-to-MODEL) and the
-- CP-side slot-separation already landed in cp#213 + cp#220.
--
-- Conflict handling: a workspace_secrets row already keyed MODEL takes
-- precedence (persona-env files commonly write MODEL=... directly), so
-- the MODEL_PROVIDER row is deleted instead of overwriting MODEL. The
-- WHERE NOT EXISTS guard makes the migration idempotent — re-running
-- it on an already-renamed schema is a no-op.
UPDATE workspace_secrets
SET key = 'MODEL', updated_at = now()
WHERE key = 'MODEL_PROVIDER'
AND NOT EXISTS (
SELECT 1 FROM workspace_secrets ws2
WHERE ws2.workspace_id = workspace_secrets.workspace_id
AND ws2.key = 'MODEL'
);
-- Drop any leftover MODEL_PROVIDER rows where a MODEL row already
-- exists (MODEL wins — see above).
DELETE FROM workspace_secrets
WHERE key = 'MODEL_PROVIDER';
+1
View File
@@ -0,0 +1 @@
# trigger autobump for python-multipart pin (PDF P0 cure)
+22
View File
@@ -34,6 +34,28 @@ async def list_peers() -> list[dict]:
async def delegate_task(workspace_id: str, task: str) -> str:
"""Send a task to a peer workspace via A2A and return the response text."""
# Task #190 / #193 — Self-delegation guard. Without this, a workspace
# delegating to its own UUID round-trips through the platform proxy back
# into the sender; the synchronous handler waits on the same lock the
# caller holds, the request times out, and the platform writes an
# a2a_receive activity row with source_id=our own workspace UUID. The
# inbox poller then surfaces that row as kind="peer_agent" and the agent
# sees the timeout echoed back as a peer instructing it (#190).
#
# The sibling guards live in:
# - workspace-server/internal/handlers/delegation.go (Go API gate)
# - workspace/a2a_tools_delegation.py (MCP path guard)
# This module is the framework-agnostic adapter surface used by adapters
# that don't go through a2a_tools_delegation.py — it needs its own guard.
if WORKSPACE_ID and workspace_id == WORKSPACE_ID:
return (
"Error: self-delegation rejected (cannot delegate_task to your own "
"workspace). There is no peer who is also you — the platform proxy "
"would deadlock and the timeout would echo back as a peer_agent "
"message from yourself (#190). Do the work directly, or use "
"commit_memory / send_message_to_user instead."
)
async with httpx.AsyncClient(timeout=120.0) as client:
# Discover target URL
try:
+22
View File
@@ -412,6 +412,28 @@ async def delegate_task_async(
"""
task_id = str(uuid.uuid4())
# Task #190 / #193 — Self-delegation guard (async path). Even on the
# async path that returns a task_id immediately, _execute_delegation
# eventually fires the A2A POST back to our own URL, which times out
# against our own held run lock, gets recorded with source_id=our
# workspace UUID, and surfaces in the inbox as a peer_agent message
# from ourselves (#190). Reject before scheduling the background task
# so no peer_agent echo can be generated. Sibling guards:
# - workspace-server/internal/handlers/delegation.go (Go API gate)
# - workspace/a2a_tools_delegation.py (MCP sync + async paths)
# - workspace/builtin_tools/a2a_tools.py (framework-agnostic sync)
if WORKSPACE_ID and workspace_id == WORKSPACE_ID:
log_event(event_type="delegation", action="delegate", resource=workspace_id,
outcome="rejected_self_delegation", trace_id=task_id)
return {
"success": False,
"error": (
"self-delegation rejected: cannot delegate_task_async to your "
"own workspace (would time out and echo back as a peer_agent "
"message from yourself — #190)"
),
}
# RBAC check
roles, custom_perms = get_workspace_roles()
if not check_permission("delegate", roles, custom_perms):
+53
View File
@@ -9,6 +9,59 @@
# Pattern matches the legacy monorepo workspace/entrypoint.sh:
# fix volume ownership as root, then re-exec via gosu as agent (uid 1000).
# --- RFC#523 Layer 2: tenant-workspace forbidden-env guard (task #146) ---
# Defense-in-depth. The provisioner (workspace-server) has a fail-closed
# abort at provision time (Layer 1, prepareProvisionContext), and the
# in-container env-build has a silent strip (forensic #145,
# provisioner.buildContainerEnv). This guard fires if either upstream
# layer is bypassed — e.g. someone runs this image standalone with
# `docker run -e GITEA_TOKEN=...`. Exit 1 with a clear message instead
# of running with an operator-scope credential in tenant scope.
#
# Key names are generic. The MOLECULE_OPERATOR_ prefix is the one
# molecule-AI-specific literal; this entrypoint lives inside the
# claude-code template that is internal-only (memory
# `feedback_open_source_templates_no_hardcoded_org_internals` — claude-
# code template is internal, separate-published templates must NOT carry
# org-specific literals). A fork can edit FORBIDDEN_KEYS /
# FORBIDDEN_PREFIXES for its own operator-scope names without touching
# the rest of the entrypoint.
#
# Skipped when MOLECULE_TENANT_GUARD_DISABLE=1 — for local-dev where the
# operator host IS the tenant host (e.g. running molecule-runtime on the
# operator box for debugging). NEVER set this in tenant containers.
if [ "${MOLECULE_TENANT_GUARD_DISABLE:-0}" != "1" ]; then
FORBIDDEN_KEYS="GITEA_TOKEN GITEA_PAT GITHUB_TOKEN GITHUB_PAT GH_TOKEN GITLAB_TOKEN GL_TOKEN BITBUCKET_TOKEN CP_ADMIN_API_TOKEN CP_ADMIN_TOKEN INFISICAL_OPERATOR_TOKEN INFISICAL_BOOTSTRAP_TOKEN RAILWAY_TOKEN RAILWAY_PERSONAL_API_TOKEN HETZNER_TOKEN HETZNER_API_TOKEN"
FORBIDDEN_PREFIXES="MOLECULE_OPERATOR_"
FOUND=""
for k in $FORBIDDEN_KEYS; do
# eval is safe here — $k is from a static whitespace-separated
# literal list above (no user input). POSIX sh has no
# associative arrays, hence the indirect-expansion via eval to
# test "is this var set" without caring about its value.
eval "v=\${$k+set}"
if [ "$v" = "set" ]; then
FOUND="$FOUND $k"
fi
done
for prefix in $FORBIDDEN_PREFIXES; do
# env | awk is the portable POSIX way to enumerate by prefix.
# busybox awk (alpine), gawk (debian), and BSD awk (macOS-test)
# all support index(). Doesn't depend on bash arrays / [[ =~ ]].
prefix_hits=$(env | awk -F= -v p="$prefix" 'index($1, p)==1 {print $1}')
if [ -n "$prefix_hits" ]; then
FOUND="$FOUND $prefix_hits"
fi
done
if [ -n "$FOUND" ]; then
echo "RFC#523 Layer 2: refusing to start tenant workspace — forbidden operator-scope env var(s) present:$FOUND" >&2
echo "These vars are operator-fleet scope and must not reach tenant workspaces." >&2
echo "Remove them from workspace_secrets / global_secrets / docker -e and retry." >&2
echo "If running this image standalone for local dev with intentional operator scope, set MOLECULE_TENANT_GUARD_DISABLE=1." >&2
exit 1
fi
fi
if [ "$(id -u)" = "0" ]; then
# Configs volume is created by Docker as root; agent needs write access
# for plugin installs, memory writes, .auth_token rotation, etc.
+24 -1
View File
@@ -102,11 +102,34 @@ class InboxMessage:
arrival_workspace_id: str = ""
def to_dict(self) -> dict[str, Any]:
# Task #190 / #193 — Distinguish delegation-result rows from peer-agent
# messages. The platform's pushDelegationResultToInbox (RFC #2829 PR-2)
# writes activity_type='a2a_receive' with method='delegate_result' and
# source_id=our own workspace UUID, so the caller's inbox poller can
# surface delegation completions/failures via wait_for_message. But
# the default to_dict derives kind="peer_agent" purely from peer_id
# being non-empty — which makes a synchronous-delegation timeout, or
# a cross-workspace ProxyA2A failure, appear to the agent as a NEW
# peer_agent message from our own workspace UUID (#190 self-echo).
#
# Explicitly classify rows with method='delegate_result' as
# kind='delegation_result' regardless of peer_id, so:
# 1. wait_for_message gives the original caller a structured
# delegation result (not a fake peer instruction).
# 2. Agents reading the envelope don't mistake the row for a
# peer instructing them — preventing the #190 reply-via-
# delegate_task-to-self loop.
if self.method == "delegate_result":
kind = "delegation_result"
elif self.peer_id:
kind = "peer_agent"
else:
kind = "canvas_user"
d = {
"activity_id": self.activity_id,
"text": self.text,
"peer_id": self.peer_id,
"kind": "peer_agent" if self.peer_id else "canvas_user",
"kind": kind,
"method": self.method,
"created_at": self.created_at,
}
+37 -8
View File
@@ -27,8 +27,8 @@ Path safety:
collisions astronomical, but defense-in-depth costs nothing).
Limits (matches the Go contract from chat_files.go):
- 50 MB total request body
- 25 MB per file
- 100 MB total request body
- 100 MB per file
- filename truncated to 100 chars
Response shape:
@@ -64,11 +64,20 @@ CHAT_UPLOAD_DIR = "/workspace/.molecule/chat-uploads"
# Total-request body cap. multipart/form-data with multiple parts can
# add ~100 bytes of framing per file; the cap is the bytes hitting the
# socket, including framing.
CHAT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
#
# SERVER_MIRROR: keep aligned with workspace-server/internal/handlers/
# chat_files.go chatUploadMaxBytes AND canvas/src/components/tabs/chat/
# uploads.ts MAX_UPLOAD_BYTES. Three constants exist (platform Go +
# workspace Python + canvas TS) because each layer must enforce or
# pre-flight the cap on its own; an SSOT follow-up tracked in
# molecule-ai/internal would expose the cap via GET /uploads/limits.
CHAT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024 # 100 MB
# Per-file cap. Keeping per-file under total lets a user attach, say,
# a 5 MB PDF + 10 small screenshots in a single batch.
CHAT_UPLOAD_MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB
# Per-file cap. Aligned with the total at 100 MB so a single legitimate
# large file (e.g. a 70 MB PDF — reno-stars 2026-05-19 forensic
# a99ab0a1) succeeds end-to-end; batched small attachments still fit
# under the same ceiling.
CHAT_UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB
# Conservative {alnum, dot, underscore, dash} character class — anything
# outside gets rewritten so embedded paths, control chars, newlines,
@@ -149,8 +158,28 @@ async def ingest_handler(request: Request) -> JSONResponse:
try:
form = await request.form(max_files=64, max_fields=32)
except Exception as exc: # multipart parse error
logger.warning("internal_chat_uploads: multipart parse failed: %s", exc)
return JSONResponse({"error": "failed to parse multipart form"}, status_code=400)
# Surface exc.class + str(exc) to the caller. Prior behavior returned
# only the opaque {"error": "failed to parse multipart form"}, which
# took ~25 min to root-cause in forensic a78762a0 (Hermes workspace
# PDF upload, 2026-05-19) — the underlying cause was a MISSING
# python-multipart dep, surfaced as an AssertionError from Starlette's
# parser. Surfacing exception class + detail in the 400 body would
# have cut that to ~10 min. Per feedback_surface_actionable_failure_
# reason_to_user (CTO 2026-05-17): user-facing failures MUST tell the
# user WHY. Top-level "error" key is preserved for backwards-compat
# with existing canvas / alert rules.
logger.warning(
"internal_chat_uploads: multipart parse failed: %s: %s",
type(exc).__name__, exc,
)
return JSONResponse(
{
"error": "failed to parse multipart form",
"exception": type(exc).__name__,
"detail": str(exc),
},
status_code=400,
)
# Starlette's FormData allows multiple values per key — `files` may
# appear multiple times for batched uploads. getlist returns them

Some files were not shown because too many files have changed in this diff Show More