diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index 579d75a06..2d57b2a65 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -216,69 +216,102 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \ const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your # Claude Code session. No tunnel/public URL needed (polling-based). # -# Prereq: Bun installed (channel plugins are Bun scripts). -# bun --version # must print a version number +# Prereq: Bun 1.3+ installed (channel plugins are Bun scripts). +# bun --version # must print a version (1.3.x or newer) # -# 1. Inside Claude Code, install the channel plugin from its GitHub repo. -# The plugin is NOT on Anthropic's default allowlist, so a one-time -# marketplace-add is needed before install: +# 1. Inside Claude Code, install the channel plugin. The plugin lives in +# Molecule's own Gitea marketplace (not Anthropic's default), so a +# one-time marketplace-add is needed before install: # # /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git # /plugin install molecule@molecule-channel # -# Then either run /reload-plugins or restart Claude Code so the -# plugin is registered. +# Then /reload-plugins (or restart Claude Code) so the plugin is +# registered. # -# 2. Create the per-watched-workspace config file: +# 2. Create (or extend) the per-host config file. The canonical SSOT +# shape is MOLECULE_WORKSPACES_JSON — a JSON array of +# {id, token, platform_url} objects. One plugin instance can watch +# many workspaces across many tenants; append more objects to the +# array (separate them with commas, NOT a newline): mkdir -p ~/.claude/channels/molecule cat > ~/.claude/channels/molecule/.env <<'EOF' -MOLECULE_PLATFORM_URL={{PLATFORM_URL}} -MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}} -MOLECULE_WORKSPACE_TOKENS= +MOLECULE_WORKSPACES_JSON=[{"id":"{{WORKSPACE_ID}}","token":"","platform_url":"{{PLATFORM_URL}}"}] EOF chmod 600 ~/.claude/channels/molecule/.env -# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic- -# allowlisted) channels need the --dangerously-load-development-channels -# flag to opt in — without it, you'll see "not on the approved channels -# allowlist" on startup. -claude --dangerously-load-development-channels \ - --channels plugin:molecule@molecule-channel +# (Legacy single-platform shape — MOLECULE_PLATFORM_URL + comma-separated +# MOLECULE_WORKSPACE_IDS + MOLECULE_WORKSPACE_TOKENS — is still supported +# for back-compat but does NOT work across multiple tenant URLs. Use +# MOLECULE_WORKSPACES_JSON above unless you have a specific reason.) + +# 3. Launch Claude Code with the channel enabled. The channel spec is the +# VALUE of --dangerously-load-development-channels — NOT a separate +# --channels flag (that flag does not exist in current Claude Code; +# passing it errors with "entries must be tagged: --channels"). +claude --dangerously-load-development-channels plugin:molecule@molecule-channel # You should see on stderr: -# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}} +# molecule channel: connected — watching N workspace(s) across M platform(s) +# targets: : # -# Inbound A2A messages now surface as conversation turns. Claude's -# replies route back via the reply_to_workspace MCP tool — no extra -# wiring on your side. +# Inbound A2A messages now surface as conversation turns (synthetic +# tags). Claude's replies route back via the +# reply_to_workspace / send_message_to_user MCP tools. +# +# Multi-workspace note: when watching more than one workspace, every +# outbound tool call (send_message_to_user, reply_to_workspace, +# delegate_task, list_peers) MUST pass _as_workspace= so the plugin +# knows which token to authenticate with. The host returns -32603 if you +# forget — the synthetic tag's "watching_as" attribute tells +# you which id to use. # # Common errors: -# "plugin not installed" → Step 1 didn't run; run /plugin install +# "plugin not installed" → Step 1 didn't run; run /plugin +# marketplace add + /plugin install # inside Claude Code, then /reload-plugins. -# "not on approved channels allowlist" → Add --dangerously-load-development-channels -# to the launch command (Step 3). -# "config-missing" → ~/.claude/channels/molecule/.env not -# readable; re-run Step 2 and check chmod. +# "entries must be tagged" → You passed --channels separately. +# Put plugin:molecule@molecule-channel +# directly after +# --dangerously-load-development-channels. +# "not on approved channels allowlist" → Org policy gating. See "managed +# settings" note below. +# "config-missing" → ~/.claude/channels/molecule/.env +# not readable; re-run Step 2 and check +# chmod 600. # -# Team/Enterprise orgs: the --dangerously-load-development-channels flag is -# blocked by managed settings. Your admin must set channelsEnabled=true and -# add the plugin to allowedChannelPlugins in claude.ai admin settings. +# Team/Enterprise plans: the channel allowlist is gated by org policy +# AND must be written to the local managed-settings.json file on disk +# (not the claude.ai web admin UI — there is no web toggle for this). +# Path per OS: +# macOS: /Library/Application Support/ClaudeCode/managed-settings.json +# Linux: /etc/claude-code/managed-settings.json +# Windows: C:\ProgramData\ClaudeCode\managed-settings.json +# Set channelsEnabled: true and add +# { "plugin": "molecule", "marketplace": "molecule-channel" } +# to allowedChannelPlugins. Restart Claude Code after writing the file. +# A user-level ~/.claude/settings.json does NOT work on Team/Enterprise +# — this is the single most common reason a freshly-installed plugin +# appears to do nothing. # -# Multi-workspace: comma-separate IDs and tokens (same order). See -# https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel for -# pairing flow, push-mode upgrade, and v0.2 roadmap. +# Pro/Max plans skip the channelsEnabled gate but still need the +# allowedChannelPlugins entry in the managed-settings file. # Need help? # Documentation: https://doc.moleculesai.app/docs/guides/claude-code-channel-plugin +# Full README: https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel # Common errors: # • "plugin not installed" — run /plugin marketplace add then # /plugin install lines above; /reload-plugins or restart. +# • "entries must be tagged: --channels" — the launch flag form +# changed; use --dangerously-load-development-channels plugin:molecule@molecule-channel +# (channel spec is the VALUE, not a separate --channels flag). # • "not on the approved channels allowlist" — custom channels need -# --dangerously-load-development-channels; team/enterprise orgs -# need admin to set channelsEnabled + allowedChannelPlugins. +# allowedChannelPlugins in /Library/Application Support/ClaudeCode/managed-settings.json +# (macOS) / equivalent on Linux+Windows. NOT a web setting. # • "Inbound messages not arriving" — stderr should show # "molecule channel: connected — watching N workspace(s)"; -# verify ~/.claude/channels/molecule/.env has PLATFORM_URL + token. +# verify ~/.claude/channels/molecule/.env shape is MOLECULE_WORKSPACES_JSON. ` // externalUniversalMcpTemplate — runtime-agnostic standalone path. @@ -670,7 +703,15 @@ def heartbeat(client, url, ws, tok, start): r.raise_for_status() def poll_inbound(client, url, ws, tok, since_id): - params = {"since_secs": "30", "limit": "50"} + # include=peer_info opts into Layer 1's row-level projection so each + # polled activity carries peer_name, peer_role, agent_card_url, and + # attachments[] inline (when source_id resolves to a peer / when the + # message included a file). Pre-Layer-1 platforms ignore unknown query + # params and return the bare row shape, so this is back-compat. Use + # the extra fields in your reply logic — e.g. address the sender by + # peer_name rather than UUID, or Read attached files via the workspace: + # URIs in attachments[]. + params = {"since_secs": "30", "limit": "50", "include": "peer_info"} if since_id: params["since_id"] = since_id r = client.get(f"{url}/workspaces/{ws}/activity", params=params, headers=hdrs(url, tok)) @@ -737,10 +778,16 @@ python3 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py # What the script does: # • Registers the workspace in poll mode (no public URL needed) # • Heartbeats every 20s to keep STATUS = online on the canvas -# • Polls /workspaces/:id/activity every 5s for new canvas messages +# • Polls /workspaces/:id/activity?include=peer_info every 5s — Layer 1 +# enrichment surfaces peer_name / peer_role / agent_card_url / +# attachments[] inline on each polled row when applicable # • Echo-replies via POST /workspaces/:id/notify # # To change the reply logic, edit the send_reply() call inside the loop. +# Each polled item has top-level peer_name / peer_role / agent_card_url +# fields (peer_agent rows) and attachments[] (any kind) when Layer 1 is +# enabled on the platform — use them to disambiguate senders and to Read +# attached files via the workspace: URIs. # 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-{{MCP_SERVER_NAME}}/env | grep TOKEN | cut -d= -f2)" \ diff --git a/workspace-server/internal/handlers/external_connection_test.go b/workspace-server/internal/handlers/external_connection_test.go index 0b9a0fa3f..b74c81199 100644 --- a/workspace-server/internal/handlers/external_connection_test.go +++ b/workspace-server/internal/handlers/external_connection_test.go @@ -118,3 +118,86 @@ func TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs(t *testing.T) { } } } + +// TestExternalChannelTemplate_LaunchFlagShape pins the Claude Code channel +// snippet to the working launch invocation. The channel spec must be the +// VALUE of --dangerously-load-development-channels, NOT a separate +// --channels flag. The two-flag form (`--dangerously-load-development-channels +// --channels plugin:molecule@...`) errors with "entries must be tagged: +// --channels" on current Claude Code builds (2.1.143+) and silently no-ops +// on older ones — either way, new users hit a wall on first launch. +// +// Empirical: hit by a session walking through this exact snippet 2026-05-21; +// the broken form was copy-pasted from this template, ran, errored, and +// confused the operator into believing the plugin install was broken when +// the snippet itself was the bug. +func TestExternalChannelTemplate_LaunchFlagShape(t *testing.T) { + // The broken two-flag form. If this string ever appears in the + // snippet again, the same onboarding pothole returns. + bannedFormBroken := "--dangerously-load-development-channels \\\n --channels plugin:molecule@molecule-channel" + if strings.Contains(externalChannelTemplate, bannedFormBroken) { + t.Errorf("externalChannelTemplate contains the broken two-flag launch form. " + + "Use --dangerously-load-development-channels plugin:molecule@molecule-channel (spec as value, not a separate --channels flag).") + } + + // The single-flag form must be present. + requiredFormGood := "--dangerously-load-development-channels plugin:molecule@molecule-channel" + if !strings.Contains(externalChannelTemplate, requiredFormGood) { + t.Errorf("externalChannelTemplate must contain %q so operators see the working launch invocation", requiredFormGood) + } +} + +// TestExternalChannelTemplate_CanonicalEnvShape pins the canvas-served +// .env example to the canonical SSOT shape (MOLECULE_WORKSPACES_JSON) +// rather than the legacy single-platform shape. The legacy form +// (MOLECULE_PLATFORM_URL + comma-separated IDs/TOKENS) is still accepted +// by the channel plugin's parseWorkspaceTargets but is single-tenant +// only — it silently fails to onboard users who want to watch multiple +// platforms (e.g. hongming + agents-team from the same plugin instance), +// which is the post-PR#15 expected use case. +func TestExternalChannelTemplate_CanonicalEnvShape(t *testing.T) { + if !strings.Contains(externalChannelTemplate, "MOLECULE_WORKSPACES_JSON=") { + t.Errorf("externalChannelTemplate must use MOLECULE_WORKSPACES_JSON as the canonical .env shape (the post-PR#15 SSOT)") + } + // The JSON example must contain the workspace_id + platform_url placeholders + // so the canvas substitutes them at serve time. + for _, ph := range []string{"{{WORKSPACE_ID}}", "{{PLATFORM_URL}}"} { + if !strings.Contains(externalChannelTemplate, ph) { + t.Errorf("externalChannelTemplate must contain placeholder %q so the canvas substitutes per-workspace values", ph) + } + } +} + +// TestPollingTemplates_OptIntoPeerInfo pins the invariant that any template +// which calls /workspaces/:id/activity for inbound delivery requests the +// Layer 1 enrichment via ?include=peer_info. Without this opt-in, the +// platform returns bare activity rows and the operator's bridge / channel +// loses peer_name / peer_role / agent_card_url / attachments[] — they're +// available on the server but not delivered. +// +// Pre-Layer-1 platforms ignore unknown query params (HTTP spec: filters +// not understood are dropped), so this is back-compat across deploys. +// +// The Claude Code channel template doesn't include the poll URL in this +// snippet — its polling lives in the plugin's own server.ts (handled by +// molecule-mcp-claude-channel PR#21). The Kimi template DOES include a +// poll loop in its kimi_bridge.py block, so the invariant applies there. +func TestPollingTemplates_OptIntoPeerInfo(t *testing.T) { + pollingTemplates := map[string]string{ + "externalKimiTemplate": externalKimiTemplate, + } + for name, body := range pollingTemplates { + // If the snippet polls /activity, it must opt into peer_info. + // The detection is intentionally loose ("/activity" appears in + // the script) — operators who customize the script keep the + // invariant only if the include hint is in the template. + if !strings.Contains(body, "/activity") { + t.Errorf("%s no longer polls /activity — review whether this test still applies", name) + continue + } + if !strings.Contains(body, `"include": "peer_info"`) && !strings.Contains(body, "include=peer_info") { + t.Errorf("%s polls /activity without ?include=peer_info — operators lose Layer 1 enrichment "+ + "(peer_name / peer_role / agent_card_url / attachments[]). Add the param to the poll URL.", name) + } + } +}