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
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ The orchestration MCP is a **second tool surface on the agent-bridge architectur
sessions. No tool ever names an unrelated session.
3. Bootstrap capability injection happens at PTY launch exactly as the browser bridge does
today (one-use, rotated to session-scoped, revoked at exit), but only for sessions whose
metadata role is `orchestrator`.
metadata role is `orchestrator`. A new CLI thread may start another MCP helper
with the same PTY environment: bootstrap authentication can join the same
launch while it has a healthy authenticated connection, even after the initial
bootstrap TTL. It cannot revive a disconnected, expired or revoked launch.
Helpers share a session-scoped reconnect capability so a new join does not
invalidate an earlier helper's reconnect path. PTY cleanup revokes them all.
4. Per-provider MCP config injection follows `ProviderLaunch.ts`'s existing adapters
(CLI args for Claude/Codex/Qwen, inline config for OpenCode, owned temp entries for
Kimi/Hermes), gated on the same role.
Expand Down
13 changes: 13 additions & 0 deletions docs/agent-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ The default private controller file is next to the connection descriptor. Use a
- `status` returns the native session state plus the controller-submitted turn, if any.
- Before its first prompt a ready provider can still report `unavailable`; inspect `screen`. Startup/authentication/trust menus require resolution before tasks. Task delivery never doubles as menu confirmation.
- `send` accepts at most 16,000 characters of task text. It rejects slash commands, terminal control sequences and input while a submitted turn is active. It does not offer automatic permission approval.
- Automated Codex prompts wait for its empty task composer in the current launch, then use explicit bracketed paste followed by Enter. Startup menus are not task input; cancellation, timeout or restart drops the pending text. A screen from a previous launch cannot establish readiness, and paste-only sends do not submit the draft.
- An accepted send means text was written to the PTY. It is not a completed task.
- A result becomes fresh only after a corresponding working-to-idle lifecycle transition. Provider turn IDs, when present, reject stale completion signals.
- `resultRevision` counts completed observations. A previous completed answer is kept separate from the active turn. `result --after N` returns `fresh: false` until a newer result exists.
Expand Down Expand Up @@ -153,3 +154,15 @@ node scripts/smoke-agent-control.mjs --live --cwd /already/trusted/test-director
The opt-in live smoke uses the actual CLI client, native PTY, installed Codex and authenticated lifecycle/result hooks. It asks Codex to create and edit one uniquely named proof file, independently verifies its bytes, and retains evidence. It never launches or controls the user's desktop window. It uses the existing Codex authentication and consumes provider usage. Platform claims require a run on that platform; Unix unit tests do not prove Windows behavior.

The smoke prints its session ID and private connection/client paths at startup. If a startup menu blocks it, inspect that session with the CLI using those paths and resolve only reviewed, authorized choices. The smoke waits up to two minutes for a ready composer; it does not silently trust hooks or approve permissions.


Codex receives the enabled `canvastty_agents` tools directly through its supported
per-server `omit_tools_from = ["deferred"]` setting. Coordination does not depend
on ranked `tool_search` results. The launch allowlist, role checks, profile ceilings
and project boundary are unchanged; browser tools retain their existing exposure.

Verify the Codex exposure contract without credentials or real providers with
`npm run smoke:codex-mcp-discovery -- /absolute/path/to/codex`
(pass the native `codex.exe` on Windows). This uses temporary profiles, a synthetic
model and loopback MCP/Responses servers. It checks both the deferred negative
control and direct exposure of the exact enabled coordination catalog.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"smoke:browser:providers": "node scripts/smoke-browser-providers.mjs --deterministic",
"smoke:browser:provider-helper": "node scripts/smoke-browser-providers.mjs --direct",
"smoke:browser:providers:live": "node scripts/smoke-browser-providers.mjs --all",
"smoke:codex-mcp-discovery": "node scripts/smoke-codex-mcp-discovery.mjs",
"smoke:cli-resolution:mac": "node scripts/smoke-packaged-cli-resolution.mjs",
"smoke:appimage": "node scripts/smoke-appimage.mjs",
"preview": "electron-vite preview",
Expand Down
130 changes: 130 additions & 0 deletions scripts/smoke-codex-mcp-discovery.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/** Real Codex with temporary profiles and synthetic loopback MCP/Responses only; no account or model API. */
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { codexMcpArgs } from "../src/main/services/agent-browser/ProviderLaunch.ts";
import { ORCHESTRATION_TOOL_DEFINITIONS, ORCHESTRATION_TOOL_NAMES } from "../src/agent-browser/orchestration-catalog.mjs";

