toolroll 0.9.61 → 0.9.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +31 -11
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. package/scripts/provider-canary.mjs +210 -31
@@ -0,0 +1,14 @@
1
+ import { type ExecResult } from "./exec.js";
2
+ import { type Runner } from "./worktree.js";
3
+ export type Git = (cwd: string, args: readonly string[]) => Promise<ExecResult>;
4
+ /** Fixed on every teardown command, ahead of its own arguments. */
5
+ export declare const TEARDOWN_FLAGS: readonly string[];
6
+ /** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
7
+ export declare const REFUSED_CODE = 128;
8
+ /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
9
+ * scope count: teardown commands never read system or global config. */
10
+ export declare function commandSetting(listing: string): string | null;
11
+ /** Why a teardown command was refused, or null when `result` isn't a refusal. */
12
+ export declare function refusal(result: ExecResult): string | null;
13
+ /** The one way teardown runs git, including the target checkout immediately before removal. */
14
+ export declare function teardownGit(runner?: Runner): Git;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Git for teardown (landed-cleanup.ts and the checkout removal it drives in worktree.ts), run by Toolroll's own
3
+ * process inside the shared repository a build can write to. A build can plant hooks in .git/hooks and settings in
4
+ * .git/config (or a worktree's config.worktree), so every teardown command runs hardened: an allowlisted environment
5
+ * with no system or global config (remote-main.ts's), no hooks, no fsmonitor, no ext transport, no prompts and no
6
+ * automatic maintenance.
7
+ *
8
+ * Settings that name a command, and includes (which could bring them in from a file nobody looked at), are not
9
+ * overridden but refused: before each command the repository's own config is read with includes off, and if it sets
10
+ * any of them the command never runs. The refusal is a failed git result, so every caller's existing doubt applies and
11
+ * nothing is removed.
12
+ */
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { run } from "./exec.js";
16
+ import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
17
+ import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
18
+ /** Fixed on every teardown command, ahead of its own arguments. */
19
+ export const TEARDOWN_FLAGS = [
20
+ "--no-optional-locks", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never",
21
+ "-c", "credential.helper=", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "submodule.recurse=false",
22
+ ];
23
+ /** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
24
+ export const REFUSED_CODE = 128;
25
+ const REFUSED_PREFIX = "Toolroll won't run git here: ";
26
+ /** Config keys that run a command, bring in unread config, or redirect inspection away from the checked path.
27
+ * `*` is one subsection, any name. */
28
+ const COMMAND_KEYS = [
29
+ "core.sshcommand", "core.gitproxy", "core.askpass", "core.pager", "core.editor", "core.alternaterefscommand",
30
+ "core.worktree", "gpg.ssh.defaultkeycommand", "trailer.*.command", "trailer.*.cmd", "submodule.*.update", "sequence.editor", "diff.external", "credential.helper", "credential.*.helper", "filter.*.clean", "filter.*.smudge", "filter.*.process",
31
+ "diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
32
+ "remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
33
+ ].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
34
+ /** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
35
+ const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
36
+ /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
37
+ * scope count: teardown commands never read system or global config. */
38
+ export function commandSetting(listing) {
39
+ const fields = listing.split("\0");
40
+ for (let at = 0; at + 1 < fields.length; at += 2) {
41
+ const scope = fields[at];
42
+ const entry = fields[at + 1];
43
+ if (scope !== "local" && scope !== "worktree")
44
+ continue;
45
+ const newline = entry.indexOf("\n");
46
+ const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
47
+ const value = newline < 0 ? "" : entry.slice(newline + 1);
48
+ if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)))
49
+ return key;
50
+ }
51
+ return null;
52
+ }
53
+ /** Why a teardown command was refused, or null when `result` isn't a refusal. */
54
+ export function refusal(result) {
55
+ return result.code === REFUSED_CODE && result.stderr.startsWith(REFUSED_PREFIX) ? result.stderr.slice(REFUSED_PREFIX.length).trim() : null;
56
+ }
57
+ const refused = (why) => ({ code: REFUSED_CODE, stdout: "", stderr: `${REFUSED_PREFIX}${why}\n`, timedOut: false, notFound: false });
58
+ /** Refuse nested repositories without asking Git to enter them or evaluate their config.
59
+ * Includes ignored/untracked repositories and bare repositories; never follows symlinks. */
60
+ function nestedRepository(path) {
61
+ const pending = [path];
62
+ while (pending.length > 0) {
63
+ const dir = pending.pop();
64
+ const entries = readdirSync(dir, { withFileTypes: true });
65
+ if (dir !== path && (entries.some(entry => entry.name === ".git") ||
66
+ ["HEAD", "objects", "config"].every(name => entries.some(entry => entry.name === name))))
67
+ return true;
68
+ for (const entry of entries)
69
+ if (entry.isDirectory() && entry.name !== ".git")
70
+ pending.push(join(dir, entry.name));
71
+ }
72
+ return false;
73
+ }
74
+ /** The one way teardown runs git, including the target checkout immediately before removal. */
75
+ export function teardownGit(runner = run) {
76
+ const options = (cwd) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
77
+ const configProblem = async (cwd) => {
78
+ const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd));
79
+ if (config.code !== 0)
80
+ return refused("its config couldn't be read");
81
+ const key = commandSetting(config.stdout);
82
+ return key === null ? null : refused(`the project's git config sets ${key}, which can run a command`);
83
+ };
84
+ return async (cwd, args) => {
85
+ const problem = await configProblem(cwd);
86
+ if (problem !== null)
87
+ return problem;
88
+ const removal = args[0] === "worktree" && args[1] === "remove";
89
+ const inspection = args.includes("status") || args.includes("diff");
90
+ const target = removal ? resolve(cwd, args.at(-1)) : cwd;
91
+ if (removal) {
92
+ const targetProblem = await configProblem(target);
93
+ if (targetProblem !== null)
94
+ return targetProblem;
95
+ }
96
+ if (removal || inspection || args.includes("checkout") || args.includes("clean")) {
97
+ try {
98
+ if (existsSync(target) && nestedRepository(target))
99
+ return refused("the checkout contains a nested repository");
100
+ }
101
+ catch {
102
+ return refused("the checkout couldn't be inspected for nested repositories");
103
+ }
104
+ // ls-files reads index entries only; it cannot run submodule filters. Even an uninitialized
105
+ // gitlink is kept, so worktree remove's internal status can never traverse it.
106
+ const index = await runner("git", [...TEARDOWN_FLAGS, "ls-files", "--stage", "-z"], options(target));
107
+ if (index.code !== 0)
108
+ return refused("the checkout's index couldn't be read");
109
+ if (index.stdout.split("\0").some(entry => entry.startsWith("160000 ")))
110
+ return refused("the checkout contains a gitlink");
111
+ }
112
+ const safeArgs = [...args];
113
+ if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
114
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
115
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
116
+ }
117
+ return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
118
+ };
119
+ }
@@ -0,0 +1,37 @@
1
+ import type { ToolSpec } from "./contracts/integration-metadata.js";
2
+ /** One tool a launch may use: its values (never leaving this process) and, for research, the only actions it offers. */
3
+ export type BrokeredTool = {
4
+ spec: ToolSpec;
5
+ values: Record<string, string>;
6
+ actions?: readonly string[];
7
+ };
8
+ export type BrokerOptions = {
9
+ /** Where a local server's environment comes from (the launch's): only SERVER_ENV's names are taken from it. */
10
+ env?: NodeJS.ProcessEnv;
11
+ omitEnv?: readonly string[];
12
+ /** Toolroll's own settings for a local server (the run's isolated database), passed as they are. */
13
+ pass?: Readonly<Record<string, string>>;
14
+ cwd?: string;
15
+ /** How a local server is started inside its run's fence, read when it starts (the fence is fixed after the tools). */
16
+ wrap?: () => ((file: string, args: readonly string[]) => {
17
+ file: string;
18
+ args: string[];
19
+ }) | null;
20
+ };
21
+ export type ToolBroker = {
22
+ /** The launch's token: the agent's only key to its tools. */
23
+ token: string;
24
+ url: (name: string) => string;
25
+ /** The local servers running now (the fence tests read them). */
26
+ pids: () => number[];
27
+ /** Revoke the token, stop every server, close the port. */
28
+ close: () => void;
29
+ };
30
+ /** What a local server inherits from the launch: enough to run and find its tools, and nothing that could be
31
+ * another tool's or a provider's secret. Its own configured values are added after. */
32
+ export declare const SERVER_ENV: readonly string[];
33
+ /** A local server's environment: the allowlisted names from `base` (and its LC_* locale), less `omit`, then `pass`,
34
+ * then the tool's own values. */
35
+ export declare function serverEnv(base: NodeJS.ProcessEnv, omit: readonly string[], pass: Readonly<Record<string, string>>, values: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
36
+ /** Start one launch's broker on a loopback port. */
37
+ export declare function startToolBroker(tools: readonly BrokeredTool[], options?: BrokerOptions): Promise<ToolBroker>;
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The tool broker: where a build's project tools keep their secrets.
3
+ *
4
+ * Anything a fenced agent can read, its shell can read too, so no tool secret
5
+ * may sit in its MCP config, its environment, an inherited descriptor or a
6
+ * process it can inspect. The broker runs in Toolroll's own process, outside
7
+ * the fence, for one launch: it holds each tool's resolved values in memory,
8
+ * starts a local server itself (with the values as that server's environment,
9
+ * inside the run's fence when it has one) or relays to a web server (adding
10
+ * the sign-in headers), and serves each tool as streamable HTTP on a loopback
11
+ * port. The agent's config names only that address and a token minted for
12
+ * this launch: valid for exactly its opted-in tools (a research step's only
13
+ * for their read-only actions), and revoked when the launch ends. A resumed
14
+ * build is a new launch with its own broker and token.
15
+ *
16
+ * What it relays is plain JSON-RPC: a request gets its answer as JSON, a
17
+ * notification is accepted, and a server's own requests (sampling, roots) are
18
+ * answered "not supported" — a build has nobody to ask.
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { randomBytes, timingSafeEqual } from "node:crypto";
22
+ import { createServer } from "node:http";
23
+ const BODY_LIMIT = 4 * 1024 * 1024;
24
+ const ANSWER_LIMIT = 32 * 1024 * 1024;
25
+ const STOP_GRACE_MS = 2_000;
26
+ const rpcError = (id, message, code = -32000) => ({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
27
+ const isRequest = (message) => typeof message.method === "string" && message.id !== undefined && message.id !== null;
28
+ /** A research step's server lists and calls only its read-only actions. */
29
+ function limitList(message, actions) {
30
+ if (actions === undefined || message === null || typeof message !== "object" || message.result === null || typeof message.result !== "object")
31
+ return message;
32
+ const result = message.result;
33
+ return { ...message, result: { ...result,
34
+ ...(Array.isArray(result["tools"]) ? { tools: result["tools"].filter(one => one !== null && typeof one === "object" && actions.includes(String(one.name))) } : {}),
35
+ // The research grant names tool actions only; it grants no resources or prompts.
36
+ ...Object.fromEntries(["resources", "resourceTemplates", "prompts"].filter(key => Array.isArray(result[key])).map(key => [key, []])), } };
37
+ }
38
+ const RESEARCH_METHODS = new Set(["initialize", "ping", "notifications/initialized", "notifications/cancelled", "tools/list", "resources/list", "resources/templates/list", "prompts/list"]);
39
+ function refused(message, actions) {
40
+ if (actions === undefined || RESEARCH_METHODS.has(String(message.method)))
41
+ return null;
42
+ const name = message.params?.name;
43
+ if (message.method === "tools/call" && typeof name === "string" && actions.includes(name))
44
+ return null;
45
+ return rpcError(message.id, "This step may only read: that action is not available.", -32601);
46
+ }
47
+ /** What a local server inherits from the launch: enough to run and find its tools, and nothing that could be
48
+ * another tool's or a provider's secret. Its own configured values are added after. */
49
+ export const SERVER_ENV = ["PATH", "HOME", "USER", "LANG", "TMPDIR", "SHELL", "TERM", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
50
+ ...["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY"].flatMap(name => [name, name.toLowerCase()])];
51
+ /** A local server's environment: the allowlisted names from `base` (and its LC_* locale), less `omit`, then `pass`,
52
+ * then the tool's own values. */
53
+ export function serverEnv(base, omit, pass, values) {
54
+ const env = {};
55
+ for (const [name, value] of Object.entries(base))
56
+ if (value !== undefined && (SERVER_ENV.includes(name) || name.startsWith("LC_")))
57
+ env[name] = value;
58
+ for (const name of omit)
59
+ delete env[name];
60
+ return Object.assign(env, pass, values);
61
+ }
62
+ /** A local server, started on first use and spoken to over its stdin and stdout. */
63
+ class LocalServer {
64
+ tool;
65
+ options;
66
+ child = null;
67
+ initialized = false;
68
+ buffer = "";
69
+ next = 1;
70
+ pending = new Map();
71
+ constructor(tool, options) {
72
+ this.tool = tool;
73
+ this.options = options;
74
+ }
75
+ pid() { return this.child?.pid ?? null; }
76
+ start() {
77
+ const env = serverEnv(this.options.env ?? process.env, this.options.omitEnv ?? [], this.options.pass ?? {}, this.tool.values);
78
+ const wrap = this.options.wrap?.() ?? null;
79
+ const { file, args } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args] } : wrap(this.tool.spec.command, this.tool.spec.args);
80
+ const child = spawn(file, args, { env, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
81
+ this.child = child;
82
+ this.initialized = false;
83
+ child.stdout?.setEncoding("utf8");
84
+ child.stdout?.on("data", (chunk) => this.read(chunk));
85
+ const gone = (why) => {
86
+ if (this.child !== child)
87
+ return;
88
+ this.child = null;
89
+ for (const [, waiting] of this.pending)
90
+ waiting.done(rpcError(waiting.id, why));
91
+ this.pending.clear();
92
+ };
93
+ child.on("error", error => gone(`The tool could not start: ${error.message}.`));
94
+ child.on("exit", code => gone(`The tool stopped (exit ${code ?? "signal"}).`));
95
+ child.stdin?.on("error", () => undefined);
96
+ return child;
97
+ }
98
+ read(chunk) {
99
+ this.buffer += chunk;
100
+ for (let end = this.buffer.indexOf("\n"); end >= 0; end = this.buffer.indexOf("\n")) {
101
+ const line = this.buffer.slice(0, end).trim();
102
+ this.buffer = this.buffer.slice(end + 1);
103
+ let message;
104
+ try {
105
+ message = JSON.parse(line);
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ if (message === null || typeof message !== "object")
111
+ continue;
112
+ if (isRequest(message)) {
113
+ this.write(rpcError(message.id, "Not supported in an unattended build.", -32601));
114
+ continue;
115
+ }
116
+ const waiting = typeof message.id === "number" ? this.pending.get(message.id) : undefined;
117
+ if (waiting === undefined)
118
+ continue;
119
+ this.pending.delete(message.id);
120
+ waiting.done({ ...message, id: waiting.id });
121
+ }
122
+ }
123
+ write(message) {
124
+ try {
125
+ this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
126
+ }
127
+ catch { /* its exit answers whoever waits */ }
128
+ }
129
+ /** One message from the agent: a request's answer, or null for a notification. */
130
+ send(message) {
131
+ // A new session (a reconnect) starts a fresh server: a server answers initialize once.
132
+ if (message.method === "initialize" && this.initialized)
133
+ this.stop();
134
+ if (this.child === null)
135
+ this.start();
136
+ if (message.method === "initialize")
137
+ this.initialized = true;
138
+ if (!isRequest(message)) {
139
+ if (typeof message.method === "string")
140
+ this.write(message);
141
+ return Promise.resolve(null);
142
+ }
143
+ const id = this.next++;
144
+ return new Promise(resolve => {
145
+ this.pending.set(id, { id: message.id, done: resolve });
146
+ this.write({ ...message, id });
147
+ });
148
+ }
149
+ stop() {
150
+ const child = this.child;
151
+ if (child === null)
152
+ return;
153
+ this.child = null;
154
+ for (const [, waiting] of this.pending)
155
+ waiting.done(rpcError(waiting.id, "The tool was stopped."));
156
+ this.pending.clear();
157
+ const signal = (name) => {
158
+ try {
159
+ if (child.pid !== undefined && process.platform !== "win32")
160
+ process.kill(-child.pid, name);
161
+ else
162
+ child.kill(name);
163
+ }
164
+ catch { /* gone */ }
165
+ };
166
+ signal("SIGTERM");
167
+ if (child.exitCode === null && child.signalCode === null)
168
+ setTimeout(() => signal("SIGKILL"), STOP_GRACE_MS).unref();
169
+ }
170
+ }
171
+ /** A web server, relayed with its sign-in headers added here and nowhere else. */
172
+ async function relayWeb(tool, request, body) {
173
+ const spec = tool.spec;
174
+ const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
175
+ for (const name of ["mcp-session-id", "mcp-protocol-version", "last-event-id"]) {
176
+ const value = request.headers[name];
177
+ if (typeof value === "string")
178
+ headers[name] = value;
179
+ }
180
+ if (spec.bearer !== null && tool.values[spec.bearer])
181
+ headers["authorization"] = `Bearer ${tool.values[spec.bearer]}`;
182
+ for (const [header, secret] of Object.entries(spec.headerSecrets))
183
+ if (tool.values[secret])
184
+ headers[header] = tool.values[secret];
185
+ try {
186
+ // Never followed: a redirect would carry the sign-in headers to wherever it points.
187
+ const response = await fetch(spec.url, { method: request.method ?? "POST", headers, redirect: "error", ...(request.method === "POST" ? { body } : {}) });
188
+ const text = (await response.text()).slice(0, ANSWER_LIMIT);
189
+ return { status: response.status, type: response.headers.get("content-type") ?? "application/json", body: text, session: response.headers.get("mcp-session-id") };
190
+ }
191
+ catch {
192
+ let id = null;
193
+ try {
194
+ id = JSON.parse(body).id ?? null;
195
+ }
196
+ catch {
197
+ id = null;
198
+ }
199
+ return { status: 200, type: "application/json", body: JSON.stringify(rpcError(id, "The tool's server could not be reached.")) };
200
+ }
201
+ }
202
+ /** A relayed answer with a research step's list limited, JSON or event stream alike. An answer that isn't JSON (an
203
+ * accepted notification's empty 202) carries no list and passes as it is. */
204
+ function limitAnswer(answer, actions) {
205
+ if (actions === undefined)
206
+ return answer;
207
+ if (!answer.type.includes("text/event-stream") && jsonOf(answer.body) === undefined)
208
+ return answer;
209
+ const limit = (text) => {
210
+ try {
211
+ const parsed = JSON.parse(text);
212
+ return JSON.stringify(Array.isArray(parsed) ? parsed.map(one => limitList(one, actions)) : limitList(parsed, actions));
213
+ }
214
+ catch {
215
+ return JSON.stringify(rpcError(null, "The tool returned an invalid response."));
216
+ }
217
+ };
218
+ const body = answer.type.includes("text/event-stream")
219
+ ? answer.body.split(/\r?\n\r?\n/).map(event => {
220
+ const lines = event.split(/\r?\n/);
221
+ const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).replace(/^ /, ""));
222
+ if (data.length === 0)
223
+ return event;
224
+ return [...lines.filter(line => !line.startsWith("data:")), `data: ${limit(data.join("\n"))}`].join("\n");
225
+ }).join("\n\n")
226
+ : limit(answer.body);
227
+ return { ...answer, body };
228
+ }
229
+ /** A body's JSON, or undefined when it is empty or not JSON. */
230
+ function jsonOf(body) {
231
+ if (body.trim() === "")
232
+ return undefined;
233
+ try {
234
+ return JSON.parse(body);
235
+ }
236
+ catch {
237
+ return undefined;
238
+ }
239
+ }
240
+ /**
241
+ * A research batch's relayed answer with the broker's refusals added: always something the agent can read. An event
242
+ * stream gets them as further events; anything else becomes one JSON 200 holding the server's answers, when it gave
243
+ * some as JSON, and the refusals. An empty or non-JSON answer (an accepted notification's 202) is never passed on
244
+ * alone, which would drop the refusals; a request the server left unanswered gets an error instead.
245
+ */
246
+ function withRefusals(answer, allowed, refusals) {
247
+ if (answer.type.includes("text/event-stream")) {
248
+ return { ...answer, body: answer.body + refusals.map(one => `\nevent: message\ndata: ${JSON.stringify(one)}\n\n`).join("") };
249
+ }
250
+ const parsed = answer.status >= 200 && answer.status < 300 ? jsonOf(answer.body) : undefined;
251
+ const upstream = parsed === undefined ? [] : Array.isArray(parsed) ? parsed : [parsed];
252
+ const why = answer.status >= 200 && answer.status < 300 ? "The tool returned an invalid response." : `The tool's server answered HTTP ${answer.status}.`;
253
+ const unanswered = allowed.filter(one => isRequest(one) && !upstream.some(reply => reply !== null && typeof reply === "object" && reply.id === one.id)).map(one => rpcError(one.id, why));
254
+ return { ...answer, status: 200, type: "application/json", body: JSON.stringify([...upstream, ...unanswered, ...refusals]) };
255
+ }
256
+ function readBody(request) {
257
+ return new Promise(resolve => {
258
+ let size = 0;
259
+ const chunks = [];
260
+ request.on("data", (chunk) => {
261
+ size += chunk.length;
262
+ if (size > BODY_LIMIT) {
263
+ resolve(null);
264
+ request.destroy();
265
+ return;
266
+ }
267
+ chunks.push(chunk);
268
+ });
269
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
270
+ request.on("error", () => resolve(null));
271
+ });
272
+ }
273
+ /** Start one launch's broker on a loopback port. */
274
+ export async function startToolBroker(tools, options = {}) {
275
+ const token = randomBytes(32).toString("base64url");
276
+ const expected = Buffer.from(`Bearer ${token}`);
277
+ let revoked = false;
278
+ const byName = new Map(tools.map(tool => [tool.spec.name, tool]));
279
+ const local = new Map(tools.filter(tool => tool.spec.transport !== "http").map(tool => [tool.spec.name, new LocalServer(tool, options)]));
280
+ const authorized = (request) => {
281
+ const given = Buffer.from(String(request.headers["authorization"] ?? ""));
282
+ return !revoked && given.length === expected.length && timingSafeEqual(given, expected);
283
+ };
284
+ const reply = (response, answer) => {
285
+ response.writeHead(answer.status, { "content-type": answer.type, ...(answer.session == null ? {} : { "mcp-session-id": answer.session }) });
286
+ response.end(answer.body);
287
+ };
288
+ const json = (status, value) => ({ status, type: "application/json", body: JSON.stringify(value) });
289
+ const handle = async (request, response) => {
290
+ const name = /^\/mcp\/([a-z0-9][a-z0-9_-]*)$/.exec(request.url ?? "")?.[1];
291
+ const tool = name === undefined ? undefined : byName.get(name);
292
+ if (!authorized(request))
293
+ return reply(response, json(401, { error: "This launch's tools are closed to that key." }));
294
+ if (tool === undefined)
295
+ return reply(response, json(404, { error: "No such tool in this launch." }));
296
+ if (request.method === "GET")
297
+ return reply(response, { status: 405, type: "text/plain", body: "" });
298
+ const server = local.get(tool.spec.name);
299
+ if (request.method === "DELETE") {
300
+ if (server !== undefined) {
301
+ server.stop();
302
+ return reply(response, { status: 200, type: "text/plain", body: "" });
303
+ }
304
+ return reply(response, await relayWeb(tool, request, ""));
305
+ }
306
+ if (request.method !== "POST")
307
+ return reply(response, { status: 405, type: "text/plain", body: "" });
308
+ const body = await readBody(request);
309
+ if (body === null)
310
+ return reply(response, json(413, rpcError(null, "The message is too large.")));
311
+ let parsed;
312
+ try {
313
+ parsed = JSON.parse(body);
314
+ }
315
+ catch {
316
+ return reply(response, json(400, rpcError(null, "Parse error", -32700)));
317
+ }
318
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
319
+ if (messages.length === 0 || messages.some(one => one === null || typeof one !== "object" || Array.isArray(one) || typeof one.method !== "string"))
320
+ return reply(response, json(400, rpcError(null, "Invalid request", -32600)));
321
+ // A refused action is answered here and never reaches the tool.
322
+ const refusals = messages.map(one => refused(one, tool.actions));
323
+ const deniedAnswers = refusals.filter((one, index) => one !== null && isRequest(messages[index]));
324
+ if (refusals.every(one => one !== null))
325
+ return reply(response, deniedAnswers.length === 0
326
+ ? { status: 202, type: "text/plain", body: "" }
327
+ : json(200, Array.isArray(parsed) ? deniedAnswers : deniedAnswers[0]));
328
+ if (server === undefined) {
329
+ const allowed = messages.filter((_, index) => refusals[index] === null);
330
+ // Relay exactly what was parsed and authorized, never ambiguous raw JSON.
331
+ const answer = limitAnswer(await relayWeb(tool, request, JSON.stringify(Array.isArray(parsed) ? allowed : allowed[0])), tool.actions);
332
+ return reply(response, deniedAnswers.length === 0 ? answer : withRefusals(answer, allowed, deniedAnswers));
333
+ }
334
+ const answers = [];
335
+ for (const [index, message] of messages.entries()) {
336
+ const answer = refusals[index] ?? await server.send(message);
337
+ if (answer !== null && isRequest(message))
338
+ answers.push(limitList(answer, tool.actions));
339
+ }
340
+ if (answers.length === 0)
341
+ return reply(response, { status: 202, type: "text/plain", body: "" });
342
+ return reply(response, json(200, Array.isArray(parsed) ? answers : answers[0]));
343
+ };
344
+ const server = createServer((request, response) => {
345
+ handle(request, response).catch(() => {
346
+ if (!response.headersSent)
347
+ reply(response, json(500, rpcError(null, "The tool broker failed.")));
348
+ else
349
+ response.end();
350
+ });
351
+ });
352
+ await new Promise((resolve, reject) => {
353
+ server.once("error", reject);
354
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
355
+ });
356
+ const port = server.address().port;
357
+ return {
358
+ token,
359
+ url: name => `http://127.0.0.1:${port}/mcp/${name}`,
360
+ pids: () => [...local.values()].map(one => one.pid()).filter((pid) => pid !== null),
361
+ close: () => {
362
+ revoked = true;
363
+ for (const one of local.values())
364
+ one.stop();
365
+ server.close();
366
+ server.closeAllConnections();
367
+ },
368
+ };
369
+ }
@@ -2,10 +2,11 @@
2
2
  * The launcher a Codex build starts each project tool through (v80):
3
3
  * `node tool-launcher.js <config.json>`. Codex hands its own environment
4
4
  * to the agent's shell whatever `shell_environment_policy` says (checked
5
- * against codex-cli 0.156), so a tool's secrets never ride Codex's
6
- * environment: they sit in the run's private 0600 file, and this process
7
- * gives them to exactly one server — as its environment for a local
8
- * program, or as sign-in headers for a web server it relays to over stdio.
5
+ * against codex-cli 0.156), so nothing rides Codex's environment. A build's
6
+ * launch file names its run's tool broker (tool-broker.ts) and that launch's
7
+ * token, and this process relays stdio to it over HTTP; the tool's own
8
+ * secrets stay in the broker. A launch file naming a local program or a web
9
+ * server with its values is still run as before.
9
10
  */
10
11
  import { spawn } from "node:child_process";
11
12
  import { readFileSync } from "node:fs";