molecule-core/workspace-server/internal/handlers/external_connection.go
Hongming Wang d5eb58af56 feat(external-connect): comprehensive setup — fix Claude Code channel snippet + add per-tab Help section
User report: handing the modal's Claude Code channel snippet to an
agent fails immediately with two errors that the snippet doesn't tell
the operator how to resolve:

  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · plugin not installed
  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · not on the approved channels allowlist

Root cause: the snippet's `claude --channels plugin:...` line assumes
the plugin is pre-installed AND that the channel is on Anthropic's
default allowlist. Both assumptions are wrong for a custom Molecule
plugin in a public repo.

Two changes:

1. Rewrite externalChannelTemplate (Go) with full setup chain:
   - Bun prereq check (channel plugins are Bun scripts)
   - `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel`
     + `/plugin install molecule@molecule-mcp-claude-channel` BEFORE the
     launch — otherwise "plugin not installed"
   - `--dangerously-load-development-channels` flag on launch — required
     for non-Anthropic-allowlisted channels, otherwise "not on approved
     channels allowlist"
   - Common-errors block at the bottom mapping each error string to
     which numbered step recovers it
   - Team/Enterprise managed-settings caveat (the dev-channels flag is
     blocked there; admin must use channelsEnabled + allowedChannelPlugins)

   Plugin install info verified by reading `Molecule-AI/molecule-mcp-claude-channel`
   plugin.json (`name: "molecule"`) and the Claude Code channels +
   plugin-discovery docs at code.claude.com/docs/en/{channels,discover-plugins}.

