Compare commits

...

2 Commits

Author SHA1 Message Date
plugin-dev 5599800bae docs(onboarding): kimi bridge — opt into Layer 1 ?include=peer_info
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m36s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m0s
CI / Platform (Go) (pull_request) Successful in 4m59s
CI / all-required (pull_request) Successful in 11m26s
audit-force-merge / audit (pull_request) Successful in 6s
Audited all 9 templates in external_connection.go against current platform
state. Channel (Claude Code) is fixed in the parent commit on this branch.
The remaining templates are mostly fine — UniversalMCP / Python / Hermes /
Codex / OpenClaw all use per-invocation env or workspace-specific server
names that handle multi-workspace correctly through their respective
runtime config (claude.json / config.yaml / config.toml / openclaw mcp).

The one outlier is the Kimi bridge script, which polls /activity in a Python
loop. That poll was bare — no ?include=peer_info — so Kimi operators saw
the legacy row shape and never received the Layer 1 enrichment fields
(peer_name / peer_role / agent_card_url / attachments[]) that the platform
projects when asked.

Changes:
- kimi_bridge.py poll_inbound: add `"include": "peer_info"` to the
  params dict. Pre-Layer-1 platforms ignore the unknown filter and
  return the bare row shape (HTTP spec: unknown query params are
  dropped silently) — back-compat across deploys.
- "What the script does" comment block notes the enrichment + points
  operators at how to use the new fields in their custom logic.

Adds TestPollingTemplates_OptIntoPeerInfo as a regression gate — any
template that polls /activity must opt into the enrichment, or operators
who customize the script silently lose sender / attachment metadata.

The Channel (Claude Code) plugin's poll URL is in molecule-mcp-claude-
channel/server.ts pollWorkspace (not in this Go file) and is handled in
PR#21 on that repo with the same opt-in semantics.

Tests: 6 pass (3 existing + 3 added across the two commits on this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:24:45 -07:00
plugin-dev 494a1d8f9a docs(onboarding): fix Claude Code channel setup snippet served by canvas
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 9s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 5s
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m0s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m5s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m48s
CI / all-required (pull_request) Failing after 7m58s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 2m52s
The canvas-served Claude Code setup snippet had three real flaws that
new operators hit on first install:

1. **Wrong launch flag form.** The snippet told users to run
       claude --dangerously-load-development-channels \
         --channels plugin:molecule@molecule-channel
   but `--channels` is not a real Claude Code flag. On 2.1.143+ this
   errors with `entries must be tagged: --channels`; on older builds
   it silently no-ops. The correct form is the channel spec as the
   VALUE of --dangerously-load-development-channels:
       claude --dangerously-load-development-channels plugin:molecule@molecule-channel

2. **Legacy .env shape as the primary example.** The snippet used
   MOLECULE_PLATFORM_URL + comma-separated MOLECULE_WORKSPACE_IDS +
   MOLECULE_WORKSPACE_TOKENS — the pre-PR#15 single-tenant shape.
   The canonical SSOT (post-PR#15 in molecule-mcp-claude-channel) is
   MOLECULE_WORKSPACES_JSON, a JSON array of {id, token, platform_url}
   objects. Multi-platform onboarding (watch hongming + agents-team
   from one plugin instance) requires the JSON shape; the legacy form
   silently fails for that case.

3. **"claude.ai admin settings" misleading.** The allowlist is the
   on-disk managed-settings.json file (per OS), not a web setting.
   New operators searched the claude.ai web UI for a toggle that
   doesn't exist. Replaced with explicit per-OS paths and the
   `sudo tee` recipe (matches the channel plugin's README §65-118).

Promotes multi-workspace from a buried side-note to the canonical
example. Adds a "_as_workspace required when watching >1 workspace"
callout that operators were hitting as -32603 errors on tool calls.
Updates the expected stderr line to the post-PR#15 format
("watching N workspace(s) across M platform(s)") so operators can
verify their setup matched the new wire shape.

Empirical: hit by a session walking through this snippet 2026-05-21.
The two-flag form errored, the user copy-pasted from this exact
template, and the only way through was for me to identify the README
disagreement after the fact.

Adds two regression tests:
- TestExternalChannelTemplate_LaunchFlagShape pins the single-flag
  form + bans the broken two-flag form
- TestExternalChannelTemplate_CanonicalEnvShape pins the
  MOLECULE_WORKSPACES_JSON canonical shape with {{WORKSPACE_ID}} +
  {{PLATFORM_URL}} placeholders

Tests: 5 pass (3 existing + 2 new).

The hermes channel template (line 432+) has the same legacy-shape
pattern in its export commands but uses a different runtime config
mechanism (~/.hermes/config.yaml) — separate PR if we want hermes
parity. Not touched here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:13 -07:00
2 changed files with 167 additions and 37 deletions
@@ -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=<paste auth_token from create response>
MOLECULE_WORKSPACES_JSON=[{"id":"{{WORKSPACE_ID}}","token":"<paste auth_token from create response>","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: <platform_url>: <workspace_id>
#
# 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
# <channel ...> 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=<id> so the plugin
# knows which token to authenticate with. The host returns -32603 if you
# forget — the synthetic <channel> 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)" \
@@ -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)
}
}
}