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");