const binary = process.argv[2];
if (!binary || !isAbsolute(binary)) throw new Error("Pass the absolute native Codex executable path (codex.exe on Windows).");
const root = await mkdtemp(join(tmpdir(), "canvas-mcp-exposure-"));
await mkdir(join(root, "work"));
const helper = join(root, "mcp.mjs");
await writeFile(helper, `
import readline from "node:readline";
const tools = ${JSON.stringify(ORCHESTRATION_TOOL_DEFINITIONS)};
for await (const line of readline.createInterface({ input: process.stdin })) {
const request = JSON.parse(line);
if (request.id === undefined) continue;
const result = request.method === "initialize"
? { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
: request.method === "tools/list" ? { tools } : {};
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n");
}
`);

let requests = [];
const server = createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk;
requests.push(JSON.parse(raw));
const item = {
id: "msg_fixture", type: "message", role: "assistant", status: "completed",
content: [{ type: "output_text", text: "FIXTURE_OK", annotations: [] }]
};
const response = {
id: "resp_fixture", object: "response", created_at: 1, status: "completed", model: "fixture-model",
output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
};
const events = [
{ type: "response.created", response: { ...response, status: "in_progress", output: [] } },
{ type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } },
{ type: "response.content_part.added", output_index: 0, content_index: 0, item_id: item.id,
part: { type: "output_text", text: "", annotations: [] } },
{ type: "response.output_text.delta", output_index: 0, content_index: 0, item_id: item.id, delta: "FIXTURE_OK" },
{ type: "response.output_text.done", output_index: 0, content_index: 0, item_id: item.id, text: "FIXTURE_OK" },
{ type: "response.output_item.done", output_index: 0, item },
{ type: "response.completed", response }
];
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.end(events.map((event, index) =>
`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number: index })}\n\n`).join(""));
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));

