From b5dea3c5df1e7ea59f8abc4417ff87da482d7ac5 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 12:28:24 -0700 Subject: [PATCH 01/11] fix(canvas): ConfirmDialog hover + focus polish Three issues on a high-stakes surface (revoke token, delete workspace, cascade delete): 1. **Cancel hover was a no-op.** `bg-surface-card hover:bg-surface-card` gave zero visual feedback on hover. Now hovers to surface-elevated with a softened border so the button visibly lifts. 2. **Confirm hovers went LIGHTER, dropping white-text contrast.** `bg-red-600 hover:bg-red-500` made the destructive button less readable on hover. Same for warning (amber) and primary (accent). Reversed to hover-darker so contrast holds in both themes. 3. **No focus-visible rings on either button.** Keyboard users had no indication of focus position (WCAG 2.4.7 fail). Added `focus-visible:ring-2 focus-visible:ring-accent/40` on Cancel and `focus-visible:ring-2 focus-visible:ring-offset-2 ...accent/60` on Confirm so the focused destructive action is unambiguous. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/ConfirmDialog.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/canvas/src/components/ConfirmDialog.tsx b/canvas/src/components/ConfirmDialog.tsx index 93961db4..75cacd70 100644 --- a/canvas/src/components/ConfirmDialog.tsx +++ b/canvas/src/components/ConfirmDialog.tsx @@ -91,12 +91,15 @@ export function ConfirmDialog({ if (!open || !mounted) return null; + // Hover goes DARKER, not lighter — lighter shades on white text drop + // contrast below AA on the accent and red ramps. Darker hovers stay + // readable in both light and dark themes. const confirmColors = confirmVariant === "danger" - ? "bg-red-600 hover:bg-red-500 text-white" + ? "bg-red-600 hover:bg-red-700 text-white" : confirmVariant === "warning" - ? "bg-amber-600 hover:bg-amber-500 text-white" - : "bg-accent-strong hover:bg-accent text-white"; + ? "bg-amber-600 hover:bg-amber-700 text-white" + : "bg-accent hover:bg-accent-strong text-white"; // Render via Portal so the fixed-position dialog escapes any containing block // (e.g. parents with transform, filter, will-change that break position:fixed). @@ -123,7 +126,7 @@ export function ConfirmDialog({ @@ -131,7 +134,7 @@ export function ConfirmDialog({ From ffcffa13752b897920849acd45315a7bf91f592b Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 12:36:44 -0700 Subject: [PATCH 02/11] fix(canvas): agent chat bubble dark-mode prose contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from PR #2618 (chat dark-contrast). PR #2618 switched the agent bubble bg to `dark:bg-zinc-700` so it visibly elevates against the dark panel — but the inner ReactMarkdown prose div only got `prose-invert` for USER messages. Result: in dark mode the agent's markdown text rendered with the Tailwind Typography plugin's default dark body color on top of the new dark bg = invisible text. User reported empty-looking gray rectangles where agent replies should be. Fix: apply `dark:prose-invert` to agent bubbles so prose body text flips light alongside the bg. Light mode unchanged (default prose colors against the warm `bg-surface-card`). Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index a218192e..040fa983 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -792,7 +792,18 @@ function MyChatPanel({ workspaceId, data }: Props) { }`} > {msg.content && ( -
p]:mb-1 [&>p:last-child]:mb-0 ${msg.role === "user" ? "prose-invert" : ""}`}> +
p]:mb-1 [&>p:last-child]:mb-0 ${ + msg.role === "user" + ? "prose-invert" + // Agent bubbles use bg-zinc-700 in dark mode; without + // prose-invert the Tailwind Typography plugin keeps + // its default DARK body color → unreadable dark-on-dark. + // Light mode keeps default prose colors against the + // warm surface-card bg. + : "dark:prose-invert" + }`} + > {msg.content}
)} From 2d1a86cac96eefa46feb07fd36a611282c4906c2 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 12:46:21 -0700 Subject: [PATCH 03/11] fix(canvas): AgentCommsPanel light-mode markdown contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered during code review of the #2623 hotfix audit. Same regression class as #2618: prose-invert applied where the bubble bg themes between light/dark, leaving markdown unreadable in one theme. `MarkdownBody` was unconditionally `prose-invert` — fine for the outgoing-message bubble (bg-cyan-900, dark in both themes) and the failure bubble (bg-red-950, dark in both themes), but WRONG for the incoming-message bubble (bg-surface-card, which themes LIGHT in light mode). Result: light prose body text on light cream bg = invisible markdown for incoming peer-to-peer messages in light mode. Added an `invert: "always" | "dark-only"` prop to MarkdownBody. The NormalMessage call sites switch on `msg.flow` so each bubble gets the direction matching its bg's theming behavior. Failure bubble keeps the default ("always") since red-950 stays dark. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/tabs/chat/AgentCommsPanel.tsx | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 4d5ca91e..268953ce 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -574,12 +574,22 @@ function NormalMessage({ msg }: { msg: CommMessage }) { {msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
{msg.text ? ( - {msg.text} + // Outgoing bubble (cyan-900) is dark in both themes → prose-invert default. + // Incoming bubble (surface-card) themes light → only invert in dark. + + {msg.text} + ) : (
(no message text)
)} {msg.responseText && ( - + {msg.responseText} )} @@ -706,17 +716,29 @@ function ErrorMessage({ msg }: { msg: CommMessage }) { * prose tweaks that keep paragraphs tight inside a small bubble. * Code blocks get an `overflow-x-auto` so a long line of code doesn't * blow out the bubble's max-width — agent-to-agent replies routinely - * ship code samples and JSON. */ + * ship code samples and JSON. + * + * `invert` controls the prose color flip: + * - "always": container bg is dark in BOTH themes (cyan-900, red-950), + * so prose always wants light body text. + * - "dark-only": container bg uses a theming token that goes light in + * light mode (e.g. bg-surface-card). Prose only inverts in dark + * mode; light mode keeps default dark prose colors against the + * light bg. Without this, light mode rendered light text on light + * bg = invisible markdown. */ function MarkdownBody({ children, className, + invert = "always", }: { children: string; className?: string; + invert?: "always" | "dark-only"; }) { + const proseInvert = invert === "always" ? "prose-invert" : "dark:prose-invert"; return (
p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`} + className={`prose prose-sm ${proseInvert} max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`} > {children}
From d2c202ddabf6671a42d24a440377ee3059fe5b40 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 12:56:00 -0700 Subject: [PATCH 04/11] fix(canvas): ApprovalBanner Approve/Deny button polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug class as #2622 (ConfirmDialog), but on a more critical surface — this is the top-of-page banner asking the user to approve / deny a real workspace permission request. 1. **Deny was a no-op hover.** `bg-surface-card hover:bg-surface-card` gave zero visual feedback before the user clicked a destructive action. Now lifts to surface-elevated + brightens the text so the button visibly responds. 2. **Approve hover went LIGHTER.** `bg-emerald-600 hover:bg-emerald-500` dropped white-text contrast on hover. Reversed to emerald-700. 3. **No focus rings on either button.** Keyboard users had no way to tell which decision was focused. Added focus-visible rings (offset against the dark amber banner bg) — emerald for Approve, amber for Deny so the choice is unambiguous. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/ApprovalBanner.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/ApprovalBanner.tsx b/canvas/src/components/ApprovalBanner.tsx index 5d36efc5..e9baa776 100644 --- a/canvas/src/components/ApprovalBanner.tsx +++ b/canvas/src/components/ApprovalBanner.tsx @@ -73,14 +73,19 @@ export function ApprovalBanner() { From c37596fc26a9d734567a813d41badddb30c2f269 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 13:04:12 -0700 Subject: [PATCH 05/11] fix(canvas): brighten agent chat prose body in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: chat-bubble agent text still washed out after #2618 + #2623. Looked at the actual rendered colors and the issue was Tailwind Typography's `prose-invert` defaults — body text ships at zinc-300, which lands at ~5.3:1 against bg-zinc-700. Passes AA but visibly duller than the user bubble's crisp white-on-blue (~10:1). Override the prose CSS variables on the agent bubble in dark mode: - body → zinc-100 (was zinc-300) - headings / bold → white - inline code → zinc-100 That brings agent body text to ~13:1 against bg-zinc-700, matching the user bubble's brightness so both sides of the conversation read at the same crispness. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 040fa983..fbe53b7c 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -796,12 +796,14 @@ function MyChatPanel({ workspaceId, data }: Props) { className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${ msg.role === "user" ? "prose-invert" - // Agent bubbles use bg-zinc-700 in dark mode; without - // prose-invert the Tailwind Typography plugin keeps - // its default DARK body color → unreadable dark-on-dark. - // Light mode keeps default prose colors against the - // warm surface-card bg. - : "dark:prose-invert" + // Agent bubbles in dark mode: invert prose AND brighten + // the body/heading/bold/code tokens. prose-invert's + // default `--tw-prose-invert-body: zinc-300` lands at + // ~5.3:1 against bg-zinc-700 — passes AA but reads + // washed out next to the user bubble's crisp + // white-on-blue (~10:1). Push body to zinc-100 so the + // agent text matches that crispness. + : "dark:prose-invert dark:[--tw-prose-invert-body:theme(colors.zinc.100)] dark:[--tw-prose-invert-headings:theme(colors.white)] dark:[--tw-prose-invert-bold:theme(colors.white)] dark:[--tw-prose-invert-code:theme(colors.zinc.100)]" }`} > {msg.content} From c3ba5df9ff356cb790b65d17e42379229a997f34 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 13:06:25 -0700 Subject: [PATCH 06/11] test(e2e): add canvas-terminal diagnose probe to synth-E2E (catches EIC-chain regressions in <20 min) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the 2026-05-03 SG-missing-port-22 bug was structurally invisible to local-dev — handleLocalConnect uses docker exec; only handleRemoteConnect exercises EIC. The CP provisioner shipped without the EIC ingress rule for ~6 months and nobody noticed until a paying tenant clicked Terminal. Continuous synth-E2E runs every 20 min; adding this probe means the same class of regression (CP provisioner ingress, EIC_ENDPOINT_SG_ID env, handleRemoteConnect chain, SDK source-group support) surfaces within ~20 min of merge instead of waiting for a user report. What: after Step 7 (workspace online), call GET /workspaces/$wid/terminal/diagnose for each workspace. The endpoint already exists in workspace-server (terminal_diagnose.go); it runs the full EIC + ssh chain from inside the tenant (which has AWS creds via its IAM profile) and returns {ok, first_failure, steps[]}. We just need to call it as the tenant — no AWS creds plumbed onto the GHA runner, no port-forwarding from CI. Local-docker workspaces (instance_id NULL) hit diagnoseLocal which probes docker.Ping + container exec; same ok=true contract, so the probe works on both production paths. This is a partial mitigation for task #269 (eliminate handleLocalConnect bypass — local must mimic prod terminal path). The architectural fix (refactor terminal.go so local docker also exercises an EIC-shaped sequence) remains pending; this PR is the "find out issues earlier" half of the user's directive. --- tests/e2e/test_staging_full_saas.sh | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/e2e/test_staging_full_saas.sh b/tests/e2e/test_staging_full_saas.sh index ce7f1e29..330affcf 100755 --- a/tests/e2e/test_staging_full_saas.sh +++ b/tests/e2e/test_staging_full_saas.sh @@ -429,6 +429,42 @@ for wid in $WS_TO_CHECK; do ok " $wid online" done +# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ──────────────────── +# This step exists because the canvas-terminal failure of 2026-05-03 +# was structurally invisible to local-dev (handleLocalConnect uses +# docker exec; handleRemoteConnect uses EIC + ssh). The CP provisioner +# shipped without the tcp/22 EIC ingress rule for ~6 months and nobody +# noticed until a paying tenant clicked Terminal in canvas. Probing the +# diagnose endpoint here at synth-E2E time means a regression in +# - tenantIngressRules / workspaceIngressRules (CP) +# - eicSSHIngressRule helper (CP) +# - AuthorizeIngress source-group support (CP awsapi) +# - EIC_ENDPOINT_SG_ID Railway env +# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain +# surfaces within ~20 min of merge instead of waiting for a user report. +# +# The diagnose endpoint runs the full EIC + ssh probe from inside the +# tenant's workspace-server (which already has AWS creds via its IAM +# profile) and reports per-step status. We only need to call it as the +# tenant — no AWS creds needed on the GHA runner. Returns +# {"ok": bool, "first_failure": "name", "steps": [...]}. +# +# Local-docker workspaces (instance_id NULL) get diagnoseLocal which +# probes docker.Ping + container exec; we still expect ok=true there +# since local-docker is the alternative production path. +log "7b/11 Canvas-terminal EIC diagnose probe..." +for wid in $WS_TO_CHECK; do + DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}') + DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false") + if [ "$DIAG_OK" = "true" ]; then + ok " $wid terminal-reachable (canvas terminal will work)" + else + DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown") + DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "") + fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health" + fi +done + # ─── 8. A2A round-trip on parent ─────────────────────────────────────── log "8/11 Sending A2A message to parent — expecting agent response..." # Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG" From eba0c5e3f19f14cf9512b59f544a250c6322faf4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 13:07:19 -0700 Subject: [PATCH 07/11] feat(canvas): add Hermes/Codex/OpenClaw tabs to ExternalConnectModal + default to Universal MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The External Connect modal had tabs for Python SDK / curl / Claude Code channel / Universal MCP. Operators using hermes / codex / openclaw as their external runtime had no copy-paste; they pieced together WORKSPACE_ID + PLATFORM_URL + auth_token into config files by reading docs. Adds three runtime-specific snippets stamped server-side: - **Hermes** — installs molecule-ai-workspace-runtime + the hermes-channel-molecule plugin, exports the 4 env vars, and writes the gateway.plugin_platforms.molecule block into ~/.hermes/config.yaml. Same long-poll-based push semantics the Claude Code channel tab delivers (push parity with the in-tree template-hermes adapter). - **Codex** — wires the molecule_runtime A2A MCP server into ~/.codex/config.toml ([mcp_servers.molecule] block with env_vars passthrough + literal env values). Outbound tools only — codex's MCP client doesn't route arbitrary notifications/* (verified by reading codex-rs/codex-mcp/src/connection_manager.rs); push parity on external codex would need a separate bridge daemon, tracked as future work. Snippet calls this out so operators know to pair with Python SDK if they need inbound delivery. - **OpenClaw** — installs openclaw + onboards, wires the molecule MCP server via openclaw mcp set, starts the gateway on loopback. Same outbound-tools-only caveat as codex; the in-tree template- openclaw adapter implements the full sessions.steer push path, but an external setup would need the same bridge daemon to translate platform inbox events into sessions.steer calls. Future work. Default open tab changed from "Claude Code" to "Universal MCP". Universal MCP is runtime-agnostic and works as a starting point for any operator regardless of their downstream agent runtime; runtime- specific tabs are still one click away. Pre-2026-05-03 the modal defaulted to Claude Code, so operators using non-Claude runtimes opened to a tab they had to skip past. Tab order also reorganized: Universal MCP → Python SDK → Claude Code → Hermes → Codex → OpenClaw → curl → Fields Each runtime-specific tab is gated on the platform supplying the snippet (older platform builds without the field don't show empty tabs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/ExternalConnectModal.tsx | 97 +++++++++- .../internal/handlers/external_connection.go | 166 ++++++++++++++++++ .../internal/handlers/workspace.go | 35 ++++ 3 files changed, 291 insertions(+), 7 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index ba63005b..af51c447 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -40,6 +40,22 @@ export interface ExternalConnectionInfo { // + inbound. Optional for backward compat with platforms that // haven't shipped PR #2413 yet. universal_mcp_snippet?: string; + // Hermes channel snippet — for operators whose external agent IS a + // hermes-agent session. Routes A2A traffic into the hermes gateway + // via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule). + // Long-poll based (no tunnel) — same UX shape as the Claude Code + // channel tab. Gives hermes true push parity. Optional for backward + // compat with platforms that haven't shipped this PR yet. + hermes_channel_snippet?: string; + // Codex MCP config snippet — wires the molecule MCP server into + // ~/.codex/config.toml so codex agents can call platform tools. + // Outbound-tools-only today (codex's MCP client doesn't route + // notifications/*); push parity would need a separate bridge daemon. + codex_snippet?: string; + // OpenClaw MCP config snippet — wires molecule MCP + starts the + // openclaw gateway on loopback. Outbound-tools-only today; push + // parity on an external openclaw needs a sessions.steer bridge. + openclaw_snippet?: string; } interface Props { @@ -47,13 +63,21 @@ interface Props { onClose: () => void; } -type Tab = "python" | "curl" | "claude" | "mcp" | "fields"; +type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields"; export function ExternalConnectModal({ info, onClose }: Props) { - // Default to Claude Code when the platform offers it — that's the - // newest + simplest path (no tunnel needed). Falls back to Python - // for older platform builds that don't ship the snippet. - const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python"; + // Default to Universal MCP when the platform offers it — runtime- + // agnostic outbound tool path that works for any MCP-aware runtime + // (Claude Code, hermes, codex, etc.) and lets operators inspect the + // primitives before picking a runtime-specific tab. Python SDK is + // the fallback for platforms predating the universal_mcp_snippet + // field. Pre-2026-05-03 the default was "claude" (Claude Code first) + // but operators using non-Claude runtimes opened to a tab they had + // to skip past — universal MCP works for everyone as a starting + // point and the runtime-specific tabs are still one click away. + const initialTab: Tab = info?.universal_mcp_snippet + ? "mcp" + : "python"; const [tab, setTab] = useState(initialTab); const [copiedKey, setCopiedKey] = useState(null); @@ -108,6 +132,24 @@ export function ExternalConnectModal({ info, onClose }: Props) { 'MOLECULE_WORKSPACE_TOKEN=""', `MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`, ); + // Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var + // name as Universal MCP). Stamp the auth_token in so the operator's + // copy-paste is fully ready-to-run. + const filledHermes = info.hermes_channel_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKEN=""', + `MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`, + ); + // Codex + OpenClaw snippets carry the placeholder inside the + // generated config block (TOML / JSON respectively). Stamp the + // token in so the copy-paste is one less manual edit. + const filledCodex = info.codex_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKEN = ""', + `MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`, + ); + const filledOpenClaw = info.openclaw_snippet?.replace( + 'WORKSPACE_TOKEN=""', + `WORKSPACE_TOKEN="${info.auth_token}"`, + ); return ( !o && onClose()}> @@ -135,10 +177,18 @@ export function ExternalConnectModal({ info, onClose }: Props) { // 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 (filledChannel) tabs.push("claude"); - tabs.push("python"); 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"); tabs.push("curl", "fields"); return tabs; })().map((t) => ( @@ -156,6 +206,12 @@ export function ExternalConnectModal({ info, onClose }: Props) { > {t === "claude" ? "Claude Code" + : t === "hermes" + ? "Hermes" + : t === "codex" + ? "Codex" + : t === "openclaw" + ? "OpenClaw" : t === "python" ? "Python SDK" : t === "mcp" @@ -205,6 +261,33 @@ export function ExternalConnectModal({ info, onClose }: Props) { onCopy={() => copy(filledUniversalMcp, "mcp")} /> )} + {tab === "hermes" && filledHermes && ( + copy(filledHermes, "hermes")} + /> + )} + {tab === "codex" && filledCodex && ( + copy(filledCodex, "codex")} + /> + )} + {tab === "openclaw" && filledOpenClaw && ( + copy(filledOpenClaw, "openclaw")} + /> + )} {tab === "fields" && (
copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} /> diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index d0313db3..13bde9c9 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -186,3 +186,169 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ` + +// externalHermesChannelTemplate — install snippet for operators whose +// external agent IS a hermes-agent session. Routes the workspace's +// A2A traffic into the running hermes gateway as platform messages +// via the molecule-channel plugin. +// +// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes +// platform adapter that: +// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a +// stdio MCP subprocess (separate from any hermes-side MCP +// client connection). +// 2. Long-polls ``wait_for_message`` on the platform's inbox. +// 3. Dispatches each inbound activity into the hermes gateway as a +// MessageEvent — same code path Telegram/Discord use. +// 4. Outbound replies route via ``send_message_to_user`` (canvas +// user) or ``delegate_task`` (peer agent) MCP tool calls. +// +// Result: hermes gets push parity with Claude Code / codex / openclaw — +// canvas messages and peer A2A arrive as conversation turns mid-session, +// not just at the start of a new ``hermes`` invocation. +// +// Plugin uses the upstream ``register_platform`` API shipped by +// NousResearch/hermes-agent#17751 (merged 2026-04-30) and falls back +// to the legacy ``register_platform_adapter`` shape on older forks — +// same wheel installs cleanly on stock or patched hermes-agent. +const externalHermesChannelTemplate = `# Hermes channel — bridges this workspace's A2A traffic into your +# hermes-agent session. No tunnel/public URL needed (long-poll based, +# same shape as the Claude Code channel). +# +# 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. +# +# 1. Install the runtime + plugin: +pip install molecule-ai-workspace-runtime +pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git' + +# 2. Export the workspace credentials: +export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}} +export MOLECULE_PLATFORM_URL={{PLATFORM_URL}} +export MOLECULE_WORKSPACE_TOKEN="" +export MOLECULE_ORG_ID="" + +# 3. Enable the platform in ~/.hermes/config.yaml. Add (or merge): +cat >> ~/.hermes/config.yaml <<'EOF' +gateway: + plugin_platforms: + molecule: + enabled: true +EOF + +# 4. Restart the hermes gateway: +hermes gateway --replace + +# Inbound canvas messages + peer A2A now arrive as MessageEvents — +# same dispatch path Telegram/Discord/Slack use. The agent replies via +# send_message_to_user / delegate_task MCP tool calls (already wired +# by the plugin's molecule_runtime MCP subprocess). +# +# Source + issue tracker: +# https://github.com/Molecule-AI/hermes-channel-molecule +` + +// externalCodexTemplate — for operators whose external agent is a +// codex CLI (@openai/codex) session. Wires the molecule_runtime A2A +// MCP server into codex's config.toml so the agent can call +// list_peers / delegate_task / send_message_to_user / commit_memory. +// +// Push parity caveat: codex's MCP client doesn't forward arbitrary +// notifications/* from configured MCP servers (verified by reading +// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So +// this snippet gives outbound tools but NOT mid-turn push from +// inbound A2A. For full push parity on a codex external, the +// equivalent of hermes-channel-molecule would be needed — a bridge +// daemon that long-polls the platform inbox and calls codex's +// turn/steer RPC. Tracked separately; this snippet is the +// outbound-tool-only first cut. +const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external +# agent is a codex CLI (@openai/codex) session. +# +# This wires the molecule platform's A2A MCP server into codex so +# the agent can call list_peers / delegate_task / send_message_to_user +# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks) +# does NOT push into the running codex turn yet — codex's MCP runtime +# doesn't route arbitrary notifications/* from configured MCP servers. +# For inbound delivery into a codex session, pair with the Python SDK +# tab for now. + +# 1. Install codex CLI + the workspace runtime wheel: +npm install -g @openai/codex@^0.57 +pip install molecule-ai-workspace-runtime + +# 2. Add the molecule MCP server to codex's config. {{PLATFORM_URL}} +# and {{WORKSPACE_ID}} are stamped server-side; paste the auth token +# for MOLECULE_WORKSPACE_TOKEN before saving. +mkdir -p ~/.codex +cat >> ~/.codex/config.toml <<'EOF' +[mcp_servers.molecule] +command = "python3" +args = ["-m", "molecule_runtime.a2a_mcp_server"] +startup_timeout_sec = 30 +env_vars = ["MOLECULE_INBOUND_SECRET", "PYTHONPATH"] + +[mcp_servers.molecule.env] +WORKSPACE_ID = "{{WORKSPACE_ID}}" +PLATFORM_URL = "{{PLATFORM_URL}}" +MOLECULE_WORKSPACE_TOKEN = "" +MOLECULE_ORG_ID = "" +EOF + +# 3. Run codex — the molecule tools are now available to the agent: +codex +` + +// externalOpenClawTemplate — for operators whose external agent is an +// openclaw session. Wires the molecule MCP server via openclaw's +// `mcp set` config + starts the openclaw gateway on loopback. +// +// Like the codex tab, this is outbound-only. Full push parity on an +// external openclaw would need a sessions.steer bridge daemon (the +// equivalent of hermes-channel-molecule for openclaw). Tracked +// separately; outbound tools is the first cut. +const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose +# external agent is an openclaw session. +# +# This wires the molecule platform's A2A MCP server into openclaw's +# gateway so the agent can call list_peers / delegate_task / +# send_message_to_user / commit_memory. Inbound A2A push into a +# running openclaw run is not wired here yet — the platform-side +# openclaw template (template-openclaw) implements the full +# 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. + +# 1. Install openclaw CLI + the workspace runtime wheel: +npm install -g openclaw@latest +pip install molecule-ai-workspace-runtime + +# 2. Onboard openclaw against your provider (sets up auth-profiles + +# the workspace dir). Skip if already done on this host. +openclaw onboard --non-interactive + +# 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}} +# are stamped server-side; paste the auth token before running. +WORKSPACE_TOKEN="" +MOLECULE_ORG_ID="" +openclaw mcp set molecule "$(cat < Date: Sun, 3 May 2026 13:12:54 -0700 Subject: [PATCH 08/11] =?UTF-8?q?fix(external-connect):=20address=20self-r?= =?UTF-8?q?eview=20criticals=20=E2=80=94=20config=20corruption=20+=20durab?= =?UTF-8?q?ility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the modal-tab additions caught footguns in the new hermes/codex/openclaw snippets. Ship the fixes before merge. Critical 1 — Hermes `cat >> ~/.hermes/config.yaml` corrupts existing configs. Most existing hermes installs have a top-level gateway: block; appending creates a duplicate, which YAML rejects. Replaced the auto-append with explicit instructions: 'under your existing gateway: block, add a plugin_platforms entry'. Critical 2 — Codex `cat >> ~/.codex/config.toml` corrupts on re-run. TOML rejects duplicate [mcp_servers.molecule] tables; a second run breaks codex parse. Replaced auto-append with commented config block + explicit 'open ~/.codex/config.toml in your editor and paste'. Canvas-side token stamping still hits the literal in the comment so the operator's clipboard has the real token already substituted. Required 3 — OpenClaw `onboard --non-interactive` missing provider/model defaults. Added explicit --provider + --model placeholders in a commented form so operators see what's needed without a stub default applying silently. Required 4 — OpenClaw gateway started with bare '&' dies on terminal close. Switched to nohup + log file + disown, with a note that systemd is the right answer for production. Optional 5 + 6 (env_vars cleanup, tests) deferred — env_vars stripped to keep the in-tree-vs-external surface narrow; tests for the new response fields can land separately when external_connection.go is next touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/external_connection.go | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index 13bde9c9..f3ff3553 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -229,13 +229,19 @@ export MOLECULE_PLATFORM_URL={{PLATFORM_URL}} export MOLECULE_WORKSPACE_TOKEN="" export MOLECULE_ORG_ID="" -# 3. Enable the platform in ~/.hermes/config.yaml. Add (or merge): -cat >> ~/.hermes/config.yaml <<'EOF' -gateway: - plugin_platforms: - molecule: - enabled: true -EOF +# 3. Edit ~/.hermes/config.yaml — under your existing top-level +# gateway: block, add a plugin_platforms entry: +# +# gateway: +# # ...your existing gateway settings... +# plugin_platforms: +# molecule: +# enabled: true +# +# If you don't yet have a gateway: block, create one with just +# that plugin_platforms entry. Don't append blindly — YAML +# rejects duplicate top-level keys, so a second gateway: block +# will silently break hermes config loading. # 4. Restart the hermes gateway: hermes gateway --replace @@ -278,23 +284,29 @@ const externalCodexTemplate = `# Codex MCP config — outbound tool path. For op npm install -g @openai/codex@^0.57 pip install molecule-ai-workspace-runtime -# 2. Add the molecule MCP server to codex's config. {{PLATFORM_URL}} -# and {{WORKSPACE_ID}} are stamped server-side; paste the auth token -# for MOLECULE_WORKSPACE_TOKEN before saving. -mkdir -p ~/.codex -cat >> ~/.codex/config.toml <<'EOF' -[mcp_servers.molecule] -command = "python3" -args = ["-m", "molecule_runtime.a2a_mcp_server"] -startup_timeout_sec = 30 -env_vars = ["MOLECULE_INBOUND_SECRET", "PYTHONPATH"] +# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}} +# and {{WORKSPACE_ID}} are stamped server-side; paste your auth +# token for MOLECULE_WORKSPACE_TOKEN before saving. +# +# 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. -[mcp_servers.molecule.env] -WORKSPACE_ID = "{{WORKSPACE_ID}}" -PLATFORM_URL = "{{PLATFORM_URL}}" -MOLECULE_WORKSPACE_TOKEN = "" -MOLECULE_ORG_ID = "" -EOF +mkdir -p ~/.codex +# (then open ~/.codex/config.toml in your editor and paste:) +# +# [mcp_servers.molecule] +# command = "python3" +# args = ["-m", "molecule_runtime.a2a_mcp_server"] +# startup_timeout_sec = 30 +# +# [mcp_servers.molecule.env] +# WORKSPACE_ID = "{{WORKSPACE_ID}}" +# PLATFORM_URL = "{{PLATFORM_URL}}" +# MOLECULE_WORKSPACE_TOKEN = "" +# MOLECULE_ORG_ID = "" # 3. Run codex — the molecule tools are now available to the agent: codex @@ -324,9 +336,14 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. npm install -g openclaw@latest pip install molecule-ai-workspace-runtime -# 2. Onboard openclaw against your provider (sets up auth-profiles + -# the workspace dir). Skip if already done on this host. -openclaw onboard --non-interactive +# 2. Onboard openclaw against your model provider (one-time setup). +# --non-interactive needs an explicit --provider + --model so it +# doesn't prompt; pick what matches your API key. Skip step 2 if +# you've already onboarded on this host. +# +# openclaw onboard --non-interactive \ +# --provider openai \ +# --model gpt-5 # 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}} # are stamped server-side; paste the auth token before running. @@ -346,8 +363,13 @@ openclaw mcp set molecule "$(cat < ~/.openclaw/gateway.log 2>&1 & +disown # 5. Run an agent turn — molecule tools are now available: openclaw agent --message "list my peers" From 1d303ee75e4921b78b8b71a18feb238af8069994 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 13:23:08 -0700 Subject: [PATCH 09/11] a11y(canvas): Tooltip Esc-to-dismiss (WCAG 1.4.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 1.4.13 (Content on Hover or Focus) requires that tooltip content be DISMISSIBLE without moving pointer hover or keyboard focus. Tooltip had no escape hatch — once a keyboard user tabbed onto a control with a tooltip, the tooltip stayed visible until they tabbed away (which moves focus and may not be possible if the tooltip is itself blocking content the user needs to see, e.g. for screen-magnifier users). Add a window-level Escape listener that's active only while a tooltip is shown. Pressing Esc clears the tooltip without moving focus or breaking the hover state, satisfying the dismissible criterion. Used `capture: true` so we beat any modal/dialog Esc handler that might also be listening — the tooltip belongs to the focused control, not the modal it sits inside. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/Tooltip.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/canvas/src/components/Tooltip.tsx b/canvas/src/components/Tooltip.tsx index a1ce074c..d694ec28 100644 --- a/canvas/src/components/Tooltip.tsx +++ b/canvas/src/components/Tooltip.tsx @@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) { useEffect(() => () => clearTimeout(timerRef.current), []); + // WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism + // is available to dismiss the additional content WITHOUT moving + // pointer hover or keyboard focus. Esc dismisses while the trigger + // stays focused/hovered, so a screen-magnifier user can read what + // the tooltip was covering without losing their place. + useEffect(() => { + if (!show) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + clearTimeout(timerRef.current); + setShow(false); + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [show]); + const enter = useCallback(() => { timerRef.current = setTimeout(() => { if (triggerRef.current) { From 3b244ca6c6375f0f4ec7081ff2cfca477ce37326 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 13:55:24 -0700 Subject: [PATCH 10/11] canvas/Toaster: add Esc dismiss + focus-visible ring + larger touch target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small a11y fixes for the global toast surface: 1. Esc dismisses the newest toast. Errors never auto-expire, so without a keyboard shortcut a keyboard-only user has to tab through the entire app to reach the × button on a stuck error. 2. Dismiss button gets focus-visible ring + theme-aware tint. The previous `opacity-70 hover:opacity-100` gave no visible focus indicator (WCAG 2.4.7). Info toasts use the semantic surface that flips with theme, so the dismiss tint splits per type — accent ring on info, white ring on the always-dark success/error toasts. 3. Touch target bumps from p-1 (~24x24) to w-7 h-7 (28x28) toward WCAG 2.5.5 AAA's 44x44 ideal. Tests: 5 new vitest cases covering Esc on info/error, no-op on empty queue, accessible label, and per-toast click dismissal. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/Toaster.tsx | 27 +++++- .../src/components/__tests__/Toaster.test.tsx | 82 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 canvas/src/components/__tests__/Toaster.test.tsx diff --git a/canvas/src/components/Toaster.tsx b/canvas/src/components/Toaster.tsx index 13396845..f8767fb3 100644 --- a/canvas/src/components/Toaster.tsx +++ b/canvas/src/components/Toaster.tsx @@ -38,6 +38,18 @@ export function Toaster() { }; }, []); + // Esc dismisses the newest toast — keyboard parity with the × button. + // Errors never auto-expire, so without this a keyboard-only user has to + // tab through the entire app to reach the dismiss button on a stuck error. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1))); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + const toastCls = (type: Toast["type"]) => `flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${ type === "success" @@ -47,6 +59,17 @@ export function Toaster() { : "bg-surface-sunken/90 border border-line/40 text-ink" }`; + // Success/error toasts are intentionally dark in both themes (high-vis). + // Info uses the semantic surface that flips with theme — so the dismiss + // button needs a tint that stays visible on a light bg in light mode. + const dismissCls = (type: Toast["type"]) => { + const base = + "ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0"; + return type === "info" + ? `${base} hover:bg-ink/10 focus-visible:ring-accent/60` + : `${base} hover:bg-white/15 focus-visible:ring-white/70`; + }; + const pos = "fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center"; @@ -66,7 +89,7 @@ export function Toaster() { type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification" - className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0" + className={dismissCls(toast.type)} > × @@ -94,7 +117,7 @@ export function Toaster() { type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification" - className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0" + className={dismissCls(toast.type)} > × diff --git a/canvas/src/components/__tests__/Toaster.test.tsx b/canvas/src/components/__tests__/Toaster.test.tsx new file mode 100644 index 00000000..6f6f35ed --- /dev/null +++ b/canvas/src/components/__tests__/Toaster.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { Toaster, showToast } from "../Toaster"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("Toaster keyboard a11y", () => { + it("Esc dismisses the most recent toast", () => { + render(); + act(() => { + showToast("first", "info"); + showToast("second", "info"); + }); + expect(screen.getByText("first")).toBeTruthy(); + expect(screen.getByText("second")).toBeTruthy(); + + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + expect(screen.queryByText("second")).toBeNull(); + expect(screen.getByText("first")).toBeTruthy(); + }); + + it("Esc dismisses persistent error toasts", () => { + render(); + act(() => { + showToast("boom", "error"); + }); + expect(screen.getByText("boom")).toBeTruthy(); + + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + expect(screen.queryByText("boom")).toBeNull(); + }); + + it("Esc with no toasts is a no-op", () => { + render(); + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + // no throw, nothing rendered + expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0); + }); + + it("dismiss button has accessible label and is keyboard reachable", () => { + render(); + act(() => { + showToast("hi", "info"); + }); + const btn = screen.getByRole("button", { name: "Dismiss notification" }); + expect(btn).toBeTruthy(); + // Native