Compare commits

...

49 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
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
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
88 changed files with 5799 additions and 420 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
@@ -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
+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
+8 -7
View File
@@ -69,7 +69,7 @@ or other removed paths — open against `molecule-ai/docs` instead.
| 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 |
| 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) |
@@ -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.
@@ -192,7 +192,7 @@ 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 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 --channels plugin:molecule@molecule-channel`.
- [`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 https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git``/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-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 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` 启动
- 通过标准 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
+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)",
@@ -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,6 +125,36 @@ 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)
@@ -675,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)
@@ -699,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.
@@ -742,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) {
@@ -792,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)
@@ -807,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))
@@ -836,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)
}
}
@@ -897,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)",
@@ -911,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",
@@ -947,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
@@ -967,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",
@@ -979,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",
@@ -1009,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)
+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.
+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
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Smoke-test for RFC#523 Layer 2 (task #146): the workspace/entrypoint.sh
# top-of-file forbidden-env guard.
#
# Strategy: source the prefix of entrypoint.sh that contains the guard
# (up through the closing `fi` of the guard block), in a sub-shell with
# the env we want to test. We rewrite the `exit 1` to a `return 1` so
# the guard signals failure via the sub-shell's exit code without
# killing the test harness.
#
# Why not docker-run the actual image: the test is unit-scope (does
# the guard logic correctly identify forbidden vs allowed env). Image
# integration is covered by the E2E provision test described in
# RFC#523 §"Acceptance criteria" Layer 2 (run on staging, not here).
#
# Pairs with: workspace_provision_forbidden_env_test.go (Layer 1
# Go-side unit tests).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENTRYPOINT="$HERE/../entrypoint.sh"
if [[ ! -f "$ENTRYPOINT" ]]; then
echo "FAIL: entrypoint not found: $ENTRYPOINT" >&2
exit 1
fi
# Extract just the guard block (from the first `if [ "${MOLECULE_TENANT_GUARD_DISABLE`
# through the matching `fi`) and rewrite `exit 1` to `return 1` so the
# guard can be invoked inside a function in a sub-shell.
GUARD_SNIPPET=$(awk '
/^if \[ "\${MOLECULE_TENANT_GUARD_DISABLE/ { inblock=1 }
inblock { print }
inblock && /^fi$/ { exit }
' "$ENTRYPOINT" | sed 's/exit 1/return 1/')
if [[ -z "$GUARD_SNIPPET" ]]; then
echo "FAIL: could not extract guard block from $ENTRYPOINT" >&2
exit 1
fi
# Helper: run the guard with the env we set, capture exit code. The
# sub-shell starts with `env -i` semantics emulated by `unset` of every
# var the guard checks, so prior shell state doesn't contaminate.
run_guard() {
# Pass extra-env assignments as args; e.g. run_guard GITEA_TOKEN=x.
(
set +e
# Defensive unset of all keys the guard inspects, so the
# caller's args are the ONLY positive cases.
unset GITEA_TOKEN GITEA_PAT GITHUB_TOKEN GITHUB_PAT GH_TOKEN GITLAB_TOKEN GL_TOKEN BITBUCKET_TOKEN
unset CP_ADMIN_API_TOKEN CP_ADMIN_TOKEN
unset INFISICAL_OPERATOR_TOKEN INFISICAL_BOOTSTRAP_TOKEN
unset RAILWAY_TOKEN RAILWAY_PERSONAL_API_TOKEN HETZNER_TOKEN HETZNER_API_TOKEN
unset MOLECULE_OPERATOR_HOST MOLECULE_OPERATOR_SSH_KEY
unset MOLECULE_TENANT_GUARD_DISABLE
for kv in "$@"; do
export "$kv"
done
guard_fn() {
eval "$GUARD_SNIPPET"
}
guard_fn
echo $?
)
}
PASS=0
FAIL=0
assert_exit() {
local label="$1"
local want="$2"
shift 2
local got
got=$(run_guard "$@" | tail -n 1)
if [[ "$got" == "$want" ]]; then
echo "PASS: $label"
PASS=$((PASS + 1))
else
echo "FAIL: $label — want exit=$want got=$got (env: $*)" >&2
FAIL=$((FAIL + 1))
fi
}
# --- Case 1: clean env passes (exit 0) ---
assert_exit "clean_env_passes" 0
# --- Case 2: per-agent-scope vars pass (exit 0) ---
assert_exit "per_agent_vars_pass" 0 \
GIT_HTTP_USERNAME=agent-dev-a \
GIT_HTTP_PASSWORD=scoped-pat \
ANTHROPIC_API_KEY=sk-keep \
MOLECULE_AGENT_ROLE=agent-dev-a
# --- Case 3: forbidden exact-match keys fail (exit 1) ---
assert_exit "gitea_token_blocks" 1 GITEA_TOKEN=leak
assert_exit "github_token_blocks" 1 GITHUB_TOKEN=leak
assert_exit "cp_admin_api_token_blocks" 1 CP_ADMIN_API_TOKEN=leak
assert_exit "infisical_operator_blocks" 1 INFISICAL_OPERATOR_TOKEN=leak
assert_exit "railway_token_blocks" 1 RAILWAY_TOKEN=leak
# --- Case 4: MOLECULE_OPERATOR_ prefix family blocks ---
assert_exit "molecule_operator_host_blocks" 1 MOLECULE_OPERATOR_HOST=op.example.com
assert_exit "molecule_operator_ssh_blocks" 1 MOLECULE_OPERATOR_SSH_KEY=ssh-ed25519...
# --- Case 5: adjacent-but-allowed MOLECULE_* names pass ---
assert_exit "molecule_agent_role_passes" 0 MOLECULE_AGENT_ROLE=agent-dev-a
assert_exit "molecule_url_passes" 0 MOLECULE_URL=https://platform.example.com
# --- Case 6: MOLECULE_TENANT_GUARD_DISABLE=1 bypasses the guard ---
assert_exit "disable_flag_bypasses" 0 \
MOLECULE_TENANT_GUARD_DISABLE=1 \
GITEA_TOKEN=leak \
CP_ADMIN_API_TOKEN=leak
echo
echo "=== L2 entrypoint guard: $PASS passed, $FAIL failed ==="
if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi
+44 -1
View File
@@ -210,7 +210,7 @@ def test_no_files_field_returns_400(client: TestClient):
def test_per_file_oversize_returns_413(client: TestClient, monkeypatch: pytest.MonkeyPatch):
"""Per-file cap is enforced. Lower the cap for the test so we don't
have to construct a real 25 MB body."""
have to construct a real 100 MB body."""
monkeypatch.setattr(internal_chat_uploads, "CHAT_UPLOAD_MAX_FILE_BYTES", 16)
big = b"x" * 32 # > 16
r = client.post(
@@ -299,3 +299,46 @@ def test_symlink_at_target_is_refused(client: TestClient, chat_uploads_dir: Path
assert r.status_code == 500, r.text
# Sentinel content unchanged — the symlink wasn't followed.
assert sentinel.read_bytes() == b"original"
# Pins the diagnostic shape of the 400 returned when multipart parsing
# fails. Prior to forensic a78762a0 (Hermes workspace PDF upload 2026-05-19),
# the response was {"error": "failed to parse multipart form"} only — opaque
# to the caller, requiring ~25 min of triage to root-cause a missing
# python-multipart dep. Surfacing exception class + str(exc) makes the
# failure self-diagnosing (would've shortened that to ~10 min). Per
# feedback_surface_actionable_failure_reason_to_user (CTO 2026-05-17):
# user-facing failures MUST tell the user WHY.
def test_malformed_multipart_returns_exception_class_and_detail(
client: TestClient,
):
"""Send a multipart-shaped body whose boundary in the header does
NOT match the boundary in the body Starlette's parser raises a
MultiPartException, which our handler must surface as exception
class + detail in the 400 JSON response.
"""
# Header claims boundary "outer" but body uses "different".
bad_body = (
b"--different\r\n"
b'Content-Disposition: form-data; name="files"; filename="a.txt"\r\n'
b"Content-Type: text/plain\r\n\r\n"
b"hello\r\n"
b"--different--\r\n"
)
r = client.post(
"/internal/chat/uploads/ingest",
data=bad_body,
headers={
"Authorization": "Bearer test-secret",
"Content-Type": "multipart/form-data; boundary=outer",
},
)
assert r.status_code == 400, r.text
body = r.json()
# Backwards-compatible top-level error keeps existing canvas /
# alert rules matching.
assert body.get("error") == "failed to parse multipart form"
# New diagnostic fields — caller can now see the exception class +
# detail without SSM access to the workspace stderr.
assert "exception" in body and isinstance(body["exception"], str) and body["exception"]
assert "detail" in body and isinstance(body["detail"], str)