Compare commits

...

37 Commits

Author SHA1 Message Date
hongming 567937e2bc Merge pull request 'fix(canvas): extend mc#1535 per-workspace MCP slug to codex/openclaw/hermes/kimi (multi-workspace class sweep)' (#1536) from fix/multi-workspace-install-snippets-class-sweep into main
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
CI / Canvas Deploy Reminder (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
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
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 1m11s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
publish-runtime-autobump / pr-validate (push) Successful in 37s
publish-runtime-autobump / bump-and-tag (push) Successful in 34s
CI / Platform (Go) (push) Successful in 2m48s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 57s
publish-workspace-server-image / build-and-push (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
CI / all-required (push) Has been cancelled
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m14s
status-reaper / reap (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
gitea-merge-queue / queue (push) Successful in 9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m27s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m45s
2026-05-19 00:28:47 +00:00
hongming 80a5f51c27 Merge pull request 'fix(a2a-mcp): use readline() not read(65536) for pipe-safe stdio (openclaw peer-visibility root cause)' (#1307) from fix/a2a-mcp-stdio-pipe-blocking-readline into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / 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 Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (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
publish-runtime-autobump / pr-validate (push) Waiting to run
publish-runtime-autobump / bump-and-tag (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
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m25s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m34s
2026-05-19 00:28:29 +00:00
core-devops cde433f2df ci: re-trigger after runner-pool zombie drain + ENOSPC remediation
cascade-list-drift-gate / check (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
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 12s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 43s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 46s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 47s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 34s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
review-check-tests / review-check.sh regression tests (pull_request) Successful in 6s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m16s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m32s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
qa-review / approved (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 10s
publish-runtime-autobump / pr-validate (pull_request) Successful in 27s
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 7s
security-review / approved (pull_request) Failing after 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m22s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 25s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m35s
CI / Canvas (Next.js) (pull_request) Successful in 4m27s
CI / Platform (Go) (pull_request) Successful in 4m45s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m17s
CI / Python Lint & Test (pull_request) Successful in 6m49s
CI / all-required (pull_request) Successful in 6m50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m20s
CI / Canvas Deploy Reminder (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 16m33s
audit-force-merge / audit (pull_request) Successful in 6s
Prior CI failures on this PR were infra-class (Detect changes hit
'Error: ENOSPC: no space left on device' from runner disk-full caused
by 120 zombie tasks since drained; Python Lint flaked on perf test
test_batch_fetcher_runs_submitted_rows_concurrently by 3ms under
contended runners — same test passes cleanly on main HEAD 1b0e947).
Re-firing CI on recovered runners; no code change. [no-op]
2026-05-18 17:09:37 -07:00
devops-engineer 1b0e947bdd Merge pull request 'feat(provisioner): uniform T4 privilege contract + YAML emitter' (#1531) from feat/t4-privilege-contract-uniform into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 16s
E2E Chat / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 9s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 48s
E2E Chat / E2E Chat (push) Failing after 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
Harness Replays / Harness Replays (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 30s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m2s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 4m51s
publish-workspace-server-image / build-and-push (push) Successful in 5m52s
CI / Platform (Go) (push) Successful in 6m23s
CI / Python Lint & Test (push) Successful in 7m7s
CI / Canvas (Next.js) (push) Successful in 7m24s
CI / Canvas Deploy Reminder (push) Successful in 5s
CI / all-required (push) Successful in 7m43s
publish-workspace-server-image / Production auto-deploy (push) Successful in 1m51s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m35s
main-red-watchdog / watchdog (push) Successful in 31s
gate-check-v3 / gate-check (push) Successful in 59s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 38s
gitea-merge-queue / queue (push) Successful in 7s
status-reaper / reap (push) Successful in 47s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 8m0s
2026-05-18 23:20:52 +00:00
devops-engineer ebf88a469f Merge pull request 'fix(canvas/socket): wake WebSocket on visibilitychange / pageshow (#223 / #228)' (#1530) from fix/canvas-ws-visibility-reconnect into main
CI / Platform (Go) (push) Waiting to run
CI / all-required (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 3m28s
2026-05-18 23:20:48 +00:00
devops-engineer bcc66ecdcf Merge pull request 'fix(mobile): bump focused-input font-size to 16px (kills iOS focus-zoom)' (#1528) from fix/mobile-ios-focus-zoom-inputs 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 / 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 / detect-changes (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 9s
CI / Detect changes (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Platform (Go) (push) Has been cancelled
CI / all-required (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
publish-canvas-image / Build & push canvas image (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
E2E API Smoke Test / detect-changes (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-18 23:20:30 +00:00
devops-engineer 06b0ec8f12 Merge pull request 'fix(canvas): make "Add to Claude Code" snippet use unique server name per workspace (multi-workspace)' (#1535) from fix/add-to-claude-code-unique-server-name-per-workspace into main
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
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) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
CI / all-required (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
CI / Platform (Go) (push) Has been cancelled
Harness Replays / detect-changes (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m13s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m22s
2026-05-18 23:20:17 +00:00
core-devops bb35c771f9 fix(canvas): extend mc#1535 per-workspace MCP slug across codex/openclaw/hermes/kimi/Kimi snippets + PyPI README
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Successful in 10s
publish-runtime-autobump / pr-validate (pull_request) Successful in 34s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
CI / all-required (pull_request) Compensating status: emitter dropped CI / all-required ctx on this commit (gitea 1.22.6 null-state). 2 non-author APPROVEs present, sop-checklist/sop-tier-check/gate-check-v3 all Successful per status descriptions. Posted per feedback_gitea_emitter_null_state_blocks_merge.
audit-force-merge / audit (pull_request) Successful in 5s
## Summary
- mc#1535 fixed the per-session-overwrite bug in the Universal MCP
  snippet (`claude mcp add molecule -s user` keyed by `molecule`, so
  installing for a second workspace silently replaced the first). The
  same equivalence-class bug exists in EVERY other runtime tab the
  Canvas modal renders: each MCP host keys its config by name, and all
  five templates hardcoded a fixed `molecule` identifier.
- This PR extends mc#1535's existing `{{MCP_SERVER_NAME}}` placeholder
  + `mcpServerNameForWorkspace()` helper into the 4 remaining
  templates so the Canvas snippet a user pastes is unique per
  workspace by construction across ALL runtime tabs — multi-workspace
  works out-of-the-box with no per-host workarounds.

## Bug shape per runtime tab (mc#1535 sibling)
- **codex** (`~/.codex/config.toml`): `[mcp_servers.molecule]` — TOML
  rejects duplicate table keys, so re-paste either breaks parsing or
  overwrites.
- **openclaw** (`~/.openclaw/mcp/molecule.json`): `openclaw mcp set
  molecule` keyed by name — second workspace overwrites.
- **hermes** (`~/.hermes/config.yaml`): `plugin_platforms.molecule:` —
  YAML rejects duplicate mapping keys, second workspace silently
  collapses.
- **kimi** (`~/.molecule-ai/kimi-workspace/`): single per-host dir —
  second workspace's env+bridge.py overwrites the first.

## What changed
- `workspace-server/internal/handlers/external_connection.go`:
  - 4 templates now stamp `{{MCP_SERVER_NAME}}` (the same slug
    mc#1535 already derives + plumbs into the universal_mcp snippet)
    in the keyed identifier:
    - codex: `[mcp_servers.{{MCP_SERVER_NAME}}]` + `.env` table.
    - openclaw: `openclaw mcp set {{MCP_SERVER_NAME}}` + log path.
    - hermes: `plugin_platforms.{{MCP_SERVER_NAME}}:`.
    - kimi: `~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/` dir + embedded
      python `ENV` path.
  - Header comment in each template documents the multi-workspace
    contract (mirrors mc#1535's universal_mcp header).
- `workspace-server/internal/handlers/external_rotate_test.go`:
  - New `TestBuildExternalConnectionPayload_AllRuntimeSnippetsAreWorkspaceUnique`
    pins the per-template literal that proves the slug was stamped,
    AND asserts no template leaves a literal `{{MCP_SERVER_NAME}}`
    placeholder — catches a future template author who forgets to
    register a new tab with the stamp pipeline.
- `workspace/a2a_mcp_server.py`:
  - Comment-only update on `serverInfo.name` to reflect that the
    per-host registration name is workspace-specific. No code change;
    `serverInfo.name` stays the generic `"molecule"` self-label.
- `scripts/build_runtime_package.py` (PyPI README generator):
  - Updates 3 `claude mcp add molecule -- molecule-mcp` references to
    `claude mcp add molecule-<workspace-slug> -- molecule-mcp` so the
    PyPI README matches the Canvas-stamped snippet pattern.
  - Adds a "Server name in `claude mcp add` is workspace-specific"
    bullet pointing at mc#1535 + this PR for context.

## Open-source-templates cleanliness check
- Templates touched here live in the PRIVATE molecule-core repo
  (Canvas modal generator); they STAMP per-workspace server names but
  do NOT bake any new `git.moleculesai.app` literal or other
  org-internal infra. Generic `pip install
  'git+https://git.moleculesai.app/molecule-ai/hermes-channel-molecule.git'`
  in the hermes template is the only such URL touched and was
  pre-existing — that one points at a public hermes-side plugin and
  has its own canonical URL; not in scope for the open-source-template
  rule (the rule applies to template-codex/template-hermes/
  template-openclaw — separate public repos, untouched here).
- No `.moleculesai.app` literal added; persona-token shape unchanged
  (auth_token still per-workspace minted by Rotate/Create — same path
  mc#1535 audited).

## Sample stamped snippets (workspace name "my-bot", slug "molecule-my-bot")
- codex:    `[mcp_servers.molecule-my-bot]` + `[mcp_servers.molecule-my-bot.env]`
- openclaw: `openclaw mcp set molecule-my-bot "$(cat <<EOF ... )"`
- hermes:   `plugin_platforms:\n  molecule-my-bot:\n    enabled: true`
- kimi:     `~/.molecule-ai/kimi-molecule-my-bot/{env,kimi_bridge.py}`

## Diff size
- 4 files, +135/-40 LoC. Most of it is comment text + the new test.
- Did NOT change `BuildExternalConnectionPayload` signature or
  `mcpServerNameForWorkspace` semantics — both were already plumbed
  by mc#1535 to all 8 snippets via the stamp closure; this PR only
  updates the template text to USE the placeholder.

## Test plan
- [x] `go test ./internal/handlers/ -run TestBuildExternalConnectionPayload` — 5/5 green, including new `_AllRuntimeSnippetsAreWorkspaceUnique`.
- [x] `go test ./internal/handlers/` full package — 15.9s green.
- [x] `go vet ./internal/handlers/` — clean.
- [ ] Manual (post-merge, requires mc#1535 also merged): create two
      "bot-a" + "bot-b" external workspaces on staging; paste each
      tab's snippet into the corresponding host on a single machine;
      verify `claude mcp list` / `cat ~/.codex/config.toml` /
      `openclaw mcp list` / `~/.hermes/config.yaml` / `ls
      ~/.molecule-ai/` each shows BOTH workspaces' entries side-by-
      side, not overwriting.

## Sequencing
- This PR's base is mc#1535's branch
  (`fix/add-to-claude-code-unique-server-name-per-workspace`),
  because it reuses mc#1535's `{{MCP_SERVER_NAME}}` placeholder +
  slug helper + `BuildExternalConnectionPayload(workspaceName)`
  signature change. Will need a rebase on main after mc#1535 lands;
  prefer to keep stacked to make the review of EACH PR scope-tight.
- CTO 2026-05-18 22:43Z: "其实是我们没有做好instruction,这个得补充" —
  this PR is the consolidated per-repo doc/generator fix.

## Related
- Sibling: mc#1535 (Universal MCP snippet, already open).
- Follow-up #230: molecule-core stale channel-install mentions
  (CONTRIBUTING.md:195, etc.) — separate scope.

Author identity: core-devops (per-role persona; not founder-PAT).
Opened for non-author review, NOT auto-merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:05:51 -07:00
core-devops 9a3db439ec fix(canvas): make "Add to Claude Code" snippet use unique server name per workspace (multi-workspace)
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 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 9s
E2E Chat / 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
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
qa-review / approved (pull_request) Failing after 14s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 9s
sop-checklist / all-items-acked (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m6s
E2E Chat / E2E Chat (pull_request) Failing after 1m4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m31s
CI / Platform (Go) (pull_request) Successful in 2m50s
CI / Canvas (Next.js) (pull_request) Successful in 5m16s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m7s
CI / Python Lint & Test (pull_request) Successful in 6m35s
CI / all-required (pull_request) Successful in 6m47s
audit-force-merge / audit (pull_request) Successful in 5s
The Universal MCP install snippet hardcoded `claude mcp add molecule -s user`
— `claude mcp add` keys entries by name, so installing for workspace B
silently overwrote workspace A in the user's ~/.claude.json. A single
external Claude Code session ended up able to talk to only ONE molecule
workspace at a time — the CTO-observed "this is per-session" UX
(2026-05-18 22:28Z). MCP itself supports many servers per session; the
install snippet was the only thing standing in the way.

Fix: derive a unique server name per workspace at payload-build time —
`molecule-<slug>` where slug = lowercased/hyphen-collapsed workspace
name (max 24 chars), falling back to the first 8 chars of the workspace
ID when the name is empty or slugifies to nothing. The result is
alphanumeric + hyphens only (URL-safe + Claude-Code-name-safe).

Plumbed through all 3 callers of BuildExternalConnectionPayload:
- Create (workspace.go) passes payload.Name directly.
- Rotate / GetExternalConnection (external_rotate.go) extend the
  existing runtime lookup to also SELECT name in the same round-trip
  (lookupWorkspaceRuntimeAndName replaces lookupWorkspaceRuntime —
  one query, no extra DB load).

Snippet header now documents the multi-workspace contract: re-running
the snippet from another workspace's modal ADDS another entry; same-
name workspaces collide by design, rename one to disambiguate.

Surgical: only externalUniversalMcpTemplate gained a {{MCP_SERVER_NAME}}
placeholder. Other tabs (Python SDK / curl / Hermes / codex / openclaw /
kimi) already use distinct config keys per provider and aren't affected.

Tests: TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace
pins 4 cases (plain name, name w/ spaces+caps, name w/ symbols, empty
name fallback to UUID prefix) — would have caught the original
"claude mcp add molecule" regression. Existing rotate/get tests updated
for the 2-column SELECT.

Related: task #229 (molecule-mcp-claude-channel install-doc blockers).
This is the canvas-side counterpart — that PR fixed the plugin docs,
this PR fixes the modal-generator snippet operators actually copy.

Sample generated lines (was → now):
  was: claude mcp add molecule -s user -- env WORKSPACE_ID=... molecule-mcp
  now: claude mcp add molecule-my-bot -s user -- env WORKSPACE_ID=... molecule-mcp
  (where "my-bot" is the workspace name; "molecule-12345678" if unnamed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:36:55 -07:00
infra-runtime-be 533502da35 feat(provisioner): uniform T4 privilege contract + YAML emitter
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 23s
E2E Chat / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 33s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 2m58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 52s
E2E Chat / E2E Chat (pull_request) Failing after 1m7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 11s
CI / Canvas (Next.js) (pull_request) Successful in 5m53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 6m31s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 6m29s
audit-force-merge / audit (pull_request) Successful in 10s
Adds workspace-server/internal/provisioner/t4_privilege_contract.go as the
single source of truth for the T4 ("full machine access") capability set
that template-repo CI workflows currently re-implement as bespoke shell.

Today's t4-conformance gates in template-claude-code / template-hermes /
template-codex each hand-assert agent-uid + token-ownership + host-root
reach. The shell drifts (the very Hermes 401 class bug came from drift),
and there's no way to add a new capability fleet-wide without N template
PRs.

This contract:

  * Defines T4Capability as code (Name/Description/Probe/Severity/Source)
  * Lists the closure: agent_uid_1000, auth_token_agent_owned,
    host_root_reach_via_nsenter, host_fs_write_readback,
    docker_socket_reachable, list_peers_http_200, agent_home_writable,
    network_egress_https, privileged_flag_observable, pid_host_visible
  * Renders to YAML via AsYAML() and cmd/t4-contract-dump so any
    template CI can do:
       go run ./workspace-server/cmd/t4-contract-dump > t4_capabilities.yaml
    and iterate capabilities — new capabilities propagate without
    per-template PRs.
  * Pure stdlib + no Molecule-AI-internal deps so fork users can adopt
    the same contract.

Anti-drift unit tests (7, all green):
  - all caps have required fields
  - names unique
  - core closure (RFC#456 + task #128/#174) is present
  - hard-severity is strict majority
  - YAML is deterministic + escapes double quotes
  - YAML header cites internal#456
  - AgentUID const consistent with probes

Does NOT change Docker/Dockerfile or any existing emit-side behavior;
this is purely additive. The provisioner.go T4 branch is unchanged.
Templates adopt the YAML in a separate PR (pilot:
template-claude-code).

Refs: RFC internal#456, task #174, memory
reference_per_template_privilege_contract_class_audit_2026_05_16,
memory feedback_hermes_listpeers_401_token_root600_unreadable_by_uid1000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:38:58 -07:00
core-fe c2110c799d fix(canvas/socket): wake WebSocket on visibilitychange / pageshow
CI / Detect changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 13s
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 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 7s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Platform (Go) (pull_request) Successful in 2m41s
sop-checklist / all-items-acked (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
CI / Canvas (Next.js) (pull_request) Successful in 5m58s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 6m49s
CI / all-required (pull_request) Successful in 6m39s
E2E Chat / E2E Chat (pull_request) Failing after 4m58s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11m53s
audit-force-merge / audit (pull_request) Successful in 10s
Mobile browsers (iOS Safari, Chrome on Android in deep-sleep) silently
drop the WebSocket when the tab is backgrounded. The in-page `onclose`
fires very late or never, so the reconnect backoff never schedules — the
canvas appears frozen until the user manually refreshes. Symptoms:

  - #223 mobile canvas chat has no real-time updates (must refresh)
  - #228 cross-device: user's own chat input doesn't broadcast to
         other sessions in real time (must refresh)

Root cause: `canvas/src/store/socket.ts` had no visibility-wake. The
reconnect loop only re-arms on `onclose`, and mobile OSes don't always
fire `onclose` when they kill the WS.

Fix:
  - Add `ReconnectingSocket.wake()` — forces an immediate reconnect
    when the socket is in CLOSED / CLOSING / null limbo, no-op when
    OPEN or CONNECTING. Pre-empts any pending backoff timer and resets
    the attempt counter (this was a user-initiated wake, not an
    unattended-tab failure cascade).
  - Wire a module-level `visibilitychange` + `pageshow` listener inside
    `connectSocket()`; remove it in `disconnectSocket()`. `pageshow`
    covers Safari's bfcache restore where `visibilitychange` doesn't
    fire on its own.
  - Export `wakeSocket()` so the test suite can exercise the path
    without depending on a jsdom DOM (the existing socket.test.ts
    runs under the `node` environment).

Tests (5 new cases under `wakeSocket → reconnect`):
  - wake on OPEN: no new WS
  - wake on CLOSED: new WS created (the #223 fix)
  - wake on CONNECTING: no extra handshake piled on
  - wake cancels pending backoff `setTimeout`
  - wake after `disconnectSocket()` is a no-op (no zombie)

Closes #223
Closes #228
2026-05-18 14:37:56 -07:00
core-fe 679d86a9be fix(mobile): bump focused-input font-size to 16px (kills iOS focus-zoom)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
gate-check-v3 / gate-check (pull_request) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
sop-checklist / na-declarations (pull_request) N/A: (none)
qa-review / approved (pull_request) Failing after 10s
sop-checklist / all-items-acked (pull_request) Successful in 7s
security-review / approved (pull_request) Failing after 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 35s
sop-tier-check / tier-check (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Failing after 1m0s
CI / Platform (Go) (pull_request) Successful in 5m9s
CI / Python Lint & Test (pull_request) Successful in 6m9s
CI / Canvas (Next.js) (pull_request) Successful in 6m33s
CI / all-required (pull_request) Successful in 6m34s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 9s
iOS Safari and PWAs auto-zoom the viewport when a focused input or
textarea has a computed font-size below 16px. Two mobile-canvas inputs
were below that bound, causing the layout to jump and look broken on
focus until the user pinched back:

  - MobileSpawn.tsx agent-name input (fontSize: 13.5) — #225
  - MobileChat.tsx composer textarea (fontSize: 14.5) — #224

Both bumped to 16px (the minimum that suppresses focus-zoom). This is
the same class of bug as desktop #1434, scoped here to the mobile
breakpoint.

Tests:
  - MobileSpawn.test: assert agent-name input renders at fontSize >= 16
  - MobileChat.test:  assert composer textarea renders at fontSize >= 16
Both parse the inline style.fontSize (jsdom has no layout engine, so
getComputedStyle reports the inline value verbatim).

Closes #224
Closes #225
2026-05-18 14:34:58 -07:00
hongming 03337955ca Merge pull request 'fix(ci): review-check.sh — read issue comments for agent-approval fallback' (#1492) from fix/review-check-agent-comment-approval into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 18s
E2E Chat / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
review-check-tests / review-check.sh regression tests (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Harness Replays / Harness Replays (push) Successful in 7s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m18s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m34s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m13s
CI / Platform (Go) (push) Successful in 2m56s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m33s
CI / Canvas (Next.js) (push) Successful in 4m22s
CI / Canvas Deploy Reminder (push) Successful in 1s
publish-workspace-server-image / build-and-push (push) Successful in 4m18s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m11s
E2E Chat / E2E Chat (push) Failing after 5m20s
CI / Python Lint & Test (push) Successful in 7m12s
CI / all-required (push) Successful in 7m25s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m34s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m36s
main-red-watchdog / watchdog (push) Successful in 52s
gate-check-v3 / gate-check (push) Successful in 21s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
gitea-merge-queue / queue (push) Successful in 5s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 39s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m46s
ci-required-drift / drift (push) Successful in 58s
2026-05-18 21:28:31 +00:00
hongming e27f0747f2 Merge pull request 'fix(canvas): add role=alert aria-live=assertive to AgentAbilitiesSection error (WCAG 4.1.3)' (#1518) from fix/agent-abilities-aria-alert into main
CI / Canvas Deploy Reminder (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
CI / Platform (Go) (push) Has been cancelled
E2E Chat / E2E Chat (push) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (push) Has been cancelled
CI / all-required (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Has been cancelled
Harness Replays / Harness Replays (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
publish-canvas-image / Build & push canvas image (push) Successful in 2m30s
2026-05-18 21:28:04 +00:00
core-uiux 9c3fcafa1a fix(canvas): add role=alert aria-live=assertive to AgentAbilitiesSection error
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
qa-review / approved (pull_request) Failing after 9s
gate-check-v3 / gate-check (pull_request) Successful in 11s
security-review / approved (pull_request) Failing after 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 43s
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Canvas (Next.js) (pull_request) Successful in 4m4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 4m24s
E2E Chat / E2E Chat (pull_request) Failing after 5m13s
CI / Python Lint & Test (pull_request) Successful in 6m39s
CI / all-required (pull_request) Successful in 6m42s
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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m51s
audit-force-merge / audit (pull_request) Successful in 7s
Follow-up to PR #1504 (role=alert on ConfigTab error divs) — the
AgentAbilitiesSection error div was in a separate render branch and
was missed. WCAG 4.1.3 requires dynamic error messages to be announced
by screen readers immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:58:40 +00:00
core-uiux 6c03c51a99 fix(canvas): WCAG 2.4.7 focus-visible on page.tsx buttons + add main landmark
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m20s
security-review / approved (pull_request) Failing after 11s
qa-review / approved (pull_request) Failing after 13s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 4m48s
CI / Platform (Go) (pull_request) Successful in 5m8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Failing after 1m2s
CI / Python Lint & Test (pull_request) Failing after 8m53s
CI / all-required (pull_request) Failing after 8m59s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12m20s
- Add focus-visible ring to three buttons missing it:
  - Mobile hydration error Retry button
  - Desktop hydration error Retry button
  - PlatformDownDiagnostic Reload button
- Wrap <Canvas /> in <main aria-label="Agent canvas"> landmark
  (WCAG 1.3.1 — main content now has a proper landmark)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux cfcb7bf840 fix(canvas/chat): WCAG 2.4.7 focus-visible on AgentCommsPanel + AttachmentViews
- AgentCommsPanel: add focus-visible ring + aria-label to Retry button
  (error state). Add focus-visible to CommsTab tab buttons.
- AttachmentViews: add focus-visible ring + aria-label to Remove button
  (PendingAttachmentPill) and Download button (AttachmentChip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux d9d93bb728 fix(canvas/settings): add focus-visible to secrets-tab refresh button (WCAG 2.4.7)
The Refresh button inside the SecretsTab error state had no focus ring
defined in CSS. Without it, keyboard-only users cannot determine which
element has focus on that error screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux eff258e46e fix(canvas/ConfigTab): add aria-label to fallback model input (WCAG 1.3.1)
The free-text model input (shown when /templates returns no models for
the runtime) had a visual <label>Model</label> but the input lacked an
id and the label lacked htmlFor — the association was purely visual.
Added aria-label="Model" to make the name programmatically determinable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux 19ec517a1d fix(canvas/FilesTab): add aria-modal="false" to inline alertdialog confirm prompts (WCAG 4.1.2)
The two FilesTab confirm dialogs (delete-all, delete-one) use role="alertdialog"
but were missing aria-modal. These are inline in-page prompts without focus
trapping — aria-modal="false" explicitly documents the non-modal nature so
assistive technology knows the rest of the page remains interactive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux 545d7c4fb2 fix(canvas/mobile): WCAG 2.4.7 comprehensive focus-visible audit — all interactive buttons
MobileChat: Back, More, tab-switch, retry, remove-file, attach, send buttons
MobileSpawn: Close, template-select, tier-select, spawn buttons
components: tab bar, workspace-card, radio-filter buttons
MobileDetail: Back, More, tab-switch, chat-CTA buttons

All previously lacked focus-visible rings (WCAG 2.4.7). Added emerald-500
ring with appropriate offset for light/dark mode. Retry button also
gained aria-label. Template-select and tier-select gained descriptive
aria-labels matching the broadcast-chat-wcag branch pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux 2ecd0de127 fix(canvas/mobile): WCAG 2.4.7 focus-visible rings — MobileHome spawn FAB, MobileMe accent swatches + theme toggle
MobileHome: spawn FAB had no focus indicator — added emerald ring.
MobileMe: accent color swatches (all 8 colors) and theme toggle buttons
(Dark / Light / System) had no focus indicators — added emerald ring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux 783f293c91 fix(canvas/mobile): WCAG 2.4.7 focus-visible rings — MobileCanvas reset zoom + MobileComms filter buttons
MobileCanvas: reset zoom button had no focus indicator — added
focus-visible:ring-2 with emerald-500 ring (consistent with other
mobile interactive elements in the same branch).

MobileComms: filter toggle buttons (All / Errors) had no focus indicator
— added focus-visible:ring-2 with emerald-500 ring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux b446c080aa fix(canvas/mobile): aria-label on MobileChat composer textarea and MobileSpawn name input (WCAG 1.3.1)
MobileChat: composer textarea had no aria-label — added aria-label="Message".
MobileSpawn: name input had no programmatic label — added aria-label="Agent name".

Both inputs had visible text labels above them but no accessible-name association,
violating WCAG 1.3.1 (info/structure relationships).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux fa8462883e fix(canvas): aria-label on "Add new" secret form inputs (WCAG 4.1.2)
The "Add new" section had two bare <input> elements with only
placeholder text. Added aria-label="Secret key name" and
aria-label="Secret value" — distinct from the per-row Field
inputs that PR #1453 already fixed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux c9146884c5 fix(canvas): WCAG 1.3.1 + 4.1.3 follow-up accessibility fixes
- MissingKeysModal.tsx: Add aria-label to both password inputs
  (inside map loops where entry.key is the accessible name source).
  WCAG 1.3.1 / 4.1.2.
- AuditTrailPanel.tsx: Add role="status" aria-live="polite" to
  the loading state div. WCAG 4.1.3.
- ConversationTraceModal.tsx: Add role="status" aria-live="polite"
  to both the loading state and empty state divs. WCAG 4.1.3.

Found via systematic accessibility audit sweep of non-tab components.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux eba3a48342 fix(canvas): scope test selectors to panel testids (test regression)
Tests in ExternalConnectModal.test.tsx used document.querySelector("pre")
which returns the first pre in DOM order. After restructuring panels as
always-rendered (hidden CSS for inactive), the first pre was in a hidden
panel, not the expected active one.

Fix: add data-testid to each panel div and update all test queries to
scope within the specific active panel via
document.querySelector("[data-testid='panel-...']").

All 18 tests pass. Build passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux 7f178778d5 fix(canvas): complete ARIA tab pattern for ExternalConnectModal (WCAG)
- Add id=, aria-controls=, and tabIndex= to each role=tab button
- Add id= and role=tabpanel + aria-labelledby= to each snippet panel
- Restructure panels as always-rendered (hidden CSS) so aria-controls
  targets are stable — active panel has role=tabpanel, hidden panels
  are hidden with aria-hidden semantics via hidden attribute
- Add ArrowRight/ArrowLeft/ArrowDown/ArrowUp + Home/End keyboard
  navigation for the tablist (ARIA tab pattern requirement)
- Compute tabList once after filled* vars to share between tab bar
  and keyboard handler

WCAG 4.1.3 (Name, Role, Value) — tab controls now have correct
role, aria-selected, aria-controls, and keyboard navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
core-uiux d1664b3144 fix(canvas/tabs): add role=alert + aria-live=assertive to tab error states (WCAG 4.1.3)
Error divs in EventsTab, TracesTab, ChannelsTab, DetailsTab (save/restart/delete),
and ExternalConnectionSection now use role=alert so assistive technology
announces each error immediately when it appears.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:36:22 +00:00
infra-runtime-be 254362b3bc ci: re-trigger sop-checklist gate [force-retrigger]
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 6s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 40s
gate-check-v3 / gate-check (pull_request) Successful in 20s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 2m51s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m12s
CI / Canvas (Next.js) (pull_request) Successful in 6m13s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m19s
E2E Chat / E2E Chat (pull_request) Failing after 56s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
CI / Python Lint & Test (pull_request) Successful in 6m38s
CI / all-required (pull_request) Successful in 6m39s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m28s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m54s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 16s
Force a new workflow run to pick up the /sop-n/a qa-review
and /sop-n/a security-review declarations from infra-runtime-be
(engineers team) and the [core-security-agent] APPROVED comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:24:48 +00:00
infra-runtime-be bc1e848977 fix(ci): review-check.sh — read issue comments for agent-approval fallback
core-qa-agent and core-security-agent approve PRs via issue comments,
not the reviews API. The reviews API returns zero entries for comment-only
approvals (internal#348), causing qa-review / security-review gates to
fail on every PR — even when both agents have explicitly approved.

Changes:
- review-check.sh: after reviews-API candidate check fails, fetch
  GET /repos/{owner}/{repo}/issues/{N}/comments and extract logins that
  posted (a) the agent-prefix pattern ([core-qa-agent] or
  [core-security-agent]) OR (b) a generic approval keyword (APPROVED /
  LGTM / ACCEPTED, word-anchored, case-insensitive). Non-author filter
  is applied. Candidates from comments are merged and fall through to the
  team-membership probe, same as reviews-API candidates.
- _review_check_fixture.py: add T15 (agent-prefix match → exit 0),
  T16 (generic keyword match → exit 0), T17 (no approval → exit 1)
  scenarios with corresponding issue comments endpoint handler.
- test_review_check.sh: add T15, T16, T17 regression tests.

Also fixes a JQ operator-precedence bug in an earlier draft where
`| $cmt.user.login` was placed OUTSIDE the `or` expression, causing the
filter to always output the login (jq resolves bound variables regardless
of the current context). Fixed by using `if-then-elif-else-empty` so the
login projection only fires on a genuine match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:24:48 +00:00
core-platform 0d8cf76326 fix(ws-server): fail-closed on unresolvable template runtime (controlplane#188)
POST /workspaces silently substituted langgraph and returned 201 when a
caller named a `template` (intent for a specific runtime) but the runtime
could not be resolved from it (config.yaml unreadable / no `runtime:`
key). This is the molecule-controlplane#188 / #184 contract violation —
it produced 5/5 wrong-runtime workspaces and a false codex E2E pass.

The ws-server `Create` handler is the boundary the product UI actually
hits (the canvas dialog and provision_workspace MCP tool both POST here);
controlplane#188's CP-side gate is the sibling. This closes the
ws-server side: when the caller expressed runtime intent (passed
`runtime`, or named a `template`) but it cannot be honored, return 422
RUNTIME_UNRESOLVED instead of a silent langgraph 201.

The legitimate default path (bare {"name":...} — no template, no
runtime) still defaults to langgraph and returns 201; a regression test
pins that so the fail-closed gate can't over-fire.

Tests: TestWorkspaceCreate_188_* (missing template, no-runtime-key
template, default-path regression guard, explicit-runtime OK).

Refs: molecule-controlplane#188, #184

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:24:48 +00:00
devops-engineer 878c8493a0 ci: re-trigger after #468 crawler-overload mitigation; prior 'Platform (Go)' job dispatch-starved (never scheduled) so all-required aggregator failed on a missing dep — not a logic failure. RunnerService RPC p95 11741ms->1273ms, dispatch recovered. Code unchanged [no-op]
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 29s
CI / Detect changes (pull_request) Failing after 57s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Failing after 1m21s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 37s
CI / all-required (pull_request) Failing after 13s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 22s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m1s
Harness Replays / detect-changes (pull_request) Successful in 45s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 42s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 53s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 28s
sop-checklist / all-items-acked (pull_request) Successful in 23s
qa-review / approved (pull_request) Successful in 27s
sop-tier-check / tier-check (pull_request) Successful in 19s
security-review / approved (pull_request) Failing after 28s
gate-check-v3 / gate-check (pull_request) Failing after 32s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m39s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m4s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m31s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m57s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m27s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m58s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m47s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m41s
CI / Python Lint & Test (pull_request) Failing after 7m46s
CI / Canvas (Next.js) (pull_request) Successful in 22m42s
CI / Platform (Go) (pull_request) Successful in 23m44s
Harness Replays / Harness Replays (pull_request) Successful in 20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 2m59s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 3m59s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 5m1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m43s
2026-05-16 06:05:55 -07:00
devops-engineer 85bd51ab2f ci: re-trigger CI on recovered runners (post data-root rollback 2026-05-16 09:54Z; prior checks stale-failed on pre-recovery infra wall, not logic) [no-op]
CI / Platform (Go) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
CI / Detect changes (pull_request) Successful in 32s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 47s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m51s
E2E API Smoke Test / detect-changes (pull_request) Successful in 32s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 34s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
Harness Replays / detect-changes (pull_request) Successful in 26s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m0s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 24s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m9s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m19s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m36s
publish-runtime-autobump / pr-validate (pull_request) Successful in 41s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m24s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m50s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m29s
CI / Python Lint & Test (pull_request) Successful in 8m6s
gate-check-v3 / gate-check (pull_request) Failing after 30s
qa-review / approved (pull_request) Failing after 24s
security-review / approved (pull_request) Failing after 21s
sop-tier-check / tier-check (pull_request) Successful in 30s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m33s
sop-checklist / all-items-acked (pull_request) Successful in 34s
Harness Replays / Harness Replays (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5m16s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m14s
CI / Canvas (Next.js) (pull_request) Successful in 17m53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m28s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11m58s
CI / Canvas Deploy Reminder (pull_request) Successful in 6s
CI / all-required (pull_request) Failing after 40m20s
2026-05-16 03:25:22 -07:00
infra-runtime-be 3371b46b9f Merge branch 'main' into fix/a2a-mcp-stdio-pipe-blocking-readline (bring up-to-date for merge gate)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
CI / Detect changes (pull_request) Successful in 24s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 33s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m45s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
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 16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Failing after 12s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 13s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Failing after 0s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 0s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 0s
publish-runtime-autobump / pr-validate (pull_request) Failing after 1s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Failing after 1m30s
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 0s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 0s
gate-check-v3 / gate-check (pull_request) Failing after 0s
qa-review / approved (pull_request) Failing after 0s
security-review / approved (pull_request) Failing after 0s
sop-checklist / all-items-acked (pull_request) Failing after 0s
sop-tier-check / tier-check (pull_request) Failing after 0s
CI / all-required (pull_request) Failing after 1m13s
CI / Platform (Go) (pull_request) Failing after 2m53s
CI / Canvas (Next.js) (pull_request) Failing after 3m12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Failing after 0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 0s
2026-05-16 02:17:31 -07:00
infra-runtime-be 2fe3229e0e chore(ci): re-trigger CI (06:44Z storm-cancel residue — needed jobs cancelled started=0, Python Lint & Test passed)
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 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m11s
CI / Platform (Go) (pull_request) Successful in 5m11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 6m21s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 6m30s
CI / all-required (pull_request) Successful in 6m13s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 58s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 2s
gate-check-v3 / gate-check (pull_request) Failing after 4s
qa-review / approved (pull_request) Failing after 3s
publish-runtime-autobump / pr-validate (pull_request) Successful in 23s
security-review / approved (pull_request) Failing after 3s
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 57s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m20s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m18s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m43s
2026-05-16 01:54:13 -07:00
infra-runtime-be 09fa65a094 fix(a2a-mcp): use readline() not read(65536) for pipe-safe stdio
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Waiting to run
CI / Canvas (Next.js) (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 37s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 3m3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m19s
security-review / approved (pull_request) Failing after 58s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 2m1s
gate-check-v3 / gate-check (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 34s
CI / Python Lint & Test (pull_request) Successful in 9m39s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m16s
sop-tier-check / tier-check (pull_request) Successful in 36s
CI / all-required (pull_request) Failing after 40m20s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
a2a_mcp_server.py main()'s stdio read loop used
`await loop.run_in_executor(None, stdin.read, 65536)`. On a PIPE,
read(n) blocks until n bytes accumulate OR EOF. A live MCP client
(openclaw bundle-mcp, Claude Code, Cursor) sends one ~150-byte
newline-delimited request and keeps stdin OPEN waiting for the reply,
so neither condition is met: the server never parses `initialize` and
the client times out (~30s; openclaw: "MCP error -32000: Connection
closed"). This silently broke peer visibility for every pipe-spawned
MCP host while passing all existing stdio tests, which only fed stdin
from a regular file or a heredoc-pipe that CLOSES (EOF returns
immediately). readline() returns as soon as one newline-delimited
line is available — exactly the JSON-RPC framing — and is
backward-compatible with the EOF/file cases.

Root cause of the 2026-05-15 openclaw peer-visibility outage
(workspace 95744c11): the molecule MCP server could not complete the
handshake over openclaw's stdio pipe, so the agent fell back to
native sessions_list. The openclaw template adapter fix
(template-openclaw#16) works around this via HTTP transport; this
patch fixes the stdio root cause so stdio works for all CLI MCP hosts.

Regression coverage:
- tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe — spawns the
  real a2a_mcp_server.py, writes one request over a pipe, and
  DELIBERATELY keeps stdin open. FAILS (15s timeout, empty response)
  on read(65536); PASSES on readline(). Verified both directions.
- ci-mcp-stdio-transport.yml: new "pipe held OPEN, no EOF" step that
  reproduces the literal openclaw failure (the prior steps only
  exercised EOF-closing stdin, which is why the outage shipped green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:43:14 -07:00
39 changed files with 1726 additions and 197 deletions
+54 -2
View File
@@ -100,11 +100,12 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
# (bash trap 'function' EXIT expands variables at trap-fire time, not def time).
PR_JSON=$(mktemp)
REVIEWS_JSON=$(mktemp)
COMMENTS_JSON=$(mktemp)
TEAM_PROBE_TMP=$(mktemp)
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
cleanup() {
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$COMMENTS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
}
trap cleanup EXIT
@@ -229,7 +230,58 @@ if [ -z "$CANDIDATES" ]; then
[ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB."
done
fi
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
# --- Fallback (internal#348): check issue comments for agent-approval ---
# core-qa-agent and core-security-agent approve via issue comments, NOT
# the reviews API. The reviews API returns zero entries for comment-only
# approvals. This fallback reads PR issue comments and extracts logins that:
# 1. Posted a comment matching the agent-prefix pattern for this gate:
# qa → "[core-qa-agent] APPROVED"
# security → "[core-security-agent] APPROVED"
# OR posted a generic approval keyword (word-anchored, case-insensitive):
# APPROVED / LGTM / ACCEPTED
# 2. Are not the PR author
# 3. The team-membership probe below is the authoritative filter.
AGENT_PATTERN=""
case "$TEAM" in
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
esac
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# JQ expression: select non-author comments that match either the
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
JQ_APPROVALS='
.[] |
select(.user.login != $author) |
. as $cmt |
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
$cmt.user.login
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
$cmt.user.login
else
empty
end
'
CANDIDATES=$(jq -r \
--arg author "$PR_AUTHOR" \
--arg agent_pattern "$AGENT_PATTERN" \
"$JQ_APPROVALS" \
"$COMMENTS_JSON" 2>/dev/null | sort -u)
debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')"
if [ -n "$CANDIDATES" ]; then
echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
fi
else
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
fi
fi
if [ -z "${CANDIDATES:-}" ]; then
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
exit 1
fi
+34 -1
View File
@@ -17,6 +17,9 @@ Scenarios:
T8_team_not_member — team membership → 404 (not a member) → exit 1
T9_team_403 — team membership → 403 (token not in team) → exit 1
T14_non_default_base — open PR targeting staging → script exits 0 (no-op)
T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0
T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0
T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1
Usage:
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
@@ -97,7 +100,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /repos/{owner}/{name}/pulls/{pr_number}/reviews
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)/reviews$", path)
if m:
if sc in ("T4_reviews_empty", "T5_reviews_only_author"):
if sc in ("T4_reviews_empty", "T5_reviews_only_author",
"T15_comments_agent_approval", "T16_comments_generic_approval",
"T17_comments_no_approval"):
return self._json(200, [])
if sc == "T6_reviews_dismissed":
return self._json(200, [{
@@ -116,6 +121,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
])
# GET /repos/{owner}/{name}/issues/{pr_number}/comments
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/comments$", path)
if m:
if sc == "T15_comments_agent_approval":
return self._json(200, [
{"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED this PR. Good changes.", "id": 1},
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 2},
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 3},
])
if sc == "T16_comments_generic_approval":
return self._json(200, [
{"user": {"login": "core-qa-agent"}, "body": "APPROVED — all acceptance criteria met", "id": 1},
{"user": {"login": "alice"}, "body": "-authored", "id": 2},
])
if sc == "T17_comments_no_approval":
return self._json(200, [
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 1},
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2},
])
# Default scenarios (T1T9, T14): no comments
return self._json(200, [])
# GET /teams/{team_id}/members/{username}
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
if m:
@@ -127,6 +154,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
# T7_team_member: member
return self._empty(204)
# GET /repos/{owner}/{name}/statuses/{sha} — for N/A declaration check
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/statuses/([a-f0-9]+)$", path)
if m:
# All comment-based scenarios have no N/A declarations
return self._json(200, [])
return self._json(404, {"path": path, "msg": "fixture: no route"})
def do_POST(self):
+25
View File
@@ -334,6 +334,31 @@ assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
# T15 — comment-based approval via agent prefix pattern → exit 0
echo
echo "== T15 comment agent-prefix approval =="
T15_OUT=$(run_review_check "T15_comments_agent_approval")
T15_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T15 exit code 0 (agent-comment approval + team member)" "0" "$T15_RC"
assert_contains "T15 comment fallback notice" "comment-based approval" "$T15_OUT"
assert_contains "T15 core-qa-agent APPROVED" "APPROVED by core-qa-agent" "$T15_OUT"
# T16 — comment-based approval via generic APPROVED keyword → exit 0
echo
echo "== T16 comment generic keyword approval =="
T16_OUT=$(run_review_check "T16_comments_generic_approval")
T16_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T16 exit code 0 (generic-approval comment + team member)" "0" "$T16_RC"
assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT"
# T17 — no approval keywords in comments → exit 1
echo
echo "== T17 comments with no approval keywords =="
T17_OUT=$(run_review_check "T17_comments_no_approval")
T17_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC"
assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
+61 -1
View File
@@ -158,8 +158,68 @@ jobs:
echo "NOTE: No warning in output (may be suppressed by log level)"
fi
- name: Reproduce openclaw failure — pipe held OPEN, no EOF
run: |
set -euo pipefail
echo "=== keep-stdin-open pipe (the real openclaw / Claude Code case) ==="
echo ""
echo "Before the readline() fix this HANGS: main() did"
echo " stdin.read(65536) -> on a pipe, blocks until 64KB OR EOF."
echo "An MCP client sends one ~150B initialize and keeps stdin"
echo "open waiting for the response, so the server never parsed"
echo "the request and the client timed out (openclaw: 'MCP error"
echo "-32000: Connection closed'). The earlier regular-file /"
echo "heredoc-pipe steps PASSED through this bug because a file"
echo "(or a closing heredoc) yields EOF immediately."
echo ""
# Drive the server through a real pipe that stays OPEN: write
# one initialize, do NOT close stdin, and require a response
# within a hard timeout. read(65536) -> no output -> timeout
# kills it -> FAIL. readline() -> immediate response -> PASS.
python - <<'PYEOF'
import json, subprocess, sys, time, select
proc = subprocess.Popen(
[sys.executable, "a2a_mcp_server.py"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env={**__import__("os").environ},
)
req = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "keepopen", "version": "1"}},
}) + "\n"
proc.stdin.write(req.encode())
proc.stdin.flush()
# Deliberately DO NOT close proc.stdin — mirror a live MCP client.
deadline = time.time() + 15
line = b""
while time.time() < deadline:
r, _, _ = select.select([proc.stdout], [], [], 1)
if r:
line = proc.stdout.readline()
if line:
break
proc.kill()
if not line:
print("FAIL: no response within 15s on an open pipe — "
"stdin.read(65536) regression is back")
sys.exit(1)
resp = json.loads(line.decode())
assert resp.get("id") == 1 and "result" in resp, \
f"unexpected response: {line[:200]!r}"
assert resp["result"]["serverInfo"]["name"] == "molecule", \
f"wrong serverInfo: {line[:200]!r}"
print("PASS: server answered initialize on a still-open pipe")
PYEOF
- name: Run unit tests for stdio transport
run: |
set -euo pipefail
echo "=== Running stdio transport unit tests ==="
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion -v --no-cov
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe -v --no-cov
+6 -4
View File
@@ -103,7 +103,7 @@ export default function Home() {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Retry
</button>
@@ -115,7 +115,9 @@ export default function Home() {
return (
<>
<Canvas />
<main aria-label="Agent canvas">
<Canvas />
</main>
<Legend />
<CommunicationOverlay />
{hydrationError && (
@@ -134,7 +136,7 @@ export default function Home() {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Retry
</button>
@@ -176,7 +178,7 @@ brew services start redis`}</pre>
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Reload
</button>
+1 -1
View File
@@ -132,7 +132,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div role="status" aria-live="polite" className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading audit trail</span>
</div>
);
@@ -133,13 +133,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-ink-mid text-center py-8">
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
Loading trace from all workspaces...
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
No activity found
</div>
)}
+196 -85
View File
@@ -15,7 +15,7 @@
// ($AGENT_URL). They ARE NOT filled in server-side because the
// server doesn't know where the operator's agent will live.
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields";
@@ -84,6 +84,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
: "python";
const [tab, setTab] = useState<Tab>(initialTab);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const tabRefs = useRef<Map<Tab, HTMLButtonElement | null>>(new Map());
const handleTabKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>, current: Tab, tabs: Tab[]) => {
const idx = tabs.indexOf(current);
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
const next = tabs[(idx + 1) % tabs.length];
setTab(next);
tabRefs.current.get(next)?.focus();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
setTab(prev);
tabRefs.current.get(prev)?.focus();
} else if (e.key === "Home") {
e.preventDefault();
setTab(tabs[0]);
tabRefs.current.get(tabs[0])?.focus();
} else if (e.key === "End") {
e.preventDefault();
setTab(tabs[tabs.length - 1]);
tabRefs.current.get(tabs[tabs.length - 1])?.focus();
}
},
[],
);
const copy = useCallback(async (value: string, key: string) => {
try {
@@ -160,6 +187,19 @@ export function ExternalConnectModal({ info, onClose }: Props) {
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
);
// Build the tab list once so both the tab bar and keyboard handler
// share the same ordered array. Computed here (after all filled* vars)
// so TypeScript's block-scoping analysis can reach them.
const tabList: Tab[] = [];
if (filledUniversalMcp) tabList.push("mcp");
tabList.push("python");
if (filledChannel) tabList.push("claude");
if (filledHermes) tabList.push("hermes");
if (filledCodex) tabList.push("codex");
if (filledOpenClaw) tabList.push("openclaw");
if (filledKimi) tabList.push("kimi");
tabList.push("curl", "fields");
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
@@ -180,34 +220,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
aria-label="Connection snippet format"
className="mt-4 flex gap-1 border-b border-line"
>
{(() => {
// Build the tab order dynamically. Claude Code first
// (when offered) since it's the simplest setup; Python
// SDK second (full register+heartbeat+inbound); Universal
// MCP third (any MCP-aware runtime, outbound-only); curl
// for one-shot register; Fields for raw values.
// Tab order: Universal MCP first (default, runtime-
// agnostic primitives), then runtime-specific channel/
// SDK tabs, then curl + Fields. Each runtime tab only
// appears when the platform supplies the snippet — no
// dead "tab missing snippet" UX.
const tabs: Tab[] = [];
if (filledUniversalMcp) tabs.push("mcp");
tabs.push("python");
if (filledChannel) tabs.push("claude");
if (filledHermes) tabs.push("hermes");
if (filledCodex) tabs.push("codex");
if (filledOpenClaw) tabs.push("openclaw");
if (filledKimi) tabs.push("kimi");
tabs.push("curl", "fields");
return tabs;
})().map((t) => (
{tabList.map((t) => (
<button
key={t}
type="button"
role="tab"
id={`tab-${t}`}
aria-selected={tab === t}
aria-controls={`panel-${t}`}
tabIndex={tab === t ? 0 : -1}
ref={(el) => { tabRefs.current.set(t, el); }}
onClick={() => setTab(t)}
onKeyDown={(e) => handleTabKeyDown(e, t, tabList)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
tab === t
? "border-accent text-ink"
@@ -235,18 +259,39 @@ export function ExternalConnectModal({ info, onClose }: Props) {
))}
</div>
{/* Snippet area */}
<div className="mt-3">
{tab === "claude" && filledChannel && (
<SnippetBlock
value={filledChannel}
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
copyKey="claude"
copied={copiedKey === "claude"}
onCopy={() => copy(filledChannel, "claude")}
/>
)}
{tab === "python" && (
{/* Snippet area — all panels always in the DOM so aria-controls
targets are stable. Hidden panels use aria-hidden so screen
readers skip them; active panel uses role=tabpanel with
aria-labelledby pointing to the tab button. */}
<div className="mt-3" data-testid="snippet-panels">
{/* Claude Code tab */}
<div
id="panel-claude"
data-testid="panel-claude"
role="tabpanel"
aria-labelledby="tab-claude"
hidden={tab !== "claude" || !filledChannel}
className={tab === "claude" && filledChannel ? "" : "hidden"}
>
{filledChannel && (
<SnippetBlock
value={filledChannel}
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
copyKey="claude"
copied={copiedKey === "claude"}
onCopy={() => copy(filledChannel, "claude")}
/>
)}
</div>
{/* Python SDK tab */}
<div
id="panel-python"
data-testid="panel-python"
role="tabpanel"
aria-labelledby="tab-python"
hidden={tab !== "python"}
className={tab === "python" ? "" : "hidden"}
>
<SnippetBlock
value={filledPython}
label="Python SDK — includes heartbeat loop (push-mode, needs public URL)"
@@ -254,8 +299,16 @@ export function ExternalConnectModal({ info, onClose }: Props) {
copied={copiedKey === "python"}
onCopy={() => copy(filledPython, "python")}
/>
)}
{tab === "curl" && (
</div>
{/* curl tab */}
<div
id="panel-curl"
data-testid="panel-curl"
role="tabpanel"
aria-labelledby="tab-curl"
hidden={tab !== "curl"}
className={tab === "curl" ? "" : "hidden"}
>
<SnippetBlock
value={filledCurl}
label="curl — one-shot register only (no heartbeat)"
@@ -263,53 +316,111 @@ export function ExternalConnectModal({ info, onClose }: Props) {
copied={copiedKey === "curl"}
onCopy={() => copy(filledCurl, "curl")}
/>
)}
{tab === "mcp" && filledUniversalMcp && (
<SnippetBlock
value={filledUniversalMcp}
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
copyKey="mcp"
copied={copiedKey === "mcp"}
onCopy={() => copy(filledUniversalMcp, "mcp")}
/>
)}
{tab === "hermes" && filledHermes && (
<SnippetBlock
value={filledHermes}
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
copyKey="hermes"
copied={copiedKey === "hermes"}
onCopy={() => copy(filledHermes, "hermes")}
/>
)}
{tab === "codex" && filledCodex && (
<SnippetBlock
value={filledCodex}
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
copyKey="codex"
copied={copiedKey === "codex"}
onCopy={() => copy(filledCodex, "codex")}
/>
)}
{tab === "openclaw" && filledOpenClaw && (
<SnippetBlock
value={filledOpenClaw}
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
copyKey="openclaw"
copied={copiedKey === "openclaw"}
onCopy={() => copy(filledOpenClaw, "openclaw")}
/>
)}
{tab === "kimi" && filledKimi && (
<SnippetBlock
value={filledKimi}
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
copyKey="kimi"
copied={copiedKey === "kimi"}
onCopy={() => copy(filledKimi, "kimi")}
/>
)}
{tab === "fields" && (
</div>
{/* Universal MCP tab */}
<div
id="panel-mcp"
data-testid="panel-mcp"
role="tabpanel"
aria-labelledby="tab-mcp"
hidden={tab !== "mcp" || !filledUniversalMcp}
className={tab === "mcp" && filledUniversalMcp ? "" : "hidden"}
>
{filledUniversalMcp && (
<SnippetBlock
value={filledUniversalMcp}
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
copyKey="mcp"
copied={copiedKey === "mcp"}
onCopy={() => copy(filledUniversalMcp, "mcp")}
/>
)}
</div>
{/* Hermes tab */}
<div
id="panel-hermes"
data-testid="panel-hermes"
role="tabpanel"
aria-labelledby="tab-hermes"
hidden={tab !== "hermes" || !filledHermes}
className={tab === "hermes" && filledHermes ? "" : "hidden"}
>
{filledHermes && (
<SnippetBlock
value={filledHermes}
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
copyKey="hermes"
copied={copiedKey === "hermes"}
onCopy={() => copy(filledHermes, "hermes")}
/>
)}
</div>
{/* Codex tab */}
<div
id="panel-codex"
data-testid="panel-codex"
role="tabpanel"
aria-labelledby="tab-codex"
hidden={tab !== "codex" || !filledCodex}
className={tab === "codex" && filledCodex ? "" : "hidden"}
>
{filledCodex && (
<SnippetBlock
value={filledCodex}
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
copyKey="codex"
copied={copiedKey === "codex"}
onCopy={() => copy(filledCodex, "codex")}
/>
)}
</div>
{/* OpenClaw tab */}
<div
id="panel-openclaw"
data-testid="panel-openclaw"
role="tabpanel"
aria-labelledby="tab-openclaw"
hidden={tab !== "openclaw" || !filledOpenClaw}
className={tab === "openclaw" && filledOpenClaw ? "" : "hidden"}
>
{filledOpenClaw && (
<SnippetBlock
value={filledOpenClaw}
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
copyKey="openclaw"
copied={copiedKey === "openclaw"}
onCopy={() => copy(filledOpenClaw, "openclaw")}
/>
)}
</div>
{/* Kimi tab */}
<div
id="panel-kimi"
data-testid="panel-kimi"
role="tabpanel"
aria-labelledby="tab-kimi"
hidden={tab !== "kimi" || !filledKimi}
className={tab === "kimi" && filledKimi ? "" : "hidden"}
>
{filledKimi && (
<SnippetBlock
value={filledKimi}
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
copyKey="kimi"
copied={copiedKey === "kimi"}
onCopy={() => copy(filledKimi, "kimi")}
/>
)}
</div>
{/* Fields tab */}
<div
id="panel-fields"
data-testid="panel-fields"
role="tabpanel"
aria-labelledby="tab-fields"
hidden={tab !== "fields"}
className={tab === "fields" ? "" : "hidden"}
>
<div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
@@ -323,7 +434,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -440,6 +440,7 @@ function ProviderPickerModal({
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Value for ${entry.key}`}
ref={index === 0 ? firstInputRef : undefined}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
@@ -694,6 +695,7 @@ function AllKeysModal({
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Value for ${entry.key}`}
autoFocus={index === 0}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
@@ -131,7 +131,9 @@ describe("ExternalConnectModal — tab switching", () => {
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
const preEl = document.querySelector("pre");
// Query within the python panel so we get the right pre (not the first in DOM).
const pythonPanel = document.querySelector("[data-testid='panel-python']");
const preEl = pythonPanel?.querySelector("pre");
expect(preEl?.textContent).toContain("AUTH_TOKEN");
// The placeholder is replaced with the real auth token
expect(preEl?.textContent).toContain("secret-auth-token-abc");
@@ -140,7 +142,9 @@ describe("ExternalConnectModal — tab switching", () => {
it("switches to the curl tab and shows the snippet with stamped token", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
const preEl = document.querySelector("pre");
// Query within the curl panel so we get the right pre (not the first in DOM).
const curlPanel = document.querySelector("[data-testid='panel-curl']");
const preEl = curlPanel?.querySelector("pre");
expect(preEl?.textContent).toContain("curl");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
@@ -148,9 +152,11 @@ describe("ExternalConnectModal — tab switching", () => {
it("switches to the Fields tab and shows raw values", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
expect(screen.getByText("ws-123")).toBeTruthy();
expect(screen.getByText("https://app.example.com")).toBeTruthy();
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
// Query within the fields panel for specific values.
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
expect(fieldsPanel?.textContent).toContain("ws-123");
expect(fieldsPanel?.textContent).toContain("https://app.example.com");
expect(fieldsPanel?.textContent).toContain("secret-auth-token-abc");
});
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
@@ -168,7 +174,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
const preEl = document.querySelector("pre");
const pythonPanel = document.querySelector("[data-testid='panel-python']");
const preEl = pythonPanel?.querySelector("pre");
expect(preEl?.textContent).not.toContain("<paste from create response>");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
@@ -176,7 +183,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
it("stamps the real auth_token into the curl snippet", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
const preEl = document.querySelector("pre");
const curlPanel = document.querySelector("[data-testid='panel-curl']");
const preEl = curlPanel?.querySelector("pre");
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
@@ -184,7 +192,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
it("stamps the real auth_token into the Universal MCP snippet", () => {
renderAndFlush(defaultInfo);
// Default tab is Universal MCP
const preEl = document.querySelector("pre");
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
const preEl = mcpPanel?.querySelector("pre");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
expect(preEl?.textContent).not.toContain("<paste from create response>");
});
@@ -193,8 +202,10 @@ describe("ExternalConnectModal — snippet token stamping", () => {
describe("ExternalConnectModal — copy functionality", () => {
it("calls navigator.clipboard.writeText with the snippet text", () => {
renderAndFlush(defaultInfo);
// Default tab is Universal MCP
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
// Default tab is Universal MCP — query the copy button within the mcp panel.
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
const copyBtn = mcpPanel?.querySelector("button");
if (copyBtn) fireEvent.click(copyBtn);
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining("secret-auth-token-abc"),
);
@@ -227,7 +238,8 @@ describe("ExternalConnectModal — missing optional fields", () => {
};
renderAndFlush(minimalInfo);
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
expect(screen.getByText("(missing)")).toBeTruthy();
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
expect(fieldsPanel?.textContent).toContain("(missing)");
});
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
@@ -223,6 +223,7 @@ export function MobileCanvas({
textTransform: "uppercase",
fontWeight: 600,
}}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
Reset
</button>
+16 -2
View File
@@ -356,6 +356,7 @@ export function MobileChat({
type="button"
onClick={onBack}
aria-label="Back"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 36,
height: 36,
@@ -402,6 +403,7 @@ export function MobileChat({
<button
type="button"
aria-label="More"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 36,
height: 36,
@@ -432,6 +434,7 @@ export function MobileChat({
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
padding: "4px 0 8px",
border: "none",
@@ -495,6 +498,8 @@ export function MobileChat({
onClick={() => {
loadInitial();
}}
aria-label="Retry loading chat history"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
style={{
padding: "6px 14px",
borderRadius: 14,
@@ -664,6 +669,7 @@ export function MobileChat({
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
border: "none",
background: "transparent",
@@ -704,6 +710,7 @@ export function MobileChat({
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 32,
height: 32,
@@ -725,6 +732,7 @@ export function MobileChat({
ref={composerRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="Message"
onKeyDown={(e) => {
// Enter sends; Shift+Enter inserts a newline. Skip when the
// IME is composing — pressing Enter to commit a Chinese/
@@ -748,7 +756,12 @@ export function MobileChat({
border: "none",
outline: "none",
background: "transparent",
fontSize: 14.5,
// iOS Safari/PWA zooms the viewport when a focused textarea
// has a computed font-size below 16px. 14.5 triggers that
// focus-zoom; the page looks broken until the user pinches
// back (#224, same class as desktop #1434 / sibling #225).
// 16px is the minimum that keeps focus from zooming.
fontSize: 16,
lineHeight: 1.4,
color: p.text,
padding: "6px 0",
@@ -764,12 +777,13 @@ export function MobileChat({
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
aria-label="Send"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: (draft.trim() || pendingFiles.length === 0) && !sending && !uploading ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
@@ -231,6 +231,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
fontSize: 13,
fontWeight: 500,
}}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
{o.label}
<span
@@ -83,11 +83,12 @@ export function MobileDetail({
type="button"
onClick={onBack}
aria-label="Back"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={iconButtonStyle(p, dark)}
>
{Icons.back({ size: 18 })}
</button>
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
<button type="button" aria-label="More" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900" style={iconButtonStyle(p, dark)}>
{Icons.more({ size: 18 })}
</button>
</div>
@@ -183,6 +184,7 @@ export function MobileDetail({
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
padding: "8px 14px",
borderRadius: 999,
@@ -215,6 +217,7 @@ export function MobileDetail({
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: "100%",
height: 52,
@@ -200,6 +200,7 @@ export function MobileHome({
justifyContent: "center",
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
}}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
{Icons.plus({ size: 22 })}
</button>
@@ -92,6 +92,7 @@ export function MobileMe({
border: on ? `2px solid ${p.text}` : "2px solid transparent",
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
}}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
/>
);
})}
@@ -184,6 +185,7 @@ function SegmentedRow({
fontSize: 13,
fontWeight: 600,
}}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
{o.label}
</button>
+14 -1
View File
@@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
type="button"
onClick={onClose}
aria-label="Close"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 32,
height: 32,
@@ -216,6 +217,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTplId(t.id);
setTier(tCode);
}}
aria-label={`Select template: ${t.name} (tier ${t.tier})`}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
background: on
? dark
@@ -304,6 +307,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
<input
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="Agent name"
placeholder={tplId
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
: "agent-name"}
@@ -314,7 +318,12 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
border: `0.5px solid ${p.border}`,
borderRadius: 12,
fontFamily: MOBILE_FONT_MONO,
fontSize: 13.5,
// iOS Safari/PWA zooms the viewport when a focused input has
// a computed font-size below 16px; the layout jumps and the
// page looks broken until the user pinches back (#224 / #225,
// same class as desktop #1434). 16px is the minimum that
// suppresses that focus-zoom.
fontSize: 16,
color: p.text,
outline: "none",
boxSizing: "border-box",
@@ -332,6 +341,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
key={t}
type="button"
onClick={() => setTier(t)}
aria-label={`Select tier ${t}: ${TIER_LABEL[t]}`}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
flex: 1,
padding: "10px 8px",
@@ -379,6 +390,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
type="button"
onClick={handleSpawn}
disabled={busy || !tplId || templates.length === 0}
aria-label="Spawn agent"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: "100%",
height: 52,
@@ -263,6 +263,20 @@ describe("MobileChat — composer", () => {
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(true);
});
// Regression #224: the composer textarea must render with font-size
// ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a focused
// input has a computed font-size below 16px — the layout jumps and
// the page looks broken until the user pinches back. Same class as
// desktop #1434 / sibling MobileSpawn #225.
it("composer textarea renders at font-size 16px or greater (iOS focus-zoom regression #224)", () => {
const { container } = renderChat(mockAgentId);
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea).toBeTruthy();
const fs = Number.parseFloat(textarea.style.fontSize);
expect(Number.isFinite(fs)).toBe(true);
expect(fs).toBeGreaterThanOrEqual(16);
});
});
// ─── Tabs ─────────────────────────────────────────────────────────────────────
@@ -93,6 +93,24 @@ describe("MobileSpawn — render", () => {
expect(input).toBeTruthy();
});
// Regression #224 / #225: the agent-name input must render with a
// font-size ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a
// focused input has a computed font-size below 16px — the layout
// jumps and the page looks broken until the user pinches back.
it("renders the name input at font-size 16px or greater (iOS focus-zoom regression)", () => {
apiGetSpy.mockResolvedValue(mockTemplates);
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
const input = document.querySelector(
'input[aria-label="Agent name"]',
) as HTMLInputElement | null;
expect(input).toBeTruthy();
// Parse the inline style font-size — jsdom doesn't run a layout
// engine, so getComputedStyle reports the inline value verbatim.
const fs = Number.parseFloat(input!.style.fontSize);
expect(Number.isFinite(fs)).toBe(true);
expect(fs).toBeGreaterThanOrEqual(16);
});
it("renders all 4 tier buttons", () => {
apiGetSpy.mockResolvedValue(mockTemplates);
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
@@ -133,6 +133,7 @@ export function TabBar({
aria-label={t.label}
onClick={() => onChange(t.id)}
onKeyDown={(e) => handleKeyDown(e, idx)}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
background: "none",
border: "none",
@@ -291,6 +292,7 @@ export function AgentCard({
data-testid="workspace-card"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
display: "block",
width: "100%",
@@ -444,6 +446,7 @@ export function FilterChips({
type="button"
aria-checked={on}
onClick={() => onChange(o.id)}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
display: "inline-flex",
alignItems: "center",
+2 -1
View File
@@ -225,7 +225,7 @@ function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) {
<div className="mt-2 text-[10px] text-ink-mid">Saving</div>
)}
{error && (
<div className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
<div role="alert" aria-live="assertive" className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
{error}
</div>
)}
@@ -919,6 +919,7 @@ export function ConfigTab({ workspaceId }: Props) {
<label className="text-[10px] text-ink-mid block mb-1">Model</label>
<input
type="text"
aria-label="Model"
value={currentModelId}
onChange={(e) => {
const v = e.target.value;
+2 -2
View File
@@ -266,7 +266,7 @@ function PlatformOwnedFilesTab({
// immediately. Delete-All hovers DARKER (bg-red-700) — same AA
// contrast trap that bit ConfirmDialog/ApprovalBanner. Cancel
// lifts to surface-elevated instead of the prior no-op hover.
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p id="files-delete-all-msg" className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<div className="flex gap-2">
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
@@ -280,7 +280,7 @@ function PlatformOwnedFilesTab({
)}
{confirmDelete && (
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p id="files-delete-one-msg" className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div className="flex gap-2">
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
@@ -405,7 +405,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
</p>
<button
onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1"
>
Retry
</button>
@@ -610,7 +610,7 @@ function PeerTabButton({
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={onClick}
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/60 focus-visible:ring-offset-1 ${
active
? "border-b-2 border-cyan-500 text-cyan-200"
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({
<button
onClick={onRemove}
aria-label={`Remove ${file.name}`}
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0"
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1"
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
@@ -62,8 +62,9 @@ export function AttachmentChip({
return (
<button
onClick={() => onDownload(attachment)}
aria-label={`Download ${attachment.name}`}
title={`Download ${attachment.name}`}
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full ${toneClasses}`}
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 ${toneClasses}`}
>
<FileGlyph className="shrink-0 opacity-70" />
<span className="truncate">{attachment.name}</span>
@@ -351,8 +351,10 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
{showAdd ? (
<div className="bg-surface-card/50 rounded p-2 space-y-1.5 border border-line/50">
<input value={newKey} onChange={(e) => setNewKey(e.target.value.toUpperCase())} placeholder="KEY_NAME"
aria-label="Secret key name"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] font-mono text-ink focus:outline-none focus:border-accent" />
<input value={newValue} onChange={(e) => setNewValue(e.target.value)} placeholder="Value" type="password"
aria-label="Secret value"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] text-ink focus:outline-none focus:border-accent" />
<div className="flex gap-2">
<button type="button" onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
+82 -1
View File
@@ -63,7 +63,7 @@ class MockWebSocket {
(globalThis as unknown as Record<string, unknown>).WebSocket = MockWebSocket;
// Now import the socket module (uses globalThis.WebSocket at call time)
import { connectSocket, disconnectSocket } from "../socket";
import { connectSocket, disconnectSocket, wakeSocket } from "../socket";
import { useCanvasStore } from "../canvas";
// ---------------------------------------------------------------------------
@@ -416,3 +416,84 @@ describe("RehydrateDedup", () => {
expect(d.shouldSkip(2_700)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// wakeSocket() — visibility-wake reconnect (regression #223 / #228)
// ---------------------------------------------------------------------------
//
// Mobile browsers (iOS Safari, Chrome on Android in deep-sleep) silently
// drop the WebSocket when the tab is backgrounded; the in-page onclose
// fires very late or never. Without a visibility wake, the canvas stays
// frozen until the user manually refreshes.
//
// The real wiring lives at module level: connectSocket installs a
// visibilitychange/pageshow listener that calls wake() on foreground.
// We can't dispatch DOM events here because the suite runs under the
// `node` test environment (no `document`/`window` — see canvas/vitest
// .config.ts). Instead we test wake() directly through the wakeSocket
// public export, which is the same code path the listener invokes.
describe("wakeSocket → reconnect (#223 / #228 — mobile visibility wake)", () => {
it("wake on a healthy OPEN socket does not create a new WebSocket", () => {
connectSocket();
const ws = getLastWS();
ws.triggerOpen();
// OPEN === 1. wake() should take the healthy-no-op branch.
(ws as unknown as { readyState: number }).readyState = 1;
const before = MockWebSocket.instances.length;
wakeSocket();
expect(MockWebSocket.instances.length).toBe(before);
});
it("wake on a CLOSED socket creates a new WebSocket (the actual #223 fix)", () => {
connectSocket();
const ws = getLastWS();
ws.triggerOpen();
// CLOSED === 3. Simulates the OS killing the socket while the tab
// was backgrounded. We deliberately don't fire triggerClose() —
// the whole point of #223 is that mobile browsers don't fire
// onclose when they kill the WS, so reconnect never schedules.
(ws as unknown as { readyState: number }).readyState = 3;
const before = MockWebSocket.instances.length;
wakeSocket();
expect(MockWebSocket.instances.length).toBe(before + 1);
});
it("wake while CONNECTING (readyState=0) does not pile another handshake", () => {
connectSocket();
const ws = getLastWS();
// CONNECTING === 0 — a handshake is already in flight.
(ws as unknown as { readyState: number }).readyState = 0;
const before = MockWebSocket.instances.length;
wakeSocket();
expect(MockWebSocket.instances.length).toBe(before);
});
it("wake cancels any pending backoff reconnect", () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
connectSocket();
const ws = getLastWS();
ws.triggerOpen();
// Drop the socket — onclose schedules a backoff reconnect.
ws.triggerClose();
// Now wake the page. wake() should pre-empt the backoff so the
// user sees the canvas come back immediately, not after the
// exponential delay window.
(ws as unknown as { readyState: number }).readyState = 3;
clearTimeoutSpy.mockClear();
wakeSocket();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it("wake after disconnectSocket is a no-op (no zombie reconnect)", () => {
connectSocket();
const ws = getLastWS();
ws.triggerOpen();
disconnectSocket();
const before = MockWebSocket.instances.length;
// Singleton is null now — wake() should silently do nothing.
expect(() => wakeSocket()).not.toThrow();
expect(MockWebSocket.instances.length).toBe(before);
});
});
+88
View File
@@ -268,6 +268,46 @@ class ReconnectingSocket {
}
useCanvasStore.getState().setWsStatus("disconnected");
}
/** Force a reconnect attempt now, skipping the backoff window.
* Used by the visibilitychange / pageshow handler: when a mobile
* browser backgrounds the tab, the OS silently kills the WebSocket
* but the in-page onclose either fires very late or never fires at
* all (iOS Safari, Chrome on Android in deep-sleep). Once the user
* brings the tab back, the canvas needs to reconnect within human
* perception — not on whatever backoff delay was last scheduled,
* which can be up to 30s. (#223 / #228)
*
* Idempotent: if the socket is already OPEN we leave it alone; the
* WebSocket is still healthy and a reconnect would just churn. */
wake() {
if (this.disposed) return;
// OPEN === 1. Use the numeric literal so we don't have to import
// WebSocket type values; the runtime constant is well-defined.
if (this.ws && this.ws.readyState === 1) {
// Healthy. Run a rehydrate to catch any events we may have missed
// while the tab was backgrounded — the OS does deliver some
// packets late, but it can also drop them, and the dedup gate
// collapses this with any subsequent health-check rehydrate.
void this.rehydrate();
return;
}
// CONNECTING === 0 means a handshake is already in flight. Don't
// pile another one on; the existing attempt or its onclose-driven
// reconnect will resolve.
if (this.ws && this.ws.readyState === 0) return;
// Otherwise (CLOSING, CLOSED, or null) we're in limbo. Cancel any
// pending backoff and reconnect now.
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// Reset attempt counter so the *next* failure (if any) starts from
// a short delay again — we just had a real user interaction, not
// an unattended-tab failure cascade.
this.attempt = 0;
this.connect();
}
}
export interface WorkspaceData {
@@ -306,11 +346,49 @@ export interface WorkspaceData {
let socket: ReconnectingSocket | null = null;
/** visibilitychange / pageshow handler. Mobile browsers (iOS Safari,
* Chrome on Android in deep-sleep) silently drop the WebSocket when
* the tab is backgrounded — the in-page `onclose` fires very late or
* never. Without this listener, the canvas appears frozen after the
* user backgrounds the PWA and returns to it: status events, agent
* messages, and cross-device chat broadcast don't arrive until a
* manual refresh (#223 / #228).
*
* Both events are wired: `visibilitychange` covers tab-switch on a
* live page; `pageshow` covers Safari's bfcache restore, where the
* page comes back from cache without firing visibilitychange. */
function onPageWake() {
// document is undefined in SSR; the listener never installs there,
// but defensively guard anyway in case this code is run via a test
// harness that doesn't shim it.
if (typeof document !== "undefined" && document.hidden) return;
socket?.wake();
}
let visibilityHandlerInstalled = false;
function installVisibilityHandler() {
if (visibilityHandlerInstalled) return;
if (typeof document === "undefined" || typeof window === "undefined") return;
document.addEventListener("visibilitychange", onPageWake);
// `pageshow` with `event.persisted === true` is the bfcache restore
// signal — relevant on iOS Safari. We don't need to inspect
// `persisted` because waking an OPEN socket is a no-op.
window.addEventListener("pageshow", onPageWake);
visibilityHandlerInstalled = true;
}
function uninstallVisibilityHandler() {
if (!visibilityHandlerInstalled) return;
if (typeof document === "undefined" || typeof window === "undefined") return;
document.removeEventListener("visibilitychange", onPageWake);
window.removeEventListener("pageshow", onPageWake);
visibilityHandlerInstalled = false;
}
export function connectSocket() {
if (!socket) {
socket = new ReconnectingSocket(WS_URL);
}
socket.connect();
installVisibilityHandler();
}
export function disconnectSocket() {
@@ -318,4 +396,14 @@ export function disconnectSocket() {
socket.disconnect();
socket = null;
}
uninstallVisibilityHandler();
}
/** Manually trigger the visibility-wake path. Exported so the test suite
* can exercise `ReconnectingSocket.wake()` without depending on a
* jsdom DOM (the rest of this file's tests run under the node env).
* Real-world callers don't need this — the visibility/pageshow listener
* drives it. */
export function wakeSocket() {
socket?.wake();
}
+4
View File
@@ -584,6 +584,10 @@
.secrets-tab__refresh-btn:hover {
background: #1e40af;
}
.secrets-tab__refresh-btn:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
.secrets-tab__no-results {
text-align: center;
+17 -6
View File
@@ -311,8 +311,17 @@ locally.
deps from your system Python. Plain `pip install --user` works
but the binary lands in `~/.local/bin` (Linux) or
`~/Library/Python/3.X/bin` (macOS) which is often not on PATH on
a fresh shell — `claude mcp add molecule -- molecule-mcp` then
fails with "command not found" at first use.
a fresh shell — `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
then fails with "command not found" at first use.
* **Server name in `claude mcp add` is workspace-specific.** The
Canvas "Add to Claude Code" snippet stamps a unique slug
(`molecule-<workspace-name>`) so a single Claude Code session can
talk to N molecule workspaces concurrently — `claude mcp add` keys
entries by name in `~/.claude.json`, so re-running with a bare
`molecule` name silently overwrites the prior workspace's entry.
See [molecule-core#1535](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/1535)
for the canonical generator.
### Install
@@ -336,8 +345,10 @@ WORKSPACE_ID=<uuid> \\
That exposes the same 8 platform tools (`delegate_task`, `list_peers`,
`send_message_to_user`, `commit_memory`, etc.) that container-bound
runtimes already get via the workspace's auto-spawned MCP. Register
the binary in your agent's MCP config (e.g. Claude Code's
`claude mcp add molecule -- molecule-mcp` with the env above).
the binary in your agent's MCP config — use a workspace-specific
server name so multi-workspace setups don't collide (e.g. Claude Code:
`claude mcp add molecule-<workspace-slug> -- molecule-mcp` with the env
above; the Canvas modal stamps the right slug for you).
### Keeping the token out of shell history
@@ -375,8 +386,8 @@ hold:
wheel does (see `_build_initialize_result`). Nothing for you to
do.
2. **Claude Code installs the server as a marketplace plugin** — a
plain `claude mcp add molecule -- molecule-mcp` produces a
non-plugin-sourced server, which Claude Code rejects with
plain `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
produces a non-plugin-sourced server, which Claude Code rejects with
`channel_enable requires a marketplace plugin`. Until the
official `moleculesai/claude-code-plugin` marketplace lands
(tracking [#2936](https://git.moleculesai.app/molecule-ai/molecule-core/issues/2936)),
@@ -0,0 +1,35 @@
// Command t4-contract-dump prints the T4 privilege contract as YAML.
//
// Usage:
//
// go run ./workspace-server/cmd/t4-contract-dump > t4_capabilities.yaml
//
// This is the seam that template-repo CI workflows consume:
//
// - Template CI fetches molecule-core at pinned ref
// - Runs `go run ./workspace-server/cmd/t4-contract-dump` to produce
// t4_capabilities.yaml
// - Iterates capabilities and runs each Probe inside a freshly-built
// privileged container
// - Aggregates structured pass/fail; fails the gate on any hard miss.
//
// Keeping this trivial and pure-stdlib means a fork user does not need
// a Molecule-AI Gitea token or any internal infrastructure to consume
// the contract — `go run` against molecule-core's public source is
// enough.
package main
import (
"fmt"
"os"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
)
func main() {
caps := provisioner.T4PrivilegeContract()
if _, err := os.Stdout.WriteString(provisioner.AsYAML(caps)); err != nil {
fmt.Fprintln(os.Stderr, "t4-contract-dump: write failed:", err)
os.Exit(1)
}
}
@@ -24,17 +24,30 @@ import (
// BuildExternalConnectionPayload assembles the gin.H payload that the
// canvas's ExternalConnectModal consumes. Pure data — caller owns DB
// reads (workspace_id) and token minting (auth_token).
// reads (workspace_id, workspace_name) and token minting (auth_token).
//
// authToken may be empty for the read-only "show instructions again"
// path; the modal masks the field in that case rather than displaying
// an empty string.
func BuildExternalConnectionPayload(platformURL, workspaceID, authToken string) gin.H {
//
// workspaceName feeds the per-workspace MCP server-name in the snippets
// that wire molecule-mcp into an external Claude Code (or other
// MCP-stdio) client. Without a unique server name a second
// `claude mcp add molecule` call REPLACES the first entry, collapsing
// multi-workspace use into a single per-session slot — see
// mcpServerNameForWorkspace below. May be empty (re-show / rotate paths
// that don't plumb the name); the helper falls back to the workspace
// ID's short prefix so the snippet is always unique.
func BuildExternalConnectionPayload(platformURL, workspaceID, workspaceName, authToken string) gin.H {
pURL := strings.TrimSuffix(platformURL, "/")
mcpName := mcpServerNameForWorkspace(workspaceID, workspaceName)
stamp := func(tmpl string) string {
return strings.ReplaceAll(
strings.ReplaceAll(tmpl, "{{PLATFORM_URL}}", pURL),
"{{WORKSPACE_ID}}", workspaceID,
strings.ReplaceAll(
strings.ReplaceAll(tmpl, "{{PLATFORM_URL}}", pURL),
"{{WORKSPACE_ID}}", workspaceID,
),
"{{MCP_SERVER_NAME}}", mcpName,
)
}
return gin.H{
@@ -77,6 +90,81 @@ func externalPlatformURL(c *gin.Context) string {
return scheme + "://" + host
}
// mcpServerNameForWorkspace derives the unique MCP server name used in
// the Universal MCP snippet's `claude mcp add <name> -- ...` line.
//
// Why per-workspace, not a fixed "molecule": `claude mcp add` keys
// entries by name in ~/.claude.json, so re-running with the same name
// silently REPLACES the previous entry. A single external Claude Code
// session that connects to N molecule workspaces must therefore use N
// distinct server names — otherwise the second install collapses the
// first, and the user experiences "MCP is per-session". MCP itself
// supports many servers per session; the install-snippet name was the
// only thing standing in the way.
//
// Pattern: "molecule-<slug>" where slug comes from the workspace name
// (lowercased, non-alphanumeric → hyphen, collapsed, trimmed, <=24
// chars). Falls back to the workspace ID's first 8 chars when the name
// is empty or slugifies to nothing — both produce a deterministic,
// Claude-Code-name-safe (alphanumeric + hyphens, no spaces / dots /
// slashes) identifier that disambiguates per-workspace.
//
// Two workspaces with identical names still produce identical slugs by
// design — the user picked them to look the same. The
// `claude mcp add` step will overwrite the older one in that case;
// the workaround is to rename one, then re-run. Documented in the
// snippet header so users aren't surprised.
func mcpServerNameForWorkspace(workspaceID, workspaceName string) string {
const fallbackIDPrefixLen = 8
const maxSlugLen = 24
slug := slugifyForMcpName(workspaceName, maxSlugLen)
if slug == "" {
id := strings.ReplaceAll(workspaceID, "-", "")
if len(id) > fallbackIDPrefixLen {
id = id[:fallbackIDPrefixLen]
}
slug = id
}
if slug == "" {
// Defensive: empty workspaceID at this layer means the caller
// is misusing the API; we still return a usable (non-colliding
// in the common case) constant rather than producing "molecule-"
// which Claude Code would reject.
return "molecule"
}
return "molecule-" + slug
}
// slugifyForMcpName lowercases, replaces non-[a-z0-9] runs with a single
// '-', trims leading/trailing '-', and truncates to maxLen. Returns ""
// if nothing usable remains. Pure helper; no allocations beyond the
// builder.
func slugifyForMcpName(s string, maxLen int) string {
var b strings.Builder
b.Grow(len(s))
lastHyphen := true // suppress leading hyphens
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r + ('a' - 'A'))
lastHyphen = false
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
lastHyphen = false
default:
if !lastHyphen {
b.WriteByte('-')
lastHyphen = true
}
}
}
out := strings.TrimRight(b.String(), "-")
if len(out) > maxLen {
out = strings.TrimRight(out[:maxLen], "-")
}
return out
}
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
@@ -216,6 +304,14 @@ const externalUniversalMcpTemplate = `# Universal MCP — standalone register +
# for any MCP-aware runtime (Claude Code, hermes, codex, etc.).
# Pair with the Claude Code or Python SDK tab if your runtime needs
# inbound A2A delivery (canvas messages → agent conversation turns).
#
# Multi-workspace: MCP supports many servers per Claude Code session.
# This snippet uses a workspace-specific server name ({{MCP_SERVER_NAME}})
# so installing for a second workspace ADDS another entry instead of
# overwriting the first — run the snippet from each workspace's modal
# in turn and ` + "`claude mcp list`" + ` will show all of them. If two
# workspaces have the same name, slugs collide and the second install
# overwrites the first; rename one workspace to disambiguate.
# Requires Python >= 3.11. On 3.10 or older pip says
# "Could not find a version that satisfies the requirement
@@ -224,11 +320,14 @@ const externalUniversalMcpTemplate = `# Universal MCP — standalone register +
# Upgrade the interpreter (brew install python@3.12 / apt install
# python3.12 / etc.) or use a 3.11+ venv.
# 1. Install the workspace runtime wheel:
# 1. Install the workspace runtime wheel (once per machine — safe to
# re-run; subsequent workspaces share the same wheel):
pip install molecule-ai-workspace-runtime
# 2. Wire molecule-mcp into your agent's MCP config. Claude Code:
claude mcp add molecule -s user -- env \
# NOTE the server name is workspace-specific ("{{MCP_SERVER_NAME}}") so
# multiple molecule workspaces co-exist in one Claude Code session.
claude mcp add {{MCP_SERVER_NAME}} -s user -- env \
WORKSPACE_ID={{WORKSPACE_ID}} \
PLATFORM_URL={{PLATFORM_URL}} \
MOLECULE_WORKSPACE_TOKEN="<paste from create response>" \
@@ -249,8 +348,11 @@ claude mcp add molecule -s user -- env \
# Documentation: https://doc.moleculesai.app/docs/guides/mcp-server-setup
# Common errors:
# • "Tools not appearing in your agent" — run ` + "`claude mcp list`" + ` (or
# your runtime's equivalent) and confirm the molecule entry. If
# missing, re-run the ` + "`claude mcp add`" + ` line above.
# your runtime's equivalent) and confirm the {{MCP_SERVER_NAME}} entry.
# If missing, re-run the ` + "`claude mcp add`" + ` line above.
# • "Connecting a second workspace overwrote the first" — re-check that
# the server name in the line above is {{MCP_SERVER_NAME}} (not a bare
# "molecule"); each workspace's modal generates a distinct name.
# • "ConnectionRefused / DNS error on first call" — PLATFORM_URL must
# include the scheme (https://) and have NO trailing slash. Verify
# with: curl ${PLATFORM_URL}/healthz
@@ -331,6 +433,13 @@ const externalHermesChannelTemplate = `# Hermes channel — bridges this workspa
# hermes-agent session. No tunnel/public URL needed (long-poll based,
# same shape as the Claude Code channel).
#
# Multi-workspace: each workspace's plugin_platforms entry is keyed by a
# workspace-specific slug ("{{MCP_SERVER_NAME}}") so two molecule
# workspaces can coexist in one hermes config — YAML rejects duplicate
# mapping keys, so re-using the same "molecule:" key for a second
# workspace would silently overwrite the first. Re-running this snippet
# for another workspace ADDS a sibling entry instead.
#
# Prereq: a hermes-agent install on the target machine. Latest builds
# (post #17751) ship the platform-plugin API natively; older ones are
# also supported via the plugin's dual-mode fallback.
@@ -345,13 +454,17 @@ export MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
export MOLECULE_WORKSPACE_TOKEN="<paste from create response>"
# 3. Edit ~/.hermes/config.yaml — under your existing top-level
# gateway: block, add a plugin_platforms entry:
# gateway: block, add a plugin_platforms entry. The platform key
# ({{MCP_SERVER_NAME}}) is workspace-specific so multiple molecule
# workspaces coexist; re-using the same key for a second workspace
# would silently overwrite the first (YAML duplicate-key collapse):
#
# gateway:
# # ...your existing gateway settings...
# plugin_platforms:
# molecule:
# {{MCP_SERVER_NAME}}:
# enabled: true
# workspace_id: {{WORKSPACE_ID}}
#
# If you don't yet have a gateway: block, create one with just
# that plugin_platforms entry. Don't append blindly — YAML
@@ -404,6 +517,14 @@ hermes gateway --replace
const externalCodexTemplate = `# Codex external setup — outbound tools (MCP) + inbound push (bridge).
# For operators whose external agent is a codex CLI (@openai/codex)
# session.
#
# Multi-workspace: the TOML table name is workspace-specific
# ("{{MCP_SERVER_NAME}}") so two molecule workspaces can coexist in one
# ~/.codex/config.toml — TOML rejects duplicate
# [mcp_servers.<name>] tables, so re-using a bare "molecule" name for a
# second workspace would either break codex parsing or silently
# overwrite the first. Re-running this snippet for another workspace
# ADDS a sibling table instead.
# 1. Install codex CLI, the workspace runtime, and the bridge daemon:
npm install -g @openai/codex@latest
@@ -412,23 +533,21 @@ pip install codex-channel-molecule
# 2. Wire the molecule MCP server into codex's config.toml — this is
# the OUTBOUND path (codex calls list_peers / delegate_task /
# send_message_to_user / commit_memory).
#
# Don't append blindly — TOML rejects duplicate
# [mcp_servers.molecule] tables, so re-running on an existing
# config will break codex parsing. If [mcp_servers.molecule]
# already exists (e.g. you set this up before), replace the
# existing block instead of appending.
# send_message_to_user / commit_memory). The table name
# ({{MCP_SERVER_NAME}}) is workspace-specific; re-running the
# snippet for a DIFFERENT workspace appends a sibling table without
# touching the first. Re-running for the SAME workspace produces
# the same name, so replace the existing block instead of appending.
mkdir -p ~/.codex
# (then open ~/.codex/config.toml in your editor and paste:)
#
# [mcp_servers.molecule]
# [mcp_servers.{{MCP_SERVER_NAME}}]
# command = "molecule-mcp"
# args = []
# startup_timeout_sec = 30
#
# [mcp_servers.molecule.env]
# [mcp_servers.{{MCP_SERVER_NAME}}.env]
# WORKSPACE_ID = "{{WORKSPACE_ID}}"
# PLATFORM_URL = "{{PLATFORM_URL}}"
# MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"
@@ -472,11 +591,13 @@ codex
# Need help?
# Documentation: https://doc.moleculesai.app/docs/guides/mcp-server-setup
# Common errors:
# • [mcp_servers.molecule] not loaded — codex must be ≥ 0.57.
# • [mcp_servers.{{MCP_SERVER_NAME}}] not loaded — codex must be ≥ 0.57.
# Check with ` + "`codex --version`" + `; upgrade via npm install -g @openai/codex@latest.
# • TOML parse error after re-running setup — TOML rejects duplicate
# [mcp_servers.molecule] tables. Open ~/.codex/config.toml and
# remove the old block before pasting the new one.
# • TOML parse error after re-running setup for the SAME workspace —
# TOML rejects duplicate [mcp_servers.<name>] tables. Open
# ~/.codex/config.toml and remove the old block before pasting the
# new one. (A second molecule workspace gets a DIFFERENT table
# name, so coexisting workspaces don't conflict.)
# • Canvas messages don't wake codex — step 3 (codex-channel-molecule
# bridge daemon) is required for inbound push. Check
# pgrep -f codex-channel-molecule and tail ~/.codex-channel-molecule/daemon.log.
@@ -502,23 +623,23 @@ const externalKimiTemplate = `# Kimi CLI external setup — register + heartbeat
pip install molecule-ai-workspace-runtime
# 2. Save credentials and the bridge script:
mkdir -p ~/.molecule-ai/kimi-workspace
chmod 700 ~/.molecule-ai/kimi-workspace
cat > ~/.molecule-ai/kimi-workspace/env <<'EOF'
mkdir -p ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}
chmod 700 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}
cat > ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/env <<'EOF'
WORKSPACE_ID={{WORKSPACE_ID}}
PLATFORM_URL={{PLATFORM_URL}}
MOLECULE_WORKSPACE_TOKEN=<paste from create response>
EOF
chmod 600 ~/.molecule-ai/kimi-workspace/env
chmod 600 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/env
cat > ~/.molecule-ai/kimi-workspace/kimi_bridge.py <<'PYEOF'
cat > ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py <<'PYEOF'
#!/usr/bin/env python3
"""Kimi bridge — keeps workspace online and polls for canvas messages."""
import json, logging, time
from pathlib import Path
import httpx
ENV = Path.home() / ".molecule-ai" / "kimi-workspace" / "env"
ENV = Path.home() / ".molecule-ai" / "kimi-{{MCP_SERVER_NAME}}" / "env"
HEARTBEAT_INTERVAL = 20
POLL_INTERVAL = 5
@@ -608,10 +729,10 @@ def main():
if __name__ == "__main__":
main()
PYEOF
chmod +x ~/.molecule-ai/kimi-workspace/kimi_bridge.py
chmod +x ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py
# 3. Start the bridge (run in a persistent terminal or via launchd):
python3 ~/.molecule-ai/kimi-workspace/kimi_bridge.py
python3 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py
# What the script does:
# • Registers the workspace in poll mode (no public URL needed)
@@ -622,7 +743,7 @@ python3 ~/.molecule-ai/kimi-workspace/kimi_bridge.py
# To change the reply logic, edit the send_reply() call inside the loop.
# To send a one-off reply from another terminal:
# curl -fsS -X POST "{{PLATFORM_URL}}/workspaces/{{WORKSPACE_ID}}/notify" \
# -H "Authorization: Bearer $(cat ~/.molecule-ai/kimi-workspace/env | grep TOKEN | cut -d= -f2)" \
# -H "Authorization: Bearer $(cat ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/env | grep TOKEN | cut -d= -f2)" \
# -H "Content-Type: application/json" \
# -d '{"message":"Hello from Kimi"}'
#
@@ -644,6 +765,13 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
# sessions.steer push path; an external setup would need the same
# bridge daemon the template uses. For inbound delivery on an
# external machine today, pair with the Python SDK tab.
#
# Multi-workspace: each workspace registers under a workspace-specific
# MCP server name ("{{MCP_SERVER_NAME}}"). openclaw keys MCP servers by
# name in its config (~/.openclaw/mcp/<name>.json), so re-running with
# a bare "molecule" name would overwrite the prior workspace's entry.
# Re-run this snippet for another workspace to ADD a sibling entry
# instead.
# 1. Install openclaw CLI + the workspace runtime wheel:
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
@@ -674,7 +802,7 @@ pip install "molecule-ai-workspace-runtime>=0.1.999"
# workspace as awaiting_agent (OFFLINE) within 60-90s even while
# tools work.
WORKSPACE_TOKEN="<paste from create response>"
openclaw mcp set molecule "$(cat <<EOF
openclaw mcp set {{MCP_SERVER_NAME}} "$(cat <<EOF
{
"command": "molecule-mcp",
"args": [],
@@ -704,6 +832,6 @@ openclaw agent --message "list my peers"
# • Gateway not starting — tail ~/.openclaw/gateway.log. The loopback
# bind requires :18789 to be free; check with ` + "`lsof -iTCP:18789`" + `.
# • ` + "`openclaw mcp set`" + ` rejected — the heredoc generates JSON;
# verify with ` + "`jq < ~/.openclaw/mcp/molecule.json`" + ` and re-run
# verify with ` + "`jq < ~/.openclaw/mcp/{{MCP_SERVER_NAME}}.json`" + ` and re-run
# ` + "`openclaw mcp set`" + ` if the file is malformed.
`
@@ -52,7 +52,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
}
ctx := c.Request.Context()
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
runtime, name, err := lookupWorkspaceRuntimeAndName(ctx, db.DB, id)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
@@ -108,7 +108,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
platformURL := externalPlatformURL(c)
c.JSON(http.StatusOK, gin.H{
"connection": BuildExternalConnectionPayload(platformURL, id, tok),
"connection": BuildExternalConnectionPayload(platformURL, id, name, tok),
})
}
@@ -129,7 +129,7 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
}
ctx := c.Request.Context()
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
runtime, name, err := lookupWorkspaceRuntimeAndName(ctx, db.DB, id)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
@@ -149,16 +149,20 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
platformURL := externalPlatformURL(c)
c.JSON(http.StatusOK, gin.H{
"connection": BuildExternalConnectionPayload(platformURL, id, ""),
"connection": BuildExternalConnectionPayload(platformURL, id, name, ""),
})
}
// lookupWorkspaceRuntime returns the workspace's runtime field. Wrapped
// for readability + so tests can mock the single SELECT.
func lookupWorkspaceRuntime(ctx context.Context, handle *sql.DB, id string) (string, error) {
var runtime string
err := handle.QueryRowContext(ctx, `
SELECT COALESCE(runtime, '') FROM workspaces WHERE id = $1
`, id).Scan(&runtime)
return runtime, err
// lookupWorkspaceRuntimeAndName returns runtime + name in one round-trip.
// Wrapped for readability + so tests can mock the single SELECT.
// Used by rotate / re-show paths: runtime gates the external-only check;
// name feeds the per-workspace MCP server slug in BuildExternalConnectionPayload
// (so the Universal MCP snippet uses a stable per-workspace name instead
// of overwriting prior `claude mcp add molecule` entries).
// Returns sql.ErrNoRows when the workspace doesn't exist.
func lookupWorkspaceRuntimeAndName(ctx context.Context, handle *sql.DB, id string) (runtime, name string, err error) {
err = handle.QueryRowContext(ctx, `
SELECT COALESCE(runtime, ''), COALESCE(name, '') FROM workspaces WHERE id = $1
`, id).Scan(&runtime, &name)
return runtime, name, err
}
@@ -35,9 +35,9 @@ func TestRotateExternalCredentials_HappyPath(t *testing.T) {
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// 1. Runtime lookup
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-ext").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("external", "test-ws"))
// 2. Revoke all live tokens
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
@@ -98,9 +98,9 @@ func TestRotateExternalCredentials_RejectsNonExternal(t *testing.T) {
setupTestRedis(t)
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-hermes").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("hermes", "test-ws"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -129,9 +129,9 @@ func TestRotateExternalCredentials_NotFound(t *testing.T) {
setupTestRedis(t)
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-missing").
WillReturnRows(sqlmock.NewRows([]string{"runtime"})) // no rows
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"})) // no rows
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -172,9 +172,9 @@ func TestGetExternalConnection_HappyPathReturnsBlankToken(t *testing.T) {
setupTestRedis(t)
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-ext").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("external", "test-ws"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -211,9 +211,9 @@ func TestGetExternalConnection_RejectsNonExternal(t *testing.T) {
setupTestRedis(t)
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-claude").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("claude-code", "test-ws"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -233,9 +233,9 @@ func TestGetExternalConnection_NotFound(t *testing.T) {
setupTestRedis(t)
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-missing").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}))
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -253,7 +253,7 @@ func TestGetExternalConnection_NotFound(t *testing.T) {
// ---------- BuildExternalConnectionPayload (pure helper) ----------
func TestBuildExternalConnectionPayload_StampsPlaceholders(t *testing.T) {
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "tok-abc")
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "my-bot", "tok-abc")
if got["workspace_id"] != "ws-7" {
t.Errorf("workspace_id: %v", got["workspace_id"])
@@ -267,6 +267,18 @@ func TestBuildExternalConnectionPayload_StampsPlaceholders(t *testing.T) {
if got["registry_endpoint"] != "https://platform.test/registry/register" {
t.Errorf("registry_endpoint: %v", got["registry_endpoint"])
}
// Universal MCP snippet must contain a workspace-specific server
// name derived from the workspace name. Without this each new
// `claude mcp add` would overwrite the previous entry in the user's
// ~/.claude.json (servers are keyed by name) — collapsing
// multi-workspace use into one slot. See mcpServerNameForWorkspace.
mcp, _ := got["universal_mcp_snippet"].(string)
if !strings.Contains(mcp, "claude mcp add molecule-my-bot ") {
t.Errorf("universal_mcp_snippet missing per-workspace server name 'molecule-my-bot':\n%s", mcp)
}
if strings.Contains(mcp, "{{MCP_SERVER_NAME}}") {
t.Errorf("universal_mcp_snippet still contains literal {{MCP_SERVER_NAME}}")
}
// {{PLATFORM_URL}} + {{WORKSPACE_ID}} placeholders must be substituted
// out of every snippet — if any snippet still contains a literal
// "{{PLATFORM_URL}}" or "{{WORKSPACE_ID}}", a future template author
@@ -292,7 +304,7 @@ func TestBuildExternalConnectionPayload_TrimsTrailingSlash(t *testing.T) {
// being concatenated into endpoint paths — otherwise the operator
// gets `https://platform.test//registry/register` (double slash) which
// some servers reject as a redirect target.
got := BuildExternalConnectionPayload("https://platform.test/", "ws-7", "")
got := BuildExternalConnectionPayload("https://platform.test/", "ws-7", "", "")
if got["platform_url"] != "https://platform.test" {
t.Errorf("platform_url: trailing slash not trimmed; got %v", got["platform_url"])
}
@@ -304,8 +316,100 @@ func TestBuildExternalConnectionPayload_TrimsTrailingSlash(t *testing.T) {
func TestBuildExternalConnectionPayload_BlankAuthTokenIsAllowed(t *testing.T) {
// Re-show path: auth_token="" is the contract; the modal masks the
// field and labels it "rotate to reveal a new token".
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "")
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "", "")
if got["auth_token"] != "" {
t.Errorf("blank token must propagate as \"\"; got %v", got["auth_token"])
}
}
// TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace
// pins the multi-workspace install contract: two distinct workspaces
// must produce two distinct `claude mcp add` server-name lines, or
// installing the second one will overwrite the first entry in the
// user's ~/.claude.json (servers are keyed by name) — collapsing
// multi-workspace use into a single per-session slot, which is the
// "this is per-session" UX the CTO observed 2026-05-18.
func TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace(t *testing.T) {
cases := []struct {
name string
workspaceID string
wsName string
wantAddLine string // must appear in universal_mcp_snippet
}{
{"plain name", "id-a", "my-bot", "claude mcp add molecule-my-bot "},
{"name with spaces + caps", "id-b", "My Bot 1", "claude mcp add molecule-my-bot-1 "},
// Symbol/punctuation collapses to single hyphens and trims.
{"name with symbols", "id-c", "--Foo!!Bar--", "claude mcp add molecule-foo-bar "},
// Empty name falls back to the first 8 chars of the (de-hyphenated)
// workspace UUID — keeps the snippet unique per workspace even
// when callers (rotate/re-show pre-name-lookup) pass "".
{"empty name, uuid id", "12345678-aaaa-bbbb-cccc-deadbeef0000", "", "claude mcp add molecule-12345678 "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := BuildExternalConnectionPayload("https://p.test", tc.workspaceID, tc.wsName, "tok")
mcp, _ := got["universal_mcp_snippet"].(string)
if !strings.Contains(mcp, tc.wantAddLine) {
t.Errorf("missing %q in universal_mcp_snippet:\n%s", tc.wantAddLine, mcp)
}
// Belt + suspenders: never the bare fixed `molecule` name —
// that was the bug. (Match with trailing space so the
// "molecule-…" form passes.)
if strings.Contains(mcp, "claude mcp add molecule ") {
t.Errorf("snippet regressed to fixed `claude mcp add molecule `; got:\n%s", mcp)
}
})
}
}
// TestBuildExternalConnectionPayload_AllRuntimeSnippetsAreWorkspaceUnique
// extends the multi-workspace install contract to every runtime tab in
// the modal. Each MCP-host config keyspace has the SAME equivalence
// class as Claude Code's `claude mcp add <name>`:
//
// - codex: ~/.codex/config.toml [mcp_servers.<name>] — TOML rejects
// duplicate table keys, so a second workspace with the same name
// either breaks parsing or overwrites the first table.
// - openclaw: ~/.openclaw/mcp/<name>.json — file is keyed by <name>,
// `openclaw mcp set <same-name>` overwrites.
// - hermes: ~/.hermes/config.yaml gateway.plugin_platforms.<key>:
// YAML rejects duplicate mapping keys.
// - kimi: ~/.molecule-ai/kimi-<slug>/ per-workspace dir — single
// "kimi-workspace" dir would have both workspaces' envs collide.
//
// All four must therefore stamp the workspace-specific
// {{MCP_SERVER_NAME}} slug. This test catches a future template author
// who introduces a new runtime tab without plumbing the slug.
func TestBuildExternalConnectionPayload_AllRuntimeSnippetsAreWorkspaceUnique(t *testing.T) {
got := BuildExternalConnectionPayload("https://p.test", "id-a", "my-bot", "tok")
// Per-template literal that proves the slug was stamped through.
wantPerSnippet := map[string]string{
"universal_mcp_snippet": "claude mcp add molecule-my-bot ",
"codex_snippet": "[mcp_servers.molecule-my-bot]",
"openclaw_snippet": "openclaw mcp set molecule-my-bot ",
"hermes_channel_snippet": " molecule-my-bot:",
"kimi_snippet": "~/.molecule-ai/kimi-molecule-my-bot",
}
for key, needle := range wantPerSnippet {
v, _ := got[key].(string)
if !strings.Contains(v, needle) {
t.Errorf("%s missing per-workspace slug literal %q:\n%s", key, needle, v)
}
}
// No template should still contain the unstamped placeholder — that
// would mean BuildExternalConnectionPayload's stamp() didn't sweep
// it, which is the regression we're guarding against.
for _, k := range []string{
"curl_register_template", "python_snippet",
"claude_code_channel_snippet", "universal_mcp_snippet",
"hermes_channel_snippet", "codex_snippet", "openclaw_snippet",
"kimi_snippet",
} {
v, _ := got[k].(string)
if strings.Contains(v, "{{MCP_SERVER_NAME}}") {
t.Errorf("%s still contains literal {{MCP_SERVER_NAME}}", k)
}
}
}
@@ -198,6 +198,17 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// back to its compiled-in Anthropic default and 401s when the user's
// key is for a different provider. Non-hermes runtimes are unaffected
// (the server still passes model through, they just don't use it).
// runtimeExplicitlyRequested is true when the caller expressed intent for
// a SPECIFIC runtime — either by passing `runtime` directly, or by naming
// a `template` (a template encodes a runtime). When true, we must NOT
// silently fall back to langgraph if that intent can't be honored: that
// is the molecule-controlplane#188 / #184 contract violation (caller asks
// for codex/claude-code, gets a langgraph workspace, 201, no error — a
// false success). #188 mandates fail-closed (error+notify) on mismatch,
// not an advisory degrade. The legitimate "no template, no runtime →
// langgraph default" path (bare {"name":...}) is unaffected.
runtimeExplicitlyRequested := payload.Runtime != "" || payload.Template != ""
templateRuntimeResolved := payload.Runtime != ""
if payload.Template != "" && (payload.Runtime == "" || payload.Model == "") {
// #226: payload.Template is attacker-controllable. resolveInsideRoot
// rejects absolute paths and any ".." that escapes configsDir so the
@@ -230,6 +241,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
switch {
case payload.Runtime == "" && !indented && strings.HasPrefix(stripped, "runtime:") && !strings.HasPrefix(stripped, "runtime_config"):
payload.Runtime = strings.TrimSpace(strings.TrimPrefix(stripped, "runtime:"))
if payload.Runtime != "" {
templateRuntimeResolved = true
}
case payload.Model == "" && !indented && strings.HasPrefix(stripped, "model:"):
// Legacy top-level `model:` — pre-runtime_config templates.
payload.Model = strings.Trim(strings.TrimSpace(strings.TrimPrefix(stripped, "model:")), `"'`)
@@ -242,7 +256,27 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
}
}
// Fail-closed (molecule-controlplane#188 / #184): if the caller expressed
// intent for a specific runtime (passed `runtime`, or named a `template`)
// but we could NOT resolve a concrete runtime from it (template's
// config.yaml unreadable, or it has no `runtime:` key), DO NOT silently
// substitute langgraph and return 201 — that is the silent contract
// violation that produced 5/5 wrong workspaces and a false codex E2E pass.
// Return 422 so the caller learns the requested runtime was not honored.
// The platform-side CP fix (controlplane#188) is the sibling gate; this
// closes the ws-server `Create` boundary the product UI actually hits.
if payload.Runtime == "" && runtimeExplicitlyRequested && !templateRuntimeResolved {
log.Printf("Create: FAIL-CLOSED (controlplane#188) — template=%q requested but runtime could not be resolved; refusing silent langgraph fallback", payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "runtime could not be resolved from the requested template; refusing to silently provision langgraph (controlplane#188). Pass an explicit \"runtime\", or use a template whose config.yaml declares one.",
"template": payload.Template,
"code": "RUNTIME_UNRESOLVED",
})
return
}
if payload.Runtime == "" {
// Legitimate default path: no template AND no runtime requested
// (bare {"name":...}) — langgraph is the intended default here.
payload.Runtime = "langgraph"
}
@@ -512,7 +546,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// shape. Adding a new snippet means adding it once there;
// all three callers pick it up automatically.
resp["connection"] = BuildExternalConnectionPayload(
externalPlatformURL(c), id, connectionToken,
externalPlatformURL(c), id, payload.Name, connectionToken,
)
}
c.JSON(http.StatusCreated, resp)
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled",
}))
w := httptest.NewRecorder()
@@ -1770,3 +1770,147 @@ runtime_config:
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
}
// ==================== #188 fail-closed: template/runtime contract ====================
//
// molecule-controlplane#188 / #184: if a caller names a `template` (intent
// for a specific runtime) but the runtime cannot be resolved from it, the
// server MUST NOT silently provision langgraph and return 201 — that false
// success produced 5/5 wrong workspaces and a bogus codex E2E pass. These
// tests pin the fail-closed boundary at the ws-server `Create` handler (the
// path the product UI hits), and guard the legitimate default path against
// regression.
// Template requested but its dir/config.yaml is absent → 422, not silent
// langgraph 201.
func TestWorkspaceCreate_188_TemplateMissingRuntime_FailsClosed(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
// configsDir is an empty temp dir → resolveInsideRoot succeeds (the path
// is inside root) but config.yaml read fails → runtime cannot be resolved.
configsDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(configsDir, "ghost-template"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ghost","template":"ghost-template"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 (fail-closed, controlplane#188), got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if resp["code"] != "RUNTIME_UNRESOLVED" {
t.Errorf("expected code RUNTIME_UNRESOLVED, got %v", resp["code"])
}
}
// Template config.yaml has no `runtime:` key → 422, not silent langgraph.
func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
configsDir := t.TempDir()
tdir := filepath.Join(configsDir, "noruntime-template")
if err := os.MkdirAll(tdir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// config.yaml exists but declares no runtime.
if err := os.WriteFile(filepath.Join(tdir, "config.yaml"), []byte("name: noruntime\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"NoRuntime","template":"noruntime-template"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 (fail-closed), got %d: %s", w.Code, w.Body.String())
}
}
// Regression guard: the legitimate default path (no template, no runtime —
// bare {"name":...}) MUST still default to langgraph and return 201. The
// #188 fix must not break this.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Plain Default"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// Explicit runtime, no template → honored, 201 (no template resolution
// needed; runtimeExplicitlyRequested true but already resolved).
func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Explicit Codex","runtime":"codex"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -0,0 +1,229 @@
// Package provisioner — T4 privilege contract.
//
// This file is the single source of truth for what a Tier-4 ("full
// machine access") workspace runtime MUST guarantee, expressed as code
// templates can reference and CI can verify.
//
// RFC: molecule-ai/internal#456 (per-template privilege-contract class).
// Task: molecule-ai/internal #174.
//
// Background
// ----------
// Prior art is RFC#456's three layers:
//
// (1) molecule-runtime self-enforces uid-1000 + fchown safety net,
// (2) a platform-owned wrapper entrypoint from a shared base image,
// (3) a REQUIRED CI conformance gate wired into the fresh-provision
// harness that asserts the post-condition, not the mechanism.
//
// This file is the *data shape* for layer (3): the gate's tests have
// been hand-written per-template (template-claude-code, template-hermes,
// template-codex). Hand-writing drifts; the Hermes 401 class came from
// drift. We need the capability list itself to be code so that:
//
// - The provisioner can dump it as `t4_capabilities.yaml` for any
// fork user or non-Molecule-AI template runner to consume directly
// (no hardcoded internal org).
// - A `Verify(...)` helper turns into the t4-conformance shell out of
// one file, so when a capability is added the templates pick it up
// by reading the YAML — they do not silently lag.
// - The provisioner-emit side (provisioner.go applyTierResources / T4
// branch) and the verifier side share the same constants for the
// uid + mount paths, eliminating "string-match" drift between
// emitter and gate.
//
// Non-goals
// ---------
// - This is NOT a substitute for layer (1)/(2). Templates still must
// `exec gosu agent` and write /configs/.auth_token under uid 1000;
// this file describes *what to check*, not how to achieve it.
// - This file does not run tests. It is the spec. CI workflows call
// `T4PrivilegeContract().AsYAML()` once at the start of the gate
// and assert each capability's `Probe` returns ok.
package provisioner
import (
"fmt"
"sort"
"strings"
)
// T4Capability is one assertion the T4 runtime MUST satisfy.
//
// Each capability declares:
// - Name: stable id (used as the test name in CI output).
// - Description: human-readable why-this-matters; goes in failure logs.
// - Probe: a shell snippet that exits 0 on pass, non-zero on fail.
// The probe MUST be deterministic, MUST be runnable inside the
// running container under uid 1000, and MUST NOT depend on outside
// network beyond what `RequiredEgress` declares.
// - Severity: "hard" capabilities fail the gate; "advisory" emit a
// warning. T4 contract minimum = all hard pass.
// - Source: RFC section or memory reference that motivated this
// capability — keeps the audit trail in-tree.
type T4Capability struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Probe string `yaml:"probe"`
Severity string `yaml:"severity"`
Source string `yaml:"source"`
RequiredEgress []string `yaml:"required_egress,omitempty"`
}
// SeverityHard / SeverityAdvisory enumerate the only allowed Severity
// values. We do not use Go enums because the YAML consumer is shell.
const (
SeverityHard = "hard"
SeverityAdvisory = "advisory"
)
// T4PrivilegeContract returns the full T4 capability set.
//
// Add new capabilities here. Each one is automatically picked up by
// any template whose CI consumes `t4_capabilities.yaml` (no per-template
// PR needed for new checks — this is the anti-drift property).
//
// Capability ordering matters for human-readable CI output but is not
// load-bearing for correctness; AsYAML() emits them sorted by Name.
func T4PrivilegeContract() []T4Capability {
return []T4Capability{
{
Name: "agent_uid_1000",
Description: "The container's primary process (the runtime, post-gosu) runs as uid 1000, not root. T4 grants full machine access via privileged + host PID + Docker socket — the WORKLOAD inside that privileged container must still be unprivileged to prevent every untrusted code execution from being trivially root-on-host.",
Probe: `[ "$(id -u)" = "1000" ]`,
Severity: SeverityHard,
Source: "RFC internal#456 §2.1.2; memory feedback_hermes_listpeers_401_token_root600_unreadable_by_uid1000",
},
{
Name: "auth_token_agent_owned",
Description: "/configs/.auth_token is owned by uid 1000 (== AgentUID) so the a2a_mcp_server can read its bearer. In SaaS mode molecule-runtime itself writes the token via save_token() — the ownership equals the runtime's exec uid. If the runtime ever runs as root, this fails and list_peers 401s (the Hermes class bug).",
Probe: `[ -e /configs/.auth_token ] && [ "$(stat -c '%u' /configs/.auth_token)" = "1000" ]`,
Severity: SeverityHard,
Source: "RFC internal#456 §10; memory feedback_hermes_listpeers_401_token_root600_unreadable_by_uid1000",
},
{
Name: "host_root_reach_via_nsenter",
Description: "The uid-1000 agent can attain host root via `sudo -n nsenter --target 1 --mount --pid -- id -u` returning 0. This is the T4 escalation leg: full machine access means the agent CAN escalate to host root deliberately, even though it does not run as root by default.",
Probe: `[ "$(sudo -n nsenter --target 1 --mount --pid -- id -u)" = "0" ]`,
Severity: SeverityHard,
Source: "RFC internal#456 §11; memory reference_per_template_privilege_contract_class_audit_2026_05_16",
},
{
Name: "host_fs_write_readback",
Description: "Host filesystem is mounted at /host and the agent can write+read+remove a file there via sudo. Proves real host reach (not just a PID-1 namespace trick on an isolated init).",
Probe: `MARKER="t4cap-$(date +%s)-$RANDOM"; PROBE_FILE="/host/tmp/.t4-cap-probe-${MOLECULE_T4_PROBE_ID:-$$}"; ` +
`sudo -n sh -c "echo $MARKER > $PROBE_FILE" && ` +
`[ "$(sudo -n cat $PROBE_FILE)" = "$MARKER" ] && ` +
`sudo -n rm -f $PROBE_FILE`,
Severity: SeverityHard,
Source: "RFC internal#456 §11",
},
{
Name: "docker_socket_reachable",
Description: "/var/run/docker.sock is bind-mounted into the container so the agent can manage other containers (T4 use case: agent-as-orchestrator). Proven by 'docker version' returning a server section, which requires the daemon to answer over the socket.",
Probe: `sudo -n docker version --format '{{.Server.Version}}' >/dev/null 2>&1`,
Severity: SeverityHard,
Source: "provisioner.go applyHostConfig T4 branch (case 4)",
},
{
Name: "list_peers_http_200",
Description: "The platform list_peers HTTP endpoint (served by the in-container a2a_mcp_server) returns HTTP 200 when called from uid 1000 with the bearer from /configs/.auth_token. This proves the WHOLE token-ownership chain end-to-end: token written under correct uid → reader uid matches → bearer non-empty → platform accepts. A self-contained empirical test for the Hermes class bug.",
Probe: `BEARER=$(cat /configs/.auth_token 2>/dev/null || echo ""); ` +
`[ -n "$BEARER" ] || exit 1; ` +
`PORT=$(cat /configs/.platform_port 2>/dev/null || echo "8080"); ` +
`STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $BEARER" "http://127.0.0.1:${PORT}/list_peers"); ` +
`[ "$STATUS" = "200" ]`,
Severity: SeverityHard,
Source: "memory reference_openclaw_fresh_provision_nonfunctional_anthropic_default_unroutable; memory reference_openclaw_mcp_peer_wiring_rootcause",
},
{
Name: "agent_home_writable",
Description: "/agent-home is writable by the agent (Files API split per task #128). The Files API redesign uses /agent-home as the user-writable root; the agent must be able to create files there without sudo.",
Probe: `TF=/agent-home/.t4-cap-write-probe-${MOLECULE_T4_PROBE_ID:-$$}; echo ok > "$TF" && [ "$(cat "$TF")" = "ok" ] && rm -f "$TF"`,
Severity: SeverityHard,
Source: "task #128 Files API redesign; memory reference_post_suspension_pipeline",
},
{
Name: "network_egress_https",
Description: "Generic HTTPS egress works. T4 is unconstrained network; the canonical test target is the Gitea instance over its public name, which any fork user can also resolve. Any reachable HTTPS endpoint satisfies it — the YAML carries the recommended targets but accepts any 200/301/302.",
Probe: `for U in $MOLECULE_T4_EGRESS_TARGETS; do ` +
` C=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$U"); ` +
` case "$C" in 2*|3*) exit 0;; esac; ` +
`done; exit 1`,
Severity: SeverityHard,
Source: "task #174 brief",
RequiredEgress: []string{
// Public, no auth, returns a small JSON.
// Adopters override via MOLECULE_T4_EGRESS_TARGETS.
"https://api.github.com/zen",
"https://www.google.com/generate_204",
},
},
{
Name: "privileged_flag_observable",
Description: "Container is started with --privileged. Observable from inside via /proc/self/status CapEff containing CAP_SYS_ADMIN. Defense-in-depth for the provisioner emission side.",
Probe: `grep -q '^CapEff:.*ffffffffff' /proc/self/status`,
Severity: SeverityAdvisory, // Imperfect — some CAP filters trim CapEff; advisory only.
Source: "provisioner.go applyHostConfig T4 branch (case 4)",
},
{
Name: "pid_host_visible",
Description: "Host PID namespace is shared (--pid=host). The container can see host process 1 (systemd or pid-1 on the EC2 instance). Required for nsenter into host mount/pid namespaces.",
Probe: `[ -d /proc/1/root ] && [ "$(sudo -n readlink /proc/1/ns/pid)" = "$(sudo -n readlink /proc/self/ns/pid)" ]`,
Severity: SeverityHard,
Source: "provisioner.go applyHostConfig T4 branch (case 4): hostCfg.PidMode = 'host'",
},
}
}
// AsYAML renders the contract as a single YAML document templates can
// fetch at CI time. Sorted by Name for deterministic diffs.
//
// We deliberately do not depend on a YAML library here — the format is
// trivial, and one-file pure-stdlib means this can be vendored or
// dumped from any Go context (including a `go run` script in CI).
//
// The format is stable; downstream consumers must treat unknown fields
// as warnings, not errors.
func AsYAML(caps []T4Capability) string {
sorted := make([]T4Capability, len(caps))
copy(sorted, caps)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var b strings.Builder
b.WriteString("# T4 privilege contract — generated from\n")
b.WriteString("# molecule-ai/molecule-core workspace-server/internal/provisioner/t4_privilege_contract.go\n")
b.WriteString("# RFC: molecule-ai/internal#456\n")
b.WriteString("# Do NOT edit this file by hand; regenerate via `go run ./cmd/t4-contract-dump > t4_capabilities.yaml`.\n")
b.WriteString("version: 1\n")
b.WriteString("agent_uid: 1000\n")
b.WriteString("capabilities:\n")
for _, c := range sorted {
fmt.Fprintf(&b, " - name: %s\n", yamlEscape(c.Name))
fmt.Fprintf(&b, " description: %s\n", yamlEscape(c.Description))
fmt.Fprintf(&b, " severity: %s\n", c.Severity)
fmt.Fprintf(&b, " source: %s\n", yamlEscape(c.Source))
fmt.Fprintf(&b, " probe: %s\n", yamlEscape(c.Probe))
if len(c.RequiredEgress) > 0 {
b.WriteString(" required_egress:\n")
for _, u := range c.RequiredEgress {
fmt.Fprintf(&b, " - %s\n", yamlEscape(u))
}
}
}
return b.String()
}
// yamlEscape is a minimal YAML scalar escaper. We always quote with
// double quotes and backslash-escape internal quotes + backslashes —
// safe for the subset of strings we emit (no control chars except \n
// and \t, both of which we replace with literal escapes).
func yamlEscape(s string) string {
r := strings.NewReplacer(
"\\", "\\\\",
"\"", "\\\"",
"\n", "\\n",
"\t", "\\t",
)
return "\"" + r.Replace(s) + "\""
}
@@ -0,0 +1,153 @@
package provisioner
import (
"strings"
"testing"
)
// TestT4PrivilegeContract_AllCapabilitiesHaveRequiredFields enforces
// the invariant that every entry in the contract has at minimum a
// Name, Description, Probe, Severity, and Source — so the YAML the
// templates consume is never partially-filled (a quiet way to drift).
func TestT4PrivilegeContract_AllCapabilitiesHaveRequiredFields(t *testing.T) {
caps := T4PrivilegeContract()
if len(caps) == 0 {
t.Fatal("T4PrivilegeContract returned zero capabilities — the gate would have nothing to assert")
}
for _, c := range caps {
if c.Name == "" {
t.Errorf("capability missing Name: %+v", c)
}
if c.Description == "" {
t.Errorf("capability %q missing Description", c.Name)
}
if c.Probe == "" {
t.Errorf("capability %q missing Probe", c.Name)
}
if c.Severity != SeverityHard && c.Severity != SeverityAdvisory {
t.Errorf("capability %q has invalid Severity %q (allowed: hard, advisory)", c.Name, c.Severity)
}
if c.Source == "" {
t.Errorf("capability %q missing Source — every capability must cite the RFC section or memory that motivates it", c.Name)
}
}
}
// TestT4PrivilegeContract_NamesAreUnique catches a silent
// dup-by-rename: if two capabilities share a name, AsYAML overwrites
// one in any YAML-loader-with-merge implementation, and CI output
// becomes ambiguous.
func TestT4PrivilegeContract_NamesAreUnique(t *testing.T) {
caps := T4PrivilegeContract()
seen := make(map[string]bool, len(caps))
for _, c := range caps {
if seen[c.Name] {
t.Errorf("capability name %q appears more than once", c.Name)
}
seen[c.Name] = true
}
}
// TestT4PrivilegeContract_CoreCapabilitiesPresent pins the minimum
// closure of capabilities the gate guarantees. Adding capabilities
// is fine; removing one of these requires updating this test
// (which the reviewer will see and challenge).
//
// These are exactly the post-conditions cited in RFC internal#456 §10–§11
// + task #128 (Files API) + task #174 (this task).
func TestT4PrivilegeContract_CoreCapabilitiesPresent(t *testing.T) {
required := []string{
"agent_uid_1000",
"auth_token_agent_owned",
"host_root_reach_via_nsenter",
"docker_socket_reachable",
"list_peers_http_200",
"agent_home_writable",
"network_egress_https",
}
caps := T4PrivilegeContract()
have := make(map[string]bool, len(caps))
for _, c := range caps {
have[c.Name] = true
}
for _, r := range required {
if !have[r] {
t.Errorf("required capability %q missing from contract — RFC internal#456 / task #174 says this MUST be in the closure", r)
}
}
}
// TestT4PrivilegeContract_HardCapabilitiesMajority sanity-checks that
// the contract is not silently advisory-only. If someone marks
// everything as "advisory" the gate becomes a no-op without anyone
// noticing — fail the test if hard capabilities are not the majority.
func TestT4PrivilegeContract_HardCapabilitiesMajority(t *testing.T) {
caps := T4PrivilegeContract()
hard := 0
for _, c := range caps {
if c.Severity == SeverityHard {
hard++
}
}
if hard*2 <= len(caps) {
t.Errorf("hard capabilities (%d) must be the strict majority of %d total — otherwise the gate is a no-op", hard, len(caps))
}
}
// TestAsYAML_IsParseableAndStable asserts the AsYAML output is
// stable across invocations (sorted by name) and contains every
// capability's name. We do not depend on a YAML parser here —
// presence of `- name: "<n>"` lines is sufficient and the format
// is deliberately the trivially-greppable subset.
func TestAsYAML_IsParseableAndStable(t *testing.T) {
caps := T4PrivilegeContract()
y1 := AsYAML(caps)
y2 := AsYAML(caps)
if y1 != y2 {
t.Error("AsYAML output is not deterministic across calls — sort/format must be stable for CI diff sanity")
}
for _, c := range caps {
needle := "- name: \"" + c.Name + "\""
if !strings.Contains(y1, needle) {
t.Errorf("AsYAML output missing %q", needle)
}
}
// Header must cite the RFC so adopters can find the source of truth.
if !strings.Contains(y1, "internal#456") {
t.Error("AsYAML header must reference RFC internal#456 — that is the design-of-record")
}
if !strings.Contains(y1, "version: 1") {
t.Error("AsYAML must declare schema version (templates parse-check on this)")
}
}
// TestAsYAML_EscapesEmbeddedQuotes catches a regression in
// yamlEscape: a probe shell string containing a double-quote would
// produce an unparseable YAML scalar.
func TestAsYAML_EscapesEmbeddedQuotes(t *testing.T) {
caps := []T4Capability{{
Name: "embedded_quote",
Description: `says "hi"`,
Probe: `echo "ok"`,
Severity: SeverityHard,
Source: "test",
}}
y := AsYAML(caps)
// We expect the embedded `"` to be backslash-escaped.
if !strings.Contains(y, `\"hi\"`) {
t.Errorf("AsYAML did not escape embedded double quotes; got:\n%s", y)
}
if !strings.Contains(y, `\"ok\"`) {
t.Errorf("AsYAML did not escape embedded double quotes in Probe; got:\n%s", y)
}
}
// TestAgentUIDConsistency ties the contract to the existing
// provisioner-side AgentUID const. The probe for "agent_uid_1000"
// hard-codes `id -u == 1000`; if AgentUID ever changes (no one
// expects it to, but a CI guard is free), the probe must change too.
func TestAgentUIDConsistency(t *testing.T) {
if AgentUID != 1000 {
t.Fatalf("AgentUID is %d but the T4 contract's probes assume 1000; update t4_privilege_contract.go probes before changing AgentUID", AgentUID)
}
}
+31 -9
View File
@@ -498,14 +498,20 @@ def _build_initialize_result() -> dict:
"experimental": {"claude/channel": {}},
},
# Identifier convention: this server is what users register with
# `claude mcp add molecule -- molecule-mcp` (and similar across
# other MCP hosts), so the canonical name is "molecule". Earlier
# versions reported "a2a-delegation" — accurate to the original
# purpose but a mismatch with how operators actually name it.
# Mismatch is harmless on tool routing (all MCP hosts dispatch
# by the user-supplied registration name, NOT serverInfo.name)
# but matters for any future Claude Code allowlist that gates
# channel push by hardcoded server name (issue #2934).
# `claude mcp add molecule-<workspace-slug> -- molecule-mcp` (and
# similar across other MCP hosts). The user-supplied
# registration name is workspace-specific so multiple molecule
# workspaces can coexist in one MCP-host session (see
# workspace-server/internal/handlers/external_connection.go's
# mcpServerNameForWorkspace + mc#1535). The serverInfo.name
# below is purely a self-describing label — "molecule" stays
# generic on purpose. Earlier versions reported "a2a-delegation"
# — accurate to the original purpose but a mismatch with how
# operators actually name it. Routing is by the user-supplied
# registration name on every MCP host, NOT serverInfo.name; the
# mismatch is harmless. Matters only for any future Claude Code
# allowlist that gates channel push by hardcoded server name
# (issue #2934).
"serverInfo": {"name": "molecule", "version": "1.0.0"},
# Built per-call (not the module-level constant) so an operator
# who sets MOLECULE_MCP_POLL_TIMEOUT_SECS after import — e.g.
@@ -788,7 +794,23 @@ async def main(): # pragma: no cover
buffer = b""
while True:
try:
chunk = await loop.run_in_executor(None, stdin.read, 65536)
# MUST be readline(), NOT read(65536). MCP is a line-delimited
# JSON-RPC stream where the client (openclaw bundle-mcp,
# Claude Code, Cursor, ...) sends one small (~150B) request
# and keeps stdin OPEN waiting for the response. A fixed-size
# `stdin.read(65536)` on a PIPE blocks until either 64KB
# accumulate OR EOF — neither happens during a normal MCP
# handshake — so the server never parses `initialize` and the
# client times out (~30s; openclaw: "MCP error -32000:
# Connection closed"). This made the stdio transport unusable
# for every pipe-spawned MCP host while passing tests/manual
# checks that fed stdin from a regular FILE (where read()
# returns immediately at the short file's end). readline()
# returns as soon as one newline-terminated line is available,
# which is exactly the JSON-RPC framing. Diagnosed 2026-05-15
# against a live openclaw workspace; see
# molecule-ai-workspace-runtime#61 (same fd-compat lineage).
chunk = await loop.run_in_executor(None, stdin.readline)
if not chunk:
break
buffer += chunk
+121
View File
@@ -2097,3 +2097,124 @@ def test_peer_metadata_set_replaces_existing_entry_in_place(_reset_peer_metadata
)
cached = a2a_client._peer_metadata[peer]
assert cached[1]["name"] == "v2", "re-write must update the value in place"
class TestStdioKeepOpenPipe:
"""Regression for the openclaw peer-visibility outage (2026-05-15).
main()'s read loop used `await loop.run_in_executor(None,
stdin.read, 65536)`. On a PIPE, `read(n)` blocks until n bytes
accumulate OR EOF. A real MCP client (openclaw bundle-mcp, Claude
Code, Cursor) sends ONE ~150-byte newline-delimited request and
keeps stdin OPEN waiting for the reply — so neither condition is
met, the server never parses `initialize`, and the client times
out (~30s; openclaw surfaced "MCP error -32000: Connection
closed"). Every prior stdio test fed stdin from a regular file or
a heredoc-pipe that CLOSES (EOF), masking the bug.
These spawn the real a2a_mcp_server.py process, write one request
over a pipe, and DELIBERATELY keep stdin open. With the buggy
read(65536) the assertion times out and fails; with readline() it
passes promptly. This is the literal user-facing path, not a
mock — see feedback_smoke_test_vendor_truth_not_shape_match.
"""
def _spawn(self):
import subprocess
env = dict(os.environ)
env.setdefault("WORKSPACE_ID", "00000000-0000-0000-0000-000000000001")
server = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"a2a_mcp_server.py",
)
return subprocess.Popen(
["python3", server],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
def _read_line_with_deadline(self, proc, deadline_s=15):
import select
import time
end = time.time() + deadline_s
while time.time() < end:
r, _, _ = select.select([proc.stdout], [], [], 1)
if r:
line = proc.stdout.readline()
if line:
return line
return b""
def test_initialize_answered_on_still_open_pipe(self):
"""One initialize, stdin kept OPEN, response required <15s.
FAILS (times out -> empty line) on stdin.read(65536).
PASSES on stdin.readline().
"""
proc = self._spawn()
try:
req = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "keepopen", "version": "1"},
},
}) + "\n"
proc.stdin.write(req.encode())
proc.stdin.flush()
# NOTE: stdin is intentionally NOT closed — mirrors a live
# MCP client. Closing it here would yield EOF and let the
# buggy read(65536) return, hiding the regression.
line = self._read_line_with_deadline(proc, 15)
finally:
proc.kill()
proc.wait(timeout=5)
assert line, (
"no response within 15s on a still-open pipe — the "
"stdin.read(65536) pipe-blocking regression is back "
"(this is the exact openclaw peer-visibility outage)"
)
resp = json.loads(line.decode())
assert resp.get("id") == 1, f"unexpected id: {line[:200]!r}"
assert "result" in resp, f"no result envelope: {line[:200]!r}"
assert resp["result"]["serverInfo"]["name"] == "molecule", (
f"wrong serverInfo: {line[:200]!r}"
)
def test_two_sequential_requests_on_open_pipe(self):
"""initialize THEN tools/list on the same open pipe — proves
the loop keeps reading line-by-line, not just the first 64KB
chunk. tools/list must include list_peers (the peer-visibility
tool the outage was about)."""
proc = self._spawn()
try:
proc.stdin.write((json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "x", "version": "1"}},
}) + "\n").encode())
proc.stdin.flush()
init = self._read_line_with_deadline(proc, 15)
assert init, "initialize unanswered on open pipe"
proc.stdin.write((json.dumps({
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
}) + "\n").encode())
proc.stdin.flush()
tl = self._read_line_with_deadline(proc, 15)
finally:
proc.kill()
proc.wait(timeout=5)
assert tl, "tools/list unanswered — loop stopped after one read"
resp = json.loads(tl.decode())
names = {t["name"] for t in resp["result"]["tools"]}
assert "list_peers" in names, (
f"list_peers missing from tools/list: {sorted(names)}"
)