From 126449d4b9d237685f28e0d8d36cd56be26e4a12 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:17:42 +0000 Subject: [PATCH] fix(orchestration): let the canvastty_agents helper authenticate OrchestrationGateway issues every orchestrator capability for one connection id and refuses an authenticate message with any other. The helper did not receive that id and made up its own (`helper-`), so the gateway refused every real helper: an orchestrator card listed canvastty_agents but each tool call answered "CanvasTTY orchestration bridge is unavailable". OrchestrationBridge now passes the capability's connection id as CANVASTTY_ORCHESTRATION_CONNECTION_ID, the helper requires it like the address and token, and every provider's MCP config forwards it (Codex env_vars; OpenCode and Hermes read all ORCHESTRATION_ENV names). tests/orchestration-helper-identity.test.mjs starts the real helper with the environment the bridge gives a card and calls list_agents through the real gateway; it fails before this change and passes after it. --- src/agent-browser/orchestration-helper.mjs | 7 +- .../agent-browser/OrchestrationBridge.ts | 3 +- .../services/agent-browser/ProviderLaunch.ts | 2 +- .../agent-browser/orchestration-protocol.ts | 4 +- tests/orchestration-helper-identity.test.mjs | 75 +++++++++++++++++++ tests/orchestration-launch-extra.test.mjs | 6 +- tests/orchestration-launch-role.test.mjs | 2 + 7 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/orchestration-helper-identity.test.mjs diff --git a/src/agent-browser/orchestration-helper.mjs b/src/agent-browser/orchestration-helper.mjs index 08321d7c..5c84a786 100644 --- a/src/agent-browser/orchestration-helper.mjs +++ b/src/agent-browser/orchestration-helper.mjs @@ -17,7 +17,8 @@ const DEFAULT_MCP_PROTOCOL_VERSION = "2025-06-18"; const ENV = { address: "CANVASTTY_ORCHESTRATION_ADDRESS", capabilityToken: "CANVASTTY_ORCHESTRATION_CAPABILITY", - terminalSessionId: "CANVASTTY_TERMINAL_SESSION_ID" + terminalSessionId: "CANVASTTY_TERMINAL_SESSION_ID", + connectionId: "CANVASTTY_ORCHESTRATION_CONNECTION_ID" }; export const ORCHESTRATION_AGENT_INSTRUCTIONS = [ @@ -257,7 +258,9 @@ function readIdentity() { const address = requiredEnvironment(ENV.address); const capabilityToken = requiredEnvironment(ENV.capabilityToken); const terminalSessionId = requiredEnvironment(ENV.terminalSessionId); - return { address, capabilityToken, terminalSessionId, connectionId: `helper-${randomUUID()}` }; + // The connection id the capability was issued for: the gateway refuses any other. + const connectionId = requiredEnvironment(ENV.connectionId); + return { address, capabilityToken, terminalSessionId, connectionId }; } function requiredEnvironment(key) { diff --git a/src/main/services/agent-browser/OrchestrationBridge.ts b/src/main/services/agent-browser/OrchestrationBridge.ts index 667dcde7..1882a842 100644 --- a/src/main/services/agent-browser/OrchestrationBridge.ts +++ b/src/main/services/agent-browser/OrchestrationBridge.ts @@ -36,7 +36,8 @@ export class OrchestrationBridge implements OrchestrationLaunchCoordinator { environment: { [ORCHESTRATION_ENV.address]: capability.address, [ORCHESTRATION_ENV.capabilityToken]: capability.capabilityToken, - [ORCHESTRATION_ENV.terminalSessionId]: capability.terminalSessionId + [ORCHESTRATION_ENV.terminalSessionId]: capability.terminalSessionId, + [ORCHESTRATION_ENV.connectionId]: capability.connectionId }, cleanup: () => { if (cleaned) return; diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index 647a05f5..dd5d5cdc 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -309,7 +309,7 @@ export function codexMcpArgs(helper: StdioHelperLaunch, orchestrationHelper?: St `command=${tomlString(orchestrationHelper.command)}`, `args=${tomlStringArray(orchestrationHelper.args)}`, `env=${tomlStringTable(orchestrationHelper.env ?? {})}`, - `env_vars=${tomlStringArray(["CANVASTTY_ORCHESTRATION_ADDRESS", "CANVASTTY_ORCHESTRATION_CAPABILITY", "CANVASTTY_TERMINAL_SESSION_ID"])}`, + `env_vars=${tomlStringArray(["CANVASTTY_ORCHESTRATION_ADDRESS", "CANVASTTY_ORCHESTRATION_CAPABILITY", "CANVASTTY_TERMINAL_SESSION_ID", "CANVASTTY_ORCHESTRATION_CONNECTION_ID"])}`, "enabled=true", "required=false", 'default_tools_approval_mode="approve"', diff --git a/src/main/services/agent-browser/orchestration-protocol.ts b/src/main/services/agent-browser/orchestration-protocol.ts index c4bb1fed..2940d9ce 100644 --- a/src/main/services/agent-browser/orchestration-protocol.ts +++ b/src/main/services/agent-browser/orchestration-protocol.ts @@ -16,7 +16,9 @@ export const MAX_INFLIGHT_ORCHESTRATION_COMMANDS = 4; export const ORCHESTRATION_ENV = Object.freeze({ address: "CANVASTTY_ORCHESTRATION_ADDRESS", capabilityToken: "CANVASTTY_ORCHESTRATION_CAPABILITY", - terminalSessionId: "CANVASTTY_TERMINAL_SESSION_ID" + terminalSessionId: "CANVASTTY_TERMINAL_SESSION_ID", + // The gateway checks it on authenticate, so the helper must get the one the capability was issued for. + connectionId: "CANVASTTY_ORCHESTRATION_CONNECTION_ID" }); export type OrchestrationToolName = diff --git a/tests/orchestration-helper-identity.test.mjs b/tests/orchestration-helper-identity.test.mjs new file mode 100644 index 00000000..eb6f0aed --- /dev/null +++ b/tests/orchestration-helper-identity.test.mjs @@ -0,0 +1,75 @@ +/** + * The real canvastty_agents helper must authenticate with the environment an orchestrator card gets. The gateway + * issues each capability for one connection id and refuses any other, so the helper has to use that id instead of + * making one up. + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { OrchestrationBridge } from "../src/main/services/agent-browser/OrchestrationBridge.ts"; +import { OrchestrationGateway } from "../src/main/services/agent-browser/OrchestrationGateway.ts"; + +const HELPER = fileURLToPath(new URL("../src/agent-browser/orchestration-helper.mjs", import.meta.url)); + +function startHelper(environment) { + const child = spawn(process.execPath, [HELPER], { + env: { PATH: process.env.PATH, ...environment }, + stdio: ["pipe", "pipe", "pipe"] + }); + const waiting = new Map(); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let index; + while ((index = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (!line) continue; + const message = JSON.parse(line); + waiting.get(message.id)?.(message); + waiting.delete(message.id); + } + }); + const request = (id, method, params) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`No answer to ${method}.`)), 5_000); + waiting.set(id, (message) => { clearTimeout(timer); resolve(message); }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) })}\n`); + }); + return { child, request }; +} + +test("the orchestration helper authenticates with the card's own capability and reaches the tools", async (t) => { + const runtimeDirectory = await mkdtemp(join(tmpdir(), "canvastty-orch-helper-")); + t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); + const calls = []; + const gateway = new OrchestrationGateway({ + runtimeDirectory: join(runtimeDirectory, "runtime"), + handler: { + async execute(sessionId, request) { + calls.push({ sessionId, tool: request.tool }); + return { agents: [] }; + } + } + }); + await gateway.start(); + t.after(() => gateway.stop()); + + const launch = new OrchestrationBridge(gateway).prepareLaunch({ terminalSessionId: "orchestrator-1" }); + assert.ok(launch, "the bridge is enabled"); + t.after(() => launch.cleanup()); + const { child, request } = startHelper(launch.environment); + t.after(() => child.kill()); + + const initialized = await request(1, "initialize", {}); + assert.equal(initialized.error, undefined, `initialize failed: ${JSON.stringify(initialized.error)}`); + assert.equal(initialized.result.serverInfo.name, "canvastty_agents"); + + const listed = await request(2, "tools/call", { name: "list_agents", arguments: {} }); + assert.equal(listed.result.isError, false, listed.result.content?.[0]?.text); + assert.deepEqual(JSON.parse(listed.result.content[0].text), { agents: [] }); + assert.deepEqual(calls, [{ sessionId: "orchestrator-1", tool: "list_agents" }]); +}); diff --git a/tests/orchestration-launch-extra.test.mjs b/tests/orchestration-launch-extra.test.mjs index 19b921ae..80d478a5 100644 --- a/tests/orchestration-launch-extra.test.mjs +++ b/tests/orchestration-launch-extra.test.mjs @@ -36,7 +36,8 @@ const orchestrationHelper = Object.freeze({ const OPENCODE_ORCHESTRATION_ENV_NAMES = [ "CANVASTTY_ORCHESTRATION_ADDRESS", "CANVASTTY_ORCHESTRATION_CAPABILITY", - "CANVASTTY_TERMINAL_SESSION_ID" + "CANVASTTY_TERMINAL_SESSION_ID", + "CANVASTTY_ORCHESTRATION_CONNECTION_ID" ]; const providerClis = Object.freeze({ @@ -124,7 +125,8 @@ test("OpenCode gains canvastty_agents only when orchestration is requested", asy ELECTRON_RUN_AS_NODE: "1", CANVASTTY_ORCHESTRATION_ADDRESS: "{env:CANVASTTY_ORCHESTRATION_ADDRESS}", CANVASTTY_ORCHESTRATION_CAPABILITY: "{env:CANVASTTY_ORCHESTRATION_CAPABILITY}", - CANVASTTY_TERMINAL_SESSION_ID: "{env:CANVASTTY_TERMINAL_SESSION_ID}" + CANVASTTY_TERMINAL_SESSION_ID: "{env:CANVASTTY_TERMINAL_SESSION_ID}", + CANVASTTY_ORCHESTRATION_CONNECTION_ID: "{env:CANVASTTY_ORCHESTRATION_CONNECTION_ID}" } }); for (const name of OPENCODE_ORCHESTRATION_ENV_NAMES) { diff --git a/tests/orchestration-launch-role.test.mjs b/tests/orchestration-launch-role.test.mjs index 8e4ac242..12fe14ca 100644 --- a/tests/orchestration-launch-role.test.mjs +++ b/tests/orchestration-launch-role.test.mjs @@ -83,6 +83,8 @@ test("only orchestrator sessions receive the orchestration capability environmen assert.ok(orchestratorEnv, "orchestrator session spawned"); assert.ok(orchestratorEnv.CANVASTTY_ORCHESTRATION_ADDRESS); assert.ok(orchestratorEnv.CANVASTTY_ORCHESTRATION_CAPABILITY); + // The helper authenticates with the connection id the capability was issued for. + assert.ok(orchestratorEnv.CANVASTTY_ORCHESTRATION_CONNECTION_ID); const interactiveEnv = calls[1]?.options?.env; assert.ok(interactiveEnv, "interactive session spawned");