2. Add per-tab HelpBlock to the modal (canvas):
   - Collapsible <details> below each snippet, closed by default so the
     snippet stays the visual focus
   - "Where to install" link (PyPI for runtime, claude.com for Claude
     Code, github.com/openai/codex for Codex, NousResearch/hermes-agent
     for Hermes)
   - "Documentation" link (docs.molecule.ai/docs/guides/*; hostname
     confirmed by existing blog post canonical metadata; paths map
     1:1 to docs/guides/*.md files in this repo)
   - "Common errors" list with concrete recovery steps for each tab
     (e.g. Codex tab calls out the codex≥0.57 requirement and TOML
     duplicate-table parse error; OpenClaw calls out the :18789 port
     conflict check)

   URL discipline: every URL is either (a) verified against a file path
   in this repo's docs/, (b) the canonical repo of an existing snippet
   reference, or (c) a well-known third-party canonical URL. No guessed
   URLs — broken links would defeat the purpose of "more comprehensive
   instructions."

Verification:
- `go build ./...` clean in workspace-server
- `go test ./internal/handlers/...` passes (4.3s)
- Bash syntax check on test_staging_full_saas.sh (no edits there) clean
- TS brace/paren/bracket counts balanced; no full tsc run because the
  worktree's node_modules isn't installed — counterpart Canvas tabs E2E
  on the PR will exercise the full type-check + render path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:46:55 -07:00

409 lines
17 KiB
Go

package handlers
// external_connection.go — copy-paste connection payload shown once to
// the operator when they create a runtime="external" workspace.
//
// The canvas UI surfaces these in a single modal so the operator can
// hand the block to whoever runs their external agent without having
// to piece together workspace_id + platform_url + auth_token + API
// shape from the docs. curl snippet has zero dependencies; Python
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
import (
"os"
"github.com/gin-gonic/gin"
)
// externalPlatformURL returns the public URL at which this workspace-
// server instance is reachable by the operator's external agent. This
// is NOT necessarily the caller's Host header (which could be an
// internal CF tunnel hostname). Prefer the EXTERNAL_PLATFORM_URL env
// that Railway/ops sets for the tenant; fall back to the request's
// Host + scheme if unset.
func externalPlatformURL(c *gin.Context) string {
if v := os.Getenv("EXTERNAL_PLATFORM_URL"); v != "" {
return v
}
scheme := "https"
if xf := c.Request.Header.Get("X-Forwarded-Proto"); xf != "" {
scheme = xf
} else if c.Request.TLS == nil {
scheme = "http"
}
host := c.Request.Host
if xh := c.Request.Header.Get("X-Forwarded-Host"); xh != "" {
host = xh
}
return scheme + "://" + host
}
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
// - $AGENT_URL — env var, operator's public HTTPS endpoint
//
// SSRF filter rejects private IPs at register time, so AGENT_URL must
// resolve to a public host.
//
// Heartbeat loop is NOT included here — curl is fine for one-shot
// register; keeping the workspace alive wants a real loop, so point
// operators at the Python snippet for long-lived setups.
const externalCurlTemplate = `# Replace AGENT_URL with YOUR agent's public HTTPS endpoint, then run:
export WORKSPACE_AUTH_TOKEN="<paste from create response>"
export AGENT_URL="https://your-agent.example.com"
# NOTE on the "Origin" header below: hosted SaaS tenants run behind an
# edge WAF that requires same-origin requests. Without "Origin", paths
# like /workspaces/* silently 404 (rewritten to the canvas Next.js).
# /registry/register is currently allowed without Origin, but setting
# it preemptively keeps your snippet working if the WAF rules expand.
curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
-H "Authorization: Bearer $WORKSPACE_AUTH_TOKEN" \
-H "Origin: {{PLATFORM_URL}}" \
-H "Content-Type: application/json" \
-d '{
"id": "{{WORKSPACE_ID}}",
"url": "'"$AGENT_URL"'",
"agent_card": {
"name": "My External Agent",
"description": "",
"version": "0.1.0"
}
}'
`
// externalChannelTemplate — Claude Code channel plugin install + .env. For
// operators whose external agent IS a Claude Code session (laptop or
// remote dev VM); routes the workspace's A2A traffic into the running
// Claude Code session as conversation turns via MCP. The plugin source
// lives at github.com/Molecule-AI/molecule-mcp-claude-channel — polling
// based, no tunnel required (uses /workspaces/:id/activity?since_secs=,
// platform-side support shipped in #2300).
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
#
# 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:
#
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
# /plugin install molecule@molecule-mcp-claude-channel
#
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
#
# 2. Create the per-watched-workspace config file:
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}}
MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
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-mcp-claude-channel
# You should see on stderr:
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
#
# 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.
#
# Common errors:
# "plugin not installed" → Step 1 didn't run; run /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.
#
# 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.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
`
// externalUniversalMcpTemplate — runtime-agnostic standalone path.
// Ships as the `molecule-mcp` console script in the
// molecule-ai-workspace-runtime PyPI wheel (workspace/mcp_cli.py).
// Any MCP-aware runtime (Claude Code, hermes, codex, third-party)
// registers it once and gets the same 8 universal tools that
// container-bound runtimes use today: delegate_task, list_peers,
// send_message_to_user, commit_memory, etc.
//
// Standalone: the binary itself handles register-on-startup +
// continuous heartbeats (daemon thread, 20s cadence). No separate
// SDK or channel process needed to keep the workspace online. The
// only thing it does NOT yet do is poll inbound A2A messages — for
// runtimes that need their agent to react to canvas messages or
// peer-initiated tasks, pair with the Claude Code channel tab
// (poll-based inbound delivery into a Claude Code session) or the
// Python SDK tab (push-mode inbound + heartbeat).
//
// Origin/WAF: handled automatically by platform_auth.auth_headers()
// in the wheel — operator doesn't need to configure anything.
const externalUniversalMcpTemplate = `# Universal MCP — standalone register + heartbeat + outbound platform tools
# 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).
# 1. Install the workspace runtime 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 \
WORKSPACE_ID={{WORKSPACE_ID}} \
PLATFORM_URL={{PLATFORM_URL}} \
MOLECULE_WORKSPACE_TOKEN="<paste from create response>" \
molecule-mcp
# molecule-mcp registers the workspace + heartbeats every 20s in a
# daemon thread, then runs the MCP stdio loop. Same env-var contract
# works with hermes-agent, codex, or any MCP stdio runtime. Tools
# exposed: delegate_task, delegate_task_async, check_task_status,
# list_peers, get_workspace_info, send_message_to_user,
# commit_memory, recall_memory.
#
# Origin/WAF handling is built into the wheel — no manual headers
# needed when calling tools through the MCP server.
`
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
// to PyPI the snippet pins git+main.
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
import asyncio
from molecule_agent import RemoteAgentClient, A2AServer
WORKSPACE_ID = "{{WORKSPACE_ID}}"
PLATFORM_URL = "{{PLATFORM_URL}}"
AUTH_TOKEN = "<paste from create response>"
INBOUND_URL = "https://your-agent.example.com/a2a/inbound" # your public HTTPS endpoint
async def handle(request: dict) -> dict:
# request has parts, message, task_id, idempotency_key
text = "".join(p.get("text", "") for p in request.get("parts", []) if p.get("type") == "text")
return {"parts": [{"type": "text", "text": f"echo: {text}"}]}
async def main():
client = RemoteAgentClient(
workspace_id=WORKSPACE_ID,
platform_url=PLATFORM_URL,
auth_token=AUTH_TOKEN,
)
server = A2AServer(
agent_id=client.workspace_id,
inbound_url=INBOUND_URL,
message_handler=handle,
)
server.start_in_background()
client.reported_url = INBOUND_URL
client.register() # one-shot announcement
await client.run_heartbeat_loop_async() # keeps the workspace online
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="<paste from create response>"
export MOLECULE_ORG_ID="<your org id>"
# 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
# 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. 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.
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 = "<paste from create response>"
# MOLECULE_ORG_ID = "<your org id>"
# 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 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.
WORKSPACE_TOKEN="<paste from create response>"
MOLECULE_ORG_ID="<your org id>"
openclaw mcp set molecule "$(cat <<EOF
{
"command": "python3",
"args": ["-m", "molecule_runtime.a2a_mcp_server"],
"env": {
"WORKSPACE_ID": "{{WORKSPACE_ID}}",
"PLATFORM_URL": "{{PLATFORM_URL}}",
"MOLECULE_WORKSPACE_TOKEN": "$WORKSPACE_TOKEN",
"MOLECULE_ORG_ID": "$MOLECULE_ORG_ID"
}
}
EOF
)"
# 4. Start the openclaw gateway as a durable background process.
# A bare '&' dies when the terminal closes; nohup + log file keeps
# the gateway alive across logout. For systemd-managed hosts,
# register a unit instead.
nohup openclaw gateway --dev --port 18789 --bind loopback \
> ~/.openclaw/gateway.log 2>&1 &
disown
# 5. Run an agent turn — molecule tools are now available:
openclaw agent --message "list my peers"
`