Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/agent-browser/orchestration-helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/services/agent-browser/OrchestrationBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/agent-browser/ProviderLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
4 changes: 3 additions & 1 deletion src/main/services/agent-browser/orchestration-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
75 changes: 75 additions & 0 deletions tests/orchestration-helper-identity.test.mjs
Original file line number Diff line number Diff line change
@@ -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" }]);
});
6 changes: 4 additions & 2 deletions tests/orchestration-launch-extra.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions tests/orchestration-launch-role.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading