toolroll 0.9.62 → 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 (100) hide show
  1. package/CHANGELOG.md +29 -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 +3 -0
  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 +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
@@ -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";
@@ -0,0 +1,170 @@
1
+ export type SecretStoreKind = "macos-keychain" | "secret-service" | "files";
2
+ /** One item's address: what the store lists, never a secret (a folder's or project's hash, a tool's name). */
3
+ export type SecretItem = {
4
+ service: string;
5
+ account: string;
6
+ };
7
+ export type SecretBackend = {
8
+ kind: Exclude<SecretStoreKind, "files">;
9
+ /** Values are [A-Za-z0-9._-] only, at most `limit` characters. */
10
+ limit: number;
11
+ get(item: SecretItem): string | null;
12
+ /** `create`: only when no item is there yet ("exists" when one is). */
13
+ set(item: SecretItem, value: string, options?: {
14
+ create?: boolean;
15
+ }): "ok" | "exists";
16
+ remove(item: SecretItem): void;
17
+ };
18
+ export declare class SecretStoreError extends Error {
19
+ }
20
+ /** The words Settings and `toolroll integrations` use for where secrets rest. */
21
+ export declare const STORE_WORDS: Readonly<Record<SecretStoreKind, string>>;
22
+ /** Said instead when a keyring is installed but doesn't answer, so files are all there is. */
23
+ export declare const UNANSWERED_WORDS = "Secrets are protected by owner-only files on this computer: its keyring isn't answering.";
24
+ /** The macOS Keychain, through `security -i` (`keychain`: a keychain file instead of the user's own, for tests). */
25
+ export declare function macosKeychain(options?: {
26
+ keychain?: string;
27
+ binary?: string;
28
+ }): SecretBackend;
29
+ /** The Secret Service (GNOME Keyring, KWallet), through `secret-tool` at `binary`, an absolute path; the value goes on its stdin. */
30
+ export declare function secretService(options: {
31
+ binary: string;
32
+ }): SecretBackend;
33
+ /**
34
+ * `path` when it is a program only the system (or, for a test's stand-in, one of `owners`) can have put there: a real
35
+ * executable file nobody else can change, in folders nobody else can either. Null otherwise.
36
+ */
37
+ export declare function trustedProgram(path: string, owners?: readonly number[]): string | null;
38
+ /** Where `secret-tool` may be: the system's own folders, never the PATH a process was given. */
39
+ export declare const SECRET_TOOL_PATHS: readonly string[];
40
+ /** This computer's store as found: a backend, or none, and when none, whether one is installed but not answering. */
41
+ export type DetectedStore = {
42
+ backend: SecretBackend | null;
43
+ unanswered: boolean;
44
+ };
45
+ /**
46
+ * Find this computer's store: the macOS Keychain where the system's `security` is; a Secret Service where the system's
47
+ * `secret-tool` is and answers; else none (files). A `secret-tool` that is there but can't reach a keyring (no session
48
+ * bus) is none too, and says so.
49
+ */
50
+ export declare function detectSecretStore(options?: {
51
+ platform?: NodeJS.Platform;
52
+ secretTools?: readonly string[];
53
+ owners?: readonly number[];
54
+ }): DetectedStore;
55
+ /** Tests choose the store (a keychain file of their own, files, or what detection found); null goes back to this computer's. */
56
+ export declare function chooseSecretStore(store: SecretBackend | "files" | DetectedStore | null): void;
57
+ /** This computer's store (found once), or null for owner-only files. */
58
+ export declare function secretBackend(env?: NodeJS.ProcessEnv): SecretBackend | null;
59
+ export declare const secretStoreKind: () => SecretStoreKind;
60
+ /** Where secrets rest, in the words Settings and `toolroll integrations` share; `problem` when Toolroll's folder can't be used. */
61
+ export declare function secretStorage(): {
62
+ kind: SecretStoreKind;
63
+ words: string;
64
+ filesOnly: boolean;
65
+ problem?: string;
66
+ };
67
+ /** The text stored at `item`, or null when nothing is. A head read while a writer replaces it is read again. */
68
+ export declare function readValue(backend: SecretBackend, item: SecretItem): string | null;
69
+ /**
70
+ * Store `text` at `item`: the chunks of a new generation first, then the head that names them, then the old
71
+ * generation's chunks are removed. `create`: only when nothing is stored there yet.
72
+ */
73
+ export declare function writeValue(backend: SecretBackend, item: SecretItem, text: string, options?: {
74
+ create?: boolean;
75
+ }): "ok" | "exists";
76
+ /** Remove what is stored at `item`, every chunk with it. */
77
+ export declare function removeValue(backend: SecretBackend, item: SecretItem): void;
78
+ /** The folder's name, beside the database. */
79
+ export declare const SECRET_FOLDER = "tool-secrets";
80
+ /** Where Toolroll records which sealing key it made: the database, which no build can write. */
81
+ export type KeyAnchor = {
82
+ read(): string | null;
83
+ swap(expected: string | null, next: string): boolean;
84
+ };
85
+ /** An anchor kept in this process alone: a test's own folder. */
86
+ export declare function memoryAnchor(): KeyAnchor;
87
+ /** Toolroll's folder for secrets, checked: its key in hand, and where the files kept before it are (`legacy`). */
88
+ export type SecretFolder = {
89
+ readonly root: string;
90
+ readonly dir: string;
91
+ readonly legacy: string | null;
92
+ dev: number;
93
+ ino: number;
94
+ key: Buffer;
95
+ digest: string;
96
+ anchor: KeyAnchor;
97
+ };
98
+ /**
99
+ * Prepare Toolroll's folder for secrets beside `stateDir` (the folder holding the database), checked against what
100
+ * `anchor` recorded: made with its sealing key when it isn't there yet (`create`), refused when something else put a
101
+ * folder, key or link there. `standing`: the folder this process uses by default (Toolroll's own start).
102
+ */
103
+ export declare function prepareSecretFolder(stateDir: string, anchor: KeyAnchor, options?: {
104
+ create?: boolean;
105
+ legacy?: string | null;
106
+ standing?: boolean;
107
+ }): {
108
+ ok: true;
109
+ dir: string;
110
+ } | {
111
+ ok: false;
112
+ problem: string;
113
+ };
114
+ /**
115
+ * Name Toolroll's folder for secrets (beside `stateDir`, the database's folder) and the record to check it against,
116
+ * without touching it yet: it is prepared the first time a secret is used. `standing`: this process's default.
117
+ */
118
+ export declare function registerSecretFolder(stateDir: string, anchor: KeyAnchor, options?: {
119
+ legacy?: string | null;
120
+ }): void;
121
+ /**
122
+ * Toolroll's folder for secrets, checked again now: the one prepared at start when `state` is absent, else the one
123
+ * beside `state` (a test's own folder is prepared the first time it is used, against an anchor in this process).
124
+ */
125
+ export declare function secretFolder(state?: string): SecretFolder;
126
+ /** Forget every prepared folder (tests). */
127
+ export declare function forgetSecretFolders(): void;
128
+ /** The keychain service for everything kept for this folder's Toolroll: its folder, hashed. Never a secret. */
129
+ export declare const serviceOf: (folder: SecretFolder) => string;
130
+ /**
131
+ * A path inside `base` (the folder, or Toolroll's own folder above it), each folder on the way checked: a real folder
132
+ * of this user's that nobody else can write, never a link. Made owner-only when `make` (else a missing one ends the
133
+ * walk: nothing is there).
134
+ */
135
+ export declare function pathIn(base: string, parts: readonly string[], options?: {
136
+ make?: boolean;
137
+ }): string;
138
+ /** A file's text, read without following a link; null when there is none. Anything not plainly Toolroll's is refused. */
139
+ export declare function readOwnFile(path: string): string | null;
140
+ /** Write a file whole and owner-only: a new file beside it, then renamed over it, so a link or second name planted there is replaced, never written through. */
141
+ export declare function writeOwnFile(path: string, text: string): void;
142
+ /** A file's bytes overwritten, then the file removed (a link is only removed, never followed). */
143
+ export declare function destroyOwnFile(path: string): void;
144
+ /** One change at a time to what `name` keeps, across Toolroll's processes (a lock file in the folder), re-entrant within one. */
145
+ export declare function withSecretLock<T>(folder: SecretFolder, name: string, work: () => T): T;
146
+ /** The same, for work that waits on the network (renewing a sign-in): other processes wait for it, up to `waitMs`. */
147
+ export declare function withSecretLockAsync<T>(folder: SecretFolder, name: string, work: () => Promise<T>, options?: {
148
+ staleMs?: number;
149
+ waitMs?: number;
150
+ }): Promise<T>;
151
+ /** `text` sealed for `item` with the folder's key. */
152
+ export declare function seal(text: string, item: SecretItem, folder: SecretFolder): string;
153
+ /** What `seal` sealed for `item`; refused when it was sealed for another item, with another key, or changed. */
154
+ export declare function unseal(sealed: string, item: SecretItem, folder: SecretFolder): string;
155
+ /** The sealed text kept at `item`, opened; null when nothing is. */
156
+ export declare function readSealed(backend: SecretBackend, folder: SecretFolder, item: SecretItem): string | null;
157
+ /** Keep `text` at `item`, sealed. */
158
+ export declare function writeSealed(backend: SecretBackend, folder: SecretFolder, item: SecretItem, text: string, options?: {
159
+ create?: boolean;
160
+ }): "ok" | "exists";
161
+ /**
162
+ * A secret Toolroll holds for an integration, by `name` ("linear-key", "google-mail", "flow-hook.12"), for the
163
+ * Toolroll whose folder is `state`. `file` (inside `state`) is where it was kept before, and still is where there's no
164
+ * keychain. The first read or write with a keychain moves the file in.
165
+ */
166
+ export declare function readHeldSecret(state: string, name: string, file: readonly string[]): string | null;
167
+ /** Keep (or, with null, remove) a held secret: see readHeldSecret. */
168
+ export declare function writeHeldSecret(state: string, name: string, file: readonly string[], value: string | null): void;
169
+ /** Whether a held secret has a value, without reading it where that can be helped. */
170
+ export declare function heldSecretSet(state: string, name: string, file: readonly string[]): boolean;