try {
const model = {
slug: "fixture-model", display_name: "Fixture", description: "Synthetic MCP exposure fixture",
default_reasoning_level: "low", supported_reasoning_levels: [{ effort: "low", description: "Fixture" }],
shell_type: "shell_command", visibility: "list", supported_in_api: true, priority: 0,
base_instructions: "Use the provided test tools only when requested.", supports_reasoning_summaries: false,
support_verbosity: false, truncation_policy: { mode: "tokens", limit: 10000 }, input_modalities: ["text"],
supports_search_tool: true, supports_parallel_tool_calls: true, experimental_supported_tools: []
};
const results = [];
for (const { direct, allowed } of [
{ direct: false, allowed: ORCHESTRATION_TOOL_NAMES },
{ direct: true, allowed: ORCHESTRATION_TOOL_NAMES },
{ direct: true, allowed: ["list_agents"] },
{ direct: true, allowed: [] }
]) {
const home = join(root, `case-${results.length}`);
await mkdir(home, { mode: 0o700 });
await writeFile(join(home, "models.json"), JSON.stringify({ models: [model] }));
await writeFile(join(home, "config.toml"), `model="fixture-model"
model_provider="fixture"
model_catalog_json=${JSON.stringify(join(home, "models.json"))}
[model_providers.fixture]
name="fixture"
base_url="http://127.0.0.1:${server.address().port}/v1"
wire_api="responses"
requires_openai_auth=false
env_key="FIXTURE_KEY"
`);
const args = codexMcpArgs(null, { command: process.execPath, args: [helper] }, allowed);
// Negative control: remove only the exposure override from the actual launch table.
if (!direct) args[1] = args[1].replace('omit_tools_from=["deferred"],', "");
requests = [];
const environment = Object.fromEntries(Object.entries(process.env)
.filter(([key]) => ["PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP", "TMPDIR"].includes(key.toUpperCase()))
.map(([key, value]) => [key.toUpperCase(), value]));
Object.assign(environment, {
HOME: home, USERPROFILE: home, APPDATA: home, LOCALAPPDATA: home,
CODEX_HOME: home, CODEX_SQLITE_HOME: home, FIXTURE_KEY: "fixture"
});
const child = spawn(binary, [
"exec", "--ephemeral", "--json", "--skip-git-repo-check", "--sandbox", "read-only",
"-C", join(root, "work"), ...args, "Reply FIXTURE_OK."
], { env: environment, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "", stderr = "";
child.stdout.on("data", chunk => { stdout = (stdout + chunk).slice(-16384); });
child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-16384); });
const timer = setTimeout(() => child.kill("SIGKILL"), 45000);
let code;
try {
code = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
} finally {
clearTimeout(timer);
}
if (code !== 0) throw new Error("Fixture client failed: " + stderr.slice(-1200));
assert.match(stdout, /FIXTURE_OK/);
const tools = (requests[0]?.tools ?? []).flatMap(tool => tool.type === "namespace"
? tool.tools.map(entry => ({ namespace: tool.name, ...entry })) : tool);
const exposed = tools.filter(tool => tool.namespace === "mcp__canvastty_agents"
|| tool.name?.includes("canvastty_agents")).map(tool => tool.name);
assert.deepEqual(exposed.sort(), direct ? [...allowed].sort() : []);
results.push({ direct, allowed, clientExitCode: code, requestCount: requests.length,
orchestrationTools: exposed, searchAdvertised: tools.some(tool => tool.type === "tool_search") });
}
console.log(JSON.stringify({ ok: true, liveProviderProof: false, cases: results }));
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
await rm(root, { recursive: true, force: true, maxRetries: 3 });
}
10 changes: 7 additions & 3 deletions src/main/services/AgentControlService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { codexPromptInput } from "./codexInput.ts";
import type {
AgentProviderId,
CreateSessionRequest,
Expand Down Expand Up @@ -191,7 +192,9 @@ export class AgentControlService {
...(request.effort !== undefined ? { effort: request.effort } : {})
}, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" });
if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created);
return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal)
const input = request.provider === "codex"
? codexPromptInput(request.initialPrompt) : `${request.initialPrompt}\r`;
return this.deliver(created.id, input, "prompt", signal)
.then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => {
// Cancelled while it started: nobody receives its id, so the card is closed instead of left running.
if (!signal?.aborted) throw error;
Expand All @@ -213,7 +216,8 @@ export class AgentControlService {
if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`);
if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required.");
if (session.exitCode !== null) throw new Error("Agent session has already exited.");
return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal);
const input = session.provider === "codex" ? codexPromptInput(text, submit) : submit ? `${text}\r` : text;
return this.deliver(sessionId, input, "text", signal);
}

status(sessionId: string): SessionMetadata {
Expand Down Expand Up @@ -382,7 +386,7 @@ export class AgentControlService {

/** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */
private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise<void> {
const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal);
const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal, this.requireSession(sessionId).provider === "codex");
if (!delivery.delivered) {
throw new PromptNotDeliveredError(sessionId, `The ${what} for agent ${sessionId} was not delivered: ${delivery.reason}`);
}
Expand Down
7 changes: 5 additions & 2 deletions src/main/services/PluginSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
TerminalDataEvent
} from "../../shared/contracts.ts";
import { IPC } from "../../shared/contracts.ts";
import { codexPromptInput } from "./codexInput.ts";
import type { PersistedEnvironmentRef } from "./TerminalSessionStore.ts";

/** A card as plugin services see it (EP-4): metadata and where it runs, never screen text. */
Expand Down Expand Up @@ -57,7 +58,7 @@ interface TerminalPort {
} | null;
setPluginOwner(id: string, pluginId: string): void;
readBuffer(id: string): TerminalBufferSnapshot;
deliverInput(id: string, text: string): Promise<{ delivered: boolean }>;
deliverInput(id: string, text: string, waitMs?: number, signal?: AbortSignal, waitForCodexComposer?: boolean): Promise<{ delivered: boolean }>;
dispose(id: string, options?: { keepEnvironmentData?: boolean }): void;
redactSecrets(text: string): string;
redactSecretsTail(text: string, maxChars: number): string;
Expand Down Expand Up @@ -252,7 +253,9 @@ export class PluginSessions {
throw new Error(`text must be 1 to ${MAX_SEND_CHARS} characters.`);
}
const submit = values.submit === undefined ? true : values.submit === true;
return this.deps.terminals.deliverInput(sessionId, submit ? `${values.text}\r` : values.text)
const codex = this.deps.terminals.pluginContext(sessionId)?.metadata.provider === "codex";
const data = codex ? codexPromptInput(values.text, submit) : submit ? `${values.text}\r` : values.text;
return this.deps.terminals.deliverInput(sessionId, data, undefined, undefined, codex)
.then((delivery) => ({ sessionId, sent: delivery.delivered }));
}

Expand Down
Loading
Loading