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
@@ -9,20 +9,28 @@
9
9
  * leaves every build at once. Reviewers and the lead chat never get tools.
10
10
  *
11
11
  * Secret values (API keys, tokens, any env value) never enter the
12
- * database, a chat, a card or a log: they live in one 0600 file per tool
13
- * under ~/.toolroll/tool-secrets/ (or an older ~/.standing-orders/), are written only from the
14
- * console's password-gated screen, and reach exactly that project's
15
- * server processes at launch.
12
+ * database, a chat, a card or a log: they live in the operating system's
13
+ * keychain (tool-secret-store.ts) or, where there is none, in one 0600 file
14
+ * per tool in `tool-secrets` beside the database (files kept before under
15
+ * ~/.toolroll/tool-secrets/, or an older name's, move in on first use).
16
+ * They are written only from the console's password-gated screen, read when a
17
+ * run starts, and reach only that run's tool broker (tool-broker.ts), which
18
+ * gives them to the project's server processes and sign-in headers — never to
19
+ * the agent. A tool connected by signing in gives a run only an access token
20
+ * of its own (oauth-run-token.ts).
16
21
  */
17
22
  import { toolRefusal } from "./policy.js";
18
23
  import { createHash } from "node:crypto";
19
24
  import { spawn } from "node:child_process";
20
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
25
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
26
  import { homedir, tmpdir } from "node:os";
22
- import { isAbsolute, join } from "node:path";
27
+ import { dirname, isAbsolute, join } from "node:path";
23
28
  import { fileURLToPath } from "node:url";
24
29
  import { scanForSecrets } from "./evidence.js";
25
30
  import { namedPath } from "./names.js";
31
+ import { startToolBroker } from "./tool-broker.js";
32
+ import { SecretStoreError, destroyOwnFile, prepareSecretFolder, registerSecretFolder, pathIn, readOwnFile, readSealed, removeValue, secretBackend, secretFolder, secretStorage, serviceOf, withSecretLock, withSecretLockAsync, writeOwnFile, writeSealed } from "./tool-secret-store.js";
33
+ import { ACCESS, GRANTED, REFRESH, mintRunToken, mintsRunTokens, oneAtATime, runScope, scopeList, signedIn } from "./oauth-run-token.js";
26
34
  import { HEADER_NAME, SECRET_NAME, TOOL_NAME as NAME, TOOL_SPEC_LIMITS as LIMITS, readToolSpec } from "./contracts/integration-metadata.js";
27
35
  /** Env names a tool may never claim: the process's own plumbing, every
28
36
  * provider credential, and anything that changes how a program or its
@@ -176,60 +184,191 @@ export function toolCommandLine(spec) {
176
184
  return [spec.command, ...spec.args].map(part => /^[A-Za-z0-9@%+=:,./_-]+$/.test(part ?? "") ? part : JSON.stringify(part)).join(" ");
177
185
  }
178
186
  // ---- secrets -----------------------------------------------------------------
179
- export function toolSecretsDir(home = homedir()) {
180
- return namedPath(home, ["tool-secrets"], { dot: true });
181
- }
182
- function secretFile(repo, tool, home) {
183
- if (!NAME.test(tool))
184
- throw new Error("Choose a tool by its name.");
185
- return join(toolSecretsDir(home), createHash("sha256").update(repo).digest("hex").slice(0, 16), `${tool}.json`);
186
- }
187
- export function readToolSecrets(repo, tool, home = homedir()) {
188
- const file = secretFile(repo, tool, home);
189
- if (!existsSync(file))
190
- return {};
187
+ /**
188
+ * Where tool secrets were kept before (an existing ~/.toolroll/tool-secrets, or an older name's), read once when
189
+ * Toolroll starts so their files move in on first use. Never where new secrets go.
190
+ */
191
+ export function legacyToolSecretsDir(home = homedir()) {
192
+ const found = namedPath(home, ["tool-secrets"], { dot: true });
193
+ return existsSync(found) ? found : null;
194
+ }
195
+ /** Commands that can start builds or serve the console: Toolroll's folder for secrets is made before any build runs. */
196
+ export const SECRETS_FIRST = new Set(["serve", "up", "daemon", "watch", "worker", "tick", "build", "run", "reconcile", "bridge"]);
197
+ /**
198
+ * Toolroll's folder for the secrets it holds, beside `databaseFile`, checked against the sealing key the database
199
+ * recorded: made now when `eager` (a command that can start builds), else the first time a secret is used.
200
+ * A folder that can't be used is said in Settings and `toolroll integrations`; every secret is refused until then.
201
+ */
202
+ export function startToolSecrets(store, databaseFile, clock, options) {
203
+ const anchor = { read: () => store.toolSecretKeyDigest(), swap: (expected, next) => store.swapToolSecretKeyDigest(expected, next, clock()) };
204
+ // The old folder is found once, at the first start of a command that can start builds (so before any build ran), then
205
+ // read from the record.
206
+ const state = dirname(databaseFile), legacy = store.toolSecretLegacyDir(() => legacyToolSecretsDir(), clock(), { record: options.eager });
207
+ if (options.eager)
208
+ prepareSecretFolder(state, anchor, { standing: true, legacy });
209
+ else
210
+ registerSecretFolder(state, anchor, { legacy });
211
+ }
212
+ /** Toolroll's folder for secrets (tool-secret-store.ts): beside the database Toolroll started with, or a test's `state`. */
213
+ export function toolSecretsDir(state) {
214
+ return secretFolder(state).dir;
215
+ }
216
+ const repoHash = (repo) => createHash("sha256").update(repo).digest("hex").slice(0, 16);
217
+ const toolName = (tool) => { if (!NAME.test(tool))
218
+ throw new Error("Choose a tool by its name."); return tool; };
219
+ /** A tool's own file in the folder: its secrets where there's no keychain, or the names of those set. */
220
+ const toolFile = (folder, repo, tool, suffix, make = false) => pathIn(folder.dir, [repoHash(repo), `${toolName(tool)}${suffix}`], { make });
221
+ /** The same tool's file where secrets were kept before Toolroll's folder; null when there was none. */
222
+ const legacyFile = (folder, repo, tool) => folder.legacy === null ? null : pathIn(folder.legacy, [repoHash(repo), `${toolName(tool)}.json`]);
223
+ /** A tool's keychain item: this Toolroll's folder and the project, hashed, and the tool's name. Never a secret. */
224
+ const secretItem = (folder, repo, tool) => ({ service: serviceOf(folder), account: `${repoHash(repo)}.${toolName(tool)}` });
225
+ const secretValues = (parsed) => parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ? {}
226
+ : Object.fromEntries(Object.entries(parsed).filter((entry) => SECRET_NAME.test(entry[0]) && typeof entry[1] === "string"));
227
+ const parsedValues = (text) => {
191
228
  try {
192
- const parsed = JSON.parse(readFileSync(file, "utf8"));
193
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
194
- return {};
195
- return Object.fromEntries(Object.entries(parsed).filter((entry) => SECRET_NAME.test(entry[0]) && typeof entry[1] === "string"));
229
+ return secretValues(JSON.parse(text));
196
230
  }
197
231
  catch {
198
- return {};
232
+ throw new SecretStoreError("A tool's stored secrets are damaged.");
199
233
  }
234
+ };
235
+ /** A secrets file (owner-only, never through a link); null when there is none. */
236
+ function readSecretFile(file) {
237
+ const text = readOwnFile(file);
238
+ return text === null ? null : parsedValues(text);
200
239
  }
201
- function writeSecrets(repo, tool, values, home) {
202
- const file = secretFile(repo, tool, home);
203
- const dir = join(file, "..");
204
- mkdirSync(dir, { recursive: true, mode: 0o700 });
205
- chmodSync(toolSecretsDir(home), 0o700);
206
- chmodSync(dir, 0o700);
207
- if (Object.keys(values).length === 0) {
240
+ /** What the current store holds for a tool: its keychain item, or (no keychain) its file in Toolroll's folder; null when nothing. */
241
+ function readStored(backend, folder, repo, tool) {
242
+ if (backend === null)
243
+ return readSecretFile(toolFile(folder, repo, tool, ".json"));
244
+ const text = readSealed(backend, folder, secretItem(folder, repo, tool));
245
+ return text === null ? null : parsedValues(text);
246
+ }
247
+ function writeStored(backend, folder, repo, tool, values, options = {}) {
248
+ if (backend !== null) {
249
+ const item = secretItem(folder, repo, tool);
250
+ if (Object.keys(values).length === 0) {
251
+ removeValue(backend, item);
252
+ return "ok";
253
+ }
254
+ return writeSealed(backend, folder, item, JSON.stringify(values), options);
255
+ }
256
+ const file = toolFile(folder, repo, tool, ".json", true);
257
+ if (Object.keys(values).length === 0)
258
+ destroyOwnFile(file);
259
+ else
260
+ writeOwnFile(file, JSON.stringify(values));
261
+ return "ok";
262
+ }
263
+ /** One change to a tool's secrets at a time, across Toolroll's processes, re-entrant within one. */
264
+ const withToolLock = (folder, repo, tool, work) => withSecretLock(folder, `${repoHash(repo)}.${toolName(tool)}`, work);
265
+ /** The names of a tool's secrets that have a value, kept beside them so a page never reads a value to say so. */
266
+ function writeNames(folder, repo, tool, names) {
267
+ const file = toolFile(folder, repo, tool, ".names.json", true);
268
+ if (names.length === 0)
208
269
  rmSync(file, { force: true });
270
+ else
271
+ writeOwnFile(file, JSON.stringify([...names].sort()));
272
+ }
273
+ /**
274
+ * A tool's secrets moved into the current store from where they were kept before — the old home folder, or (now that
275
+ * there is a keychain) its file in Toolroll's folder — the first time they are used: stored, read back and compared,
276
+ * and only then is the file destroyed. What the store already holds (an earlier move, or a change since) wins over
277
+ * the file. A failure leaves the file as it was, and says so.
278
+ */
279
+ function moveIn(backend, folder, repo, tool) {
280
+ const sources = [legacyFile(folder, repo, tool), backend === null ? null : toolFile(folder, repo, tool, ".json")].filter((one) => one !== null && existsSync(one));
281
+ if (sources.length === 0)
209
282
  return;
210
- }
211
- writeFileSync(file, JSON.stringify(values), { mode: 0o600 });
212
- chmodSync(file, 0o600);
283
+ withToolLock(folder, repo, tool, () => {
284
+ for (const file of sources) {
285
+ // Damaged, a link, or not plainly Toolroll's: kept as it is, and never used.
286
+ let values;
287
+ try {
288
+ values = readSecretFile(file);
289
+ }
290
+ catch {
291
+ values = null;
292
+ }
293
+ if (values === null)
294
+ continue;
295
+ if (Object.keys(values).length > 0 && readStored(backend, folder, repo, tool) === null) {
296
+ if (writeStored(backend, folder, repo, tool, values, { create: true }) === "ok") {
297
+ let back = null;
298
+ try {
299
+ back = readStored(backend, folder, repo, tool);
300
+ }
301
+ catch {
302
+ back = null;
303
+ }
304
+ if (back === null || JSON.stringify(back) !== JSON.stringify(values)) {
305
+ try {
306
+ if (backend !== null)
307
+ removeValue(backend, secretItem(folder, repo, tool));
308
+ }
309
+ catch { /* the file stays the copy that counts */ }
310
+ throw new SecretStoreError("A tool's secrets couldn't be moved to the keychain; they stay in their file until they can.");
311
+ }
312
+ }
313
+ }
314
+ const kept = readStored(backend, folder, repo, tool) ?? {};
315
+ writeNames(folder, repo, tool, Object.keys(kept));
316
+ destroyOwnFile(file);
317
+ }
318
+ });
319
+ }
320
+ /** A tool's secret values. Read only to test it, change it or start a run: a page reads which are set (secretsSetFor). */
321
+ export function readToolSecrets(repo, tool, state) {
322
+ const folder = secretFolder(state);
323
+ const backend = secretBackend();
324
+ moveIn(backend, folder, repo, tool);
325
+ return readStored(backend, folder, repo, tool) ?? {};
213
326
  }
214
327
  /** Set (or, with an empty value, clear) one secret of one tool. */
215
- export function setToolSecret(repo, tool, name, value, home = homedir()) {
216
- if (!SECRET_NAME.test(name) || RESERVED_ENV.has(name))
217
- throw new Error("Choose one of the tool's secrets.");
218
- if (value.length > 8_192 || /[\u0000\r\n]/.test(value))
219
- throw new Error("A secret is one line, up to 8,192 characters.");
220
- const values = readToolSecrets(repo, tool, home);
221
- if (value === "")
222
- delete values[name];
223
- else
224
- values[name] = value;
225
- writeSecrets(repo, tool, values, home);
328
+ export function setToolSecret(repo, tool, name, value, state) {
329
+ setToolSecrets(repo, tool, { [name]: value }, state);
226
330
  }
227
- export function setToolSecrets(repo, tool, values, home = homedir()) {
228
- for (const [name, value] of Object.entries(values))
229
- setToolSecret(repo, tool, name, value, home);
331
+ /** Set (or, with empty values, clear) some secrets of one tool, in one change. */
332
+ export function setToolSecrets(repo, tool, values, state) {
333
+ for (const [name, value] of Object.entries(values)) {
334
+ if (!SECRET_NAME.test(name) || RESERVED_ENV.has(name))
335
+ throw new Error("Choose one of the tool's secrets.");
336
+ if (value.length > 8_192 || /[\u0000\r\n]/.test(value))
337
+ throw new Error("A secret is one line, up to 8,192 characters.");
338
+ }
339
+ const folder = secretFolder(state);
340
+ withToolLock(folder, repo, tool, () => {
341
+ const current = readToolSecrets(repo, tool, state);
342
+ for (const [name, value] of Object.entries(values)) {
343
+ if (value === "")
344
+ delete current[name];
345
+ else
346
+ current[name] = value;
347
+ }
348
+ writeStored(secretBackend(), folder, repo, tool, current);
349
+ writeNames(folder, repo, tool, Object.keys(current));
350
+ });
351
+ }
352
+ export function clearToolSecrets(repo, tool, state) {
353
+ const folder = secretFolder(state);
354
+ withToolLock(folder, repo, tool, () => {
355
+ const backend = secretBackend();
356
+ if (backend !== null)
357
+ removeValue(backend, secretItem(folder, repo, tool));
358
+ for (const file of [toolFile(folder, repo, tool, ".json"), legacyFile(folder, repo, tool)])
359
+ if (file !== null && existsSync(file))
360
+ destroyOwnFile(file);
361
+ rmSync(toolFile(folder, repo, tool, ".names.json"), { force: true });
362
+ });
230
363
  }
231
- export function clearToolSecrets(repo, tool, home = homedir()) {
232
- rmSync(secretFile(repo, tool, home), { force: true });
364
+ /**
365
+ * One renewal of a sign-in at a time, across Toolroll's processes (a lock in its folder) and within one: a service
366
+ * that rotates its refresh token on every use would otherwise see the same one twice, and whichever answer was saved
367
+ * last would be the newest only by luck.
368
+ */
369
+ export function renewingSignIn(repo, tool, state, work) {
370
+ const folder = secretFolder(state);
371
+ return oneAtATime(`${folder.root}\0${repo}\0${tool}`, () => withSecretLockAsync(folder, `${repoHash(repo)}.${toolName(tool)}.renew`, work));
233
372
  }
234
373
  /** Required secrets with no value yet: the tool is left out of builds until they are set. */
235
374
  export function missingSecrets(spec, values) {
@@ -540,26 +679,28 @@ function safeLine(text) {
540
679
  }
541
680
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
542
681
  const tomlList = (values) => `[${values.map(toml).join(",")}]`;
543
- /** Claude: only these servers, written to a private 0600 file removed after the run (the provider adds `--strict-mcp-config`). */
544
- export function claudeToolArgs(launch) {
545
- const servers = {};
546
- for (const tool of launch.tools) {
547
- const spec = tool.spec;
548
- if (spec.transport === "stdio") {
549
- servers[spec.name] = { type: "stdio", command: spec.command, args: spec.args, env: tool.values };
550
- }
551
- else {
552
- const headers = {};
553
- if (spec.bearer !== null && tool.values[spec.bearer])
554
- headers["Authorization"] = `Bearer ${tool.values[spec.bearer]}`;
555
- for (const [header, secret] of Object.entries(spec.headerSecrets))
556
- if (tool.values[secret])
557
- headers[header] = tool.values[secret];
558
- servers[spec.name] = { type: "http", url: spec.url, headers };
559
- }
560
- }
682
+ /** The launch's broker, holding its tools' values; the agent gets only its address and token. */
683
+ function brokerFor(launch, options) {
684
+ return startToolBroker(launch.tools.map(tool => ({ spec: tool.spec, values: tool.values, ...(options.enabledTools?.[tool.spec.name] === undefined ? {} : { actions: options.enabledTools[tool.spec.name] }) })), options.broker);
685
+ }
686
+ /** A private 0700 folder for one launch's files, and its cleanup with the broker's. */
687
+ function launchFolder(broker) {
561
688
  const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
562
689
  chmodSync(dir, 0o700);
690
+ return { dir, cleanup: () => { broker?.close(); rmSync(dir, { recursive: true, force: true }); } };
691
+ }
692
+ /**
693
+ * Claude: only these servers, in a private 0600 file removed after the run
694
+ * (the provider adds `--strict-mcp-config`). Each is the broker's address
695
+ * with this launch's token: Claude and its shell share one sandbox, so the
696
+ * file holds nothing a tool signs in with.
697
+ */
698
+ export async function claudeToolArgs(launch, options = {}) {
699
+ const broker = launch.tools.length === 0 ? null : await brokerFor(launch, options);
700
+ const servers = {};
701
+ for (const tool of launch.tools)
702
+ servers[tool.spec.name] = { type: "http", url: broker.url(tool.spec.name), headers: { Authorization: `Bearer ${broker.token}` } };
703
+ const { dir, cleanup } = launchFolder(broker);
563
704
  const file = join(dir, "mcp.json");
564
705
  writeFileSync(file, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
565
706
  return {
@@ -567,32 +708,38 @@ export function claudeToolArgs(launch) {
567
708
  // A first `npx` download can take longer than the default start-up wait.
568
709
  env: launch.tools.length > 0 ? { MCP_TIMEOUT: "60000" } : {},
569
710
  omitEnv: [],
570
- cleanup: () => rmSync(dir, { recursive: true, force: true }),
711
+ cleanup,
712
+ privateDir: dir,
713
+ ...(broker === null ? {} : { serverPids: broker.pids }),
571
714
  };
572
715
  }
573
716
  /** Where the tool launcher lives beside this module, built. */
574
717
  export function toolLauncherPath() {
575
718
  return fileURLToPath(new URL("./tool-launcher.js", import.meta.url));
576
719
  }
720
+ /** The secret name a launch file gives the broker's token. */
721
+ const BROKER_TOKEN = "TOOLROLL_TOOL_TOKEN";
577
722
  /**
578
- * Codex: each tool is named by `-c` and started through the launcher. The
579
- * operator's config.toml, rules, apps, plugins and hooks are already off
580
- * (the provider's CODEX_BUILD_ISOLATION_ARGV), and nothing of config.toml
581
- * is carried. Codex gives its own environment to the agent's shell whatever
582
- * its policy says, so no secret rides it: each tool's values sit in the
583
- * run's private 0600 file and reach only that tool's process.
723
+ * Codex: each tool is named by `-c` and started through the launcher, which
724
+ * relays it to the launch's broker. The operator's config.toml, rules, apps,
725
+ * plugins and hooks are already off (the provider's
726
+ * CODEX_BUILD_ISOLATION_ARGV), and nothing of config.toml is carried. Codex
727
+ * gives its own environment to the agent's shell whatever its policy says,
728
+ * so nothing rides it: the launch files name the broker and its token, and
729
+ * the folder holding them is fenced from the agent.
584
730
  */
585
- export function codexToolArgs(launch, options = {}) {
731
+ export async function codexToolArgs(launch, options = {}) {
586
732
  const argv = [];
587
733
  if (launch.tools.length === 0)
588
734
  return { argv, env: {}, omitEnv: [], cleanup: () => undefined };
589
- const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
590
- chmodSync(dir, 0o700);
735
+ const broker = await brokerFor(launch, options);
736
+ const { dir, cleanup } = launchFolder(broker);
591
737
  const launcher = options.launcher ?? toolLauncherPath();
592
738
  const node = options.node ?? process.execPath;
593
739
  for (const tool of launch.tools) {
594
740
  const file = join(dir, `${tool.spec.name}.json`);
595
- writeFileSync(file, JSON.stringify({ spec: tool.spec, values: tool.values }), { mode: 0o600 });
741
+ const relay = { ...tool.spec, transport: "http", command: null, args: [], url: broker.url(tool.spec.name), secrets: [{ name: BROKER_TOKEN, optional: false }], bearer: BROKER_TOKEN, headerSecrets: {} };
742
+ writeFileSync(file, JSON.stringify({ spec: relay, values: { [BROKER_TOKEN]: broker.token } }), { mode: 0o600 });
596
743
  const key = `mcp_servers.${tool.spec.name}`;
597
744
  argv.push("-c", `${key}.command=${toml(node)}`, "-c", `${key}.args=${tomlList([launcher, file])}`, "-c", `${key}.startup_timeout_sec=60`,
598
745
  // Adding a tool to a project is the operator's yes to its calls: an unattended build has no one to ask.
@@ -602,7 +749,7 @@ export function codexToolArgs(launch, options = {}) {
602
749
  if (enabled !== undefined)
603
750
  argv.push("-c", `${key}.enabled_tools=${tomlList(enabled)}`);
604
751
  }
605
- return { argv, env: {}, omitEnv: [], cleanup: () => rmSync(dir, { recursive: true, force: true }), privateDir: dir };
752
+ return { argv, env: {}, omitEnv: [], cleanup, privateDir: dir, serverPids: broker.pids };
606
753
  }
607
754
  /** Gemini: no way yet to hand it exactly these servers, so it gets none. */
608
755
  export const GEMINI_NO_TOOLS_ARGV = ["--allowed-mcp-server-names", "standing-orders-none"];
@@ -631,14 +778,14 @@ export function projectToolsOf(store, repo) {
631
778
  * sealed gets today's list), each with every required secret set. What is
632
779
  * left out says why, in words the task page can show.
633
780
  */
634
- export function toolLaunchFor(store, runId, home = homedir()) {
781
+ export function toolLaunchFor(store, runId, home) {
635
782
  const run = store.getRun(runId);
636
783
  const ref = run === null ? null : store.refById(run.taskRef);
637
784
  if (ref === null || ref.repo === null)
638
785
  return { tools: [], skipped: [] };
639
786
  const approved = store.getScope(ref.externalId)?.approvedDigest ?? null;
640
787
  const seal = approved === null ? null : store.toolSealFor(ref.externalId, approved);
641
- const launch = { tools: [], skipped: [] };
788
+ const launch = { tools: [], skipped: [], repo: ref.repo };
642
789
  const claimed = new Map();
643
790
  const policy = store.orgPolicy();
644
791
  for (const tool of projectToolsOf(store, ref.repo)) {
@@ -652,13 +799,18 @@ export function toolLaunchFor(store, runId, home = homedir()) {
652
799
  launch.skipped.push({ name: tool.name, reason: "added or changed after this task was approved; approve it again to use it" });
653
800
  continue;
654
801
  }
655
- const values = readToolSecrets(ref.repo, tool.name, home);
802
+ // A tool that declares no secret never reads the store: a store that isn't ready (a process that didn't start
803
+ // Toolroll's folder) or a keychain that refuses can't take a keyless tool out of the run.
804
+ const values = tool.spec.secrets.length === 0 ? {} : readToolSecrets(ref.repo, tool.name, home);
656
805
  const missing = missingSecrets(tool.spec, values);
657
806
  if (missing.length > 0) {
658
807
  launch.skipped.push({ name: tool.name, reason: `needs ${missing.join(", ")} set on the Tools page` });
659
808
  continue;
660
809
  }
661
- const kept = Object.fromEntries(tool.spec.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => [one.name, values[one.name]]));
810
+ // A tool connected by signing in launches with its access token alone: its refresh token, client and the rest
811
+ // stay in the store, and a run that renews gets an access token of its own (prepareRunTools).
812
+ const kept = signedIn(tool.spec) ? { [ACCESS]: values[ACCESS] }
813
+ : Object.fromEntries(tool.spec.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => [one.name, values[one.name]]));
662
814
  const clash = Object.entries(kept).find(([name, value]) => claimed.has(name) && claimed.get(name) !== value);
663
815
  if (clash !== undefined) {
664
816
  launch.skipped.push({ name: tool.name, reason: `uses ${clash[0]} with a different value than another tool` });
@@ -670,33 +822,143 @@ export function toolLaunchFor(store, runId, home = homedir()) {
670
822
  }
671
823
  return launch;
672
824
  }
825
+ /**
826
+ * Why a local tool that carries secrets can't start for this launch, or null when it can. Its server's environment
827
+ * holds the values, and a process of the same user can read another's (KERN_PROCARGS2, /proc/<pid>/environ) unless
828
+ * an OS fence stops it: only the macOS sandbox around a Claude build does. Codex's filesystem policy can't deny that
829
+ * read (OpenRouter uses the same harness); Claude's own file rules and Linux's bubblewrap don't stop it, and on Linux
830
+ * hidepid hides nothing from the same user, so Linux never starts one.
831
+ */
832
+ export function secretToolRefusal(provider, containment) {
833
+ if (provider === "codex" || provider === "openrouter")
834
+ return "This tool needs a Claude build to protect its sign-in details.";
835
+ if (containment.platform === "linux")
836
+ return "Toolroll doesn't start tools with sign-in details on Linux: a build there could read them from the running tool.";
837
+ if (containment.platform !== "darwin" || containment.fence !== "macos-sandbox")
838
+ return "This build has no macOS sandbox, so it could read this tool's sign-in details. Toolroll didn't start it.";
839
+ return null;
840
+ }
673
841
  /**
674
842
  * One attempt's tools, ready for its provider, and recorded on the run:
675
843
  * what it launched with and what was left out and why. The caller spawns
676
844
  * with `argv`/`env` and calls `cleanup` when the process is done.
677
845
  */
678
- export function prepareRunTools(store, runId, provider, options) {
846
+ export async function prepareRunTools(store, runId, provider, options) {
679
847
  const launch = limitedTo(toolLaunchFor(store, runId, options.home), options.readOnly, options.withheld);
680
- const enabledTools = options.readOnly === undefined ? {} : { enabledTools: options.readOnly };
681
- const prepared = provider === "claude" ? claudeToolArgs(launch)
682
- : provider === "codex" || provider === "openrouter" ? codexToolArgs(launch, enabledTools)
683
- : { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
848
+ // A local server with secrets starts only where the build can't read its environment; the rest are left out,
849
+ // saying why, before the broker exists. Gemini starts none (below).
850
+ const refusal = provider === "gemini" ? null : secretToolRefusal(provider, options.containment);
851
+ if (refusal !== null) {
852
+ launch.tools = launch.tools.filter(one => {
853
+ if (one.spec.transport === "http" || Object.keys(one.values).length === 0)
854
+ return true;
855
+ launch.skipped.push({ name: one.spec.name, reason: refusal });
856
+ return false;
857
+ });
858
+ }
859
+ const revokes = provider === "gemini" ? [] : await signInRun(launch, { readOnly: options.readOnly !== undefined, now: options.now, ...(options.home === undefined ? {} : { home: options.home }), ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
860
+ const revoke = () => Promise.all(revokes.splice(0).map(one => one())).then(() => undefined);
861
+ const offered = { ...(options.readOnly === undefined ? {} : { enabledTools: options.readOnly }), ...(options.broker === undefined ? {} : { broker: options.broker }) };
862
+ let launched;
863
+ try {
864
+ launched = provider === "claude" ? await claudeToolArgs(launch, offered)
865
+ : provider === "codex" || provider === "openrouter" ? await codexToolArgs(launch, offered)
866
+ : { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
867
+ }
868
+ catch (error) {
869
+ // Tokens were made for a run that won't start: each is revoked, and the run with them.
870
+ if (revokes.length === 0)
871
+ throw error;
872
+ await revoke();
873
+ throw new RunSignInError("This run's tools couldn't be prepared, so it didn't start; the sign-ins made for it were revoked.");
874
+ }
875
+ // The run's own sign-ins end with it: the broker closes first, so nothing uses a token as it is revoked.
876
+ const prepared = revokes.length === 0 ? launched : { ...launched, cleanup: () => { launched.cleanup(); void revoke(); } };
684
877
  const skipped = provider === "gemini"
685
878
  ? [...launch.skipped, ...launch.tools.map(one => ({ name: one.spec.name, reason: "Gemini builds can't use project tools yet" }))]
686
879
  : launch.skipped;
687
- store.recordRunTools(runId, JSON.stringify({
688
- provider,
689
- tools: provider === "gemini" ? [] : launch.tools.map(one => ({ name: one.spec.name, digest: one.digest })),
690
- skipped,
691
- }), options.now);
880
+ try {
881
+ store.recordRunTools(runId, JSON.stringify({
882
+ provider,
883
+ tools: provider === "gemini" ? [] : launch.tools.map(one => ({ name: one.spec.name, digest: one.digest })),
884
+ skipped,
885
+ }), options.now);
886
+ }
887
+ catch (error) {
888
+ launched.cleanup();
889
+ await revoke();
890
+ throw revokes.length === 0 ? error : new RunSignInError("This run's tools couldn't be recorded, so it didn't start; the sign-ins made for it were revoked.");
891
+ }
692
892
  return prepared;
693
893
  }
894
+ /** A run whose sign-ins couldn't all be made and kept: it doesn't start, and every token made for it is revoked. */
895
+ export class RunSignInError extends Error {
896
+ }
897
+ /**
898
+ * Each signed-in tool's own access token for this run (oauth-run-token.ts), read and renewed here, outside the fence,
899
+ * one renewal at a time across Toolroll's processes. A rotated refresh token is saved before the run is let in; when
900
+ * it can't be, or anything else fails once a token is made, every token made for this run is revoked, the saved
901
+ * sign-in is left as it was, and the run doesn't start (RunSignInError). Only the new access token joins the launch.
902
+ * A tool that can't be signed in is left out, saying why. Returns how to revoke the run's tokens.
903
+ */
904
+ async function signInRun(launch, options) {
905
+ const repo = launch.repo;
906
+ const revokes = [];
907
+ if (repo === undefined)
908
+ return revokes;
909
+ const kept = [];
910
+ try {
911
+ for (const tool of launch.tools) {
912
+ if (!signedIn(tool.spec)) {
913
+ kept.push(tool);
914
+ continue;
915
+ }
916
+ const minted = await renewingSignIn(repo, tool.spec.name, options.home, async () => {
917
+ const values = readToolSecrets(repo, tool.spec.name, options.home);
918
+ // No refresh token: the one access token the sign-in gave stays in the broker, like a key.
919
+ if (!mintsRunTokens(values))
920
+ return { ok: true, token: null };
921
+ const scope = runScope(scopeList(values[GRANTED]), options.readOnly);
922
+ const answer = await mintRunToken(values, { scope, now: options.now, ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
923
+ if (answer.ok && answer.token.revoke !== null)
924
+ revokes.push(answer.token.revoke);
925
+ if (answer.ok && answer.token.rotated !== null) {
926
+ try {
927
+ setToolSecrets(repo, tool.spec.name, { [REFRESH]: answer.token.rotated }, options.home);
928
+ }
929
+ catch {
930
+ throw new RunSignInError(`${tool.spec.name}'s renewed sign-in couldn't be saved, so this run didn't start. Check that the keychain is unlocked, then try again.`);
931
+ }
932
+ }
933
+ return answer;
934
+ });
935
+ if (!minted.ok) {
936
+ launch.skipped.push({ name: tool.spec.name, reason: minted.reason });
937
+ continue;
938
+ }
939
+ if (minted.token === null) {
940
+ kept.push(tool);
941
+ continue;
942
+ }
943
+ kept.push({ ...tool, values: { [ACCESS]: minted.token.access } });
944
+ }
945
+ }
946
+ catch (error) {
947
+ await Promise.all(revokes.splice(0).map(one => one()));
948
+ if (error instanceof RunSignInError)
949
+ throw error;
950
+ throw new RunSignInError("A tool's sign-in couldn't be made for this run, so it didn't start. Check that the keychain is unlocked, then try again.");
951
+ }
952
+ launch.tools = kept;
953
+ return revokes;
954
+ }
694
955
  /** A research step's launch: only the servers it may read, each limited to its read-only actions; the rest left out, saying why (`withheld`, when a service has its own reason). */
695
956
  function limitedTo(launch, readOnly, withheld = {}) {
696
957
  if (readOnly === undefined)
697
958
  return launch;
698
959
  const readable = (name) => (readOnly[name] ?? []).length > 0;
699
960
  return {
961
+ ...launch,
700
962
  tools: launch.tools.filter(one => readable(one.spec.name)),
701
963
  skipped: [...launch.skipped, ...launch.tools.filter(one => !readable(one.spec.name)).map(one => ({ name: one.spec.name, reason: withheld[one.spec.name] ?? "research reads only connected services' read-only actions" }))],
702
964
  };
@@ -707,7 +969,7 @@ export function noToolsArgs(provider) {
707
969
  return { argv: ["--mcp-config", '{"mcpServers":{}}'], env: {}, omitEnv: [], cleanup: () => undefined };
708
970
  if (provider === "gemini")
709
971
  return { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
710
- return codexToolArgs({ tools: [], skipped: [] });
972
+ return { argv: [], env: {}, omitEnv: [], cleanup: () => undefined };
711
973
  }
712
974
  /** Add one tool (and, for an import, the values it brings) to a project's builds. */
713
975
  export function addToolTo(store, repo, input, source, by, now, options = {}) {
@@ -763,10 +1025,44 @@ export function toolStanding(tool, secretsSet) {
763
1025
  return { ready: false, words: "Last test failed" };
764
1026
  return { ready: true, words: `Working · ${tool.lastTest.tools.length} tool${tool.lastTest.tools.length === 1 ? "" : "s"}` };
765
1027
  }
766
- /** Names of a tool's secrets that have a value (never the values). */
767
- export function secretsSetFor(repo, tool, home) {
768
- const values = readToolSecrets(repo, tool.name, home);
769
- return tool.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => one.name);
1028
+ export const CREDENTIAL_MODES = {
1029
+ "run-token": { words: "Short-lived token per run", detail: "Each run gets its own access token when it starts, revoked or left to expire when it ends. The refresh token stays with Toolroll." },
1030
+ "held-key": { words: "Key held by Toolroll", detail: "Runs reach this tool through Toolroll, which adds the key. The key never enters a build." },
1031
+ "no-key": { words: "No key needed", detail: null },
1032
+ };
1033
+ /** A tool's credential mode from the names of its secrets that are set (never their values); null while a key it needs is missing. */
1034
+ export function credentialModeOf(spec, secretsSet) {
1035
+ const mode = spec.secrets.every(one => one.optional && !secretsSet.includes(one.name)) ? "no-key"
1036
+ : signedIn(spec) && mintsRunTokens(secretsSet) ? "run-token"
1037
+ : spec.secrets.some(one => !one.optional && !secretsSet.includes(one.name)) ? null
1038
+ : "held-key";
1039
+ return mode === null ? null : { mode, ...CREDENTIAL_MODES[mode] };
1040
+ }
1041
+ /** Names of a tool's secrets that have a value, never the values: in the keychain, from the names kept beside them. */
1042
+ export function secretsSetFor(repo, tool, state) {
1043
+ const folder = secretFolder(state);
1044
+ let names = null;
1045
+ const backend = secretBackend();
1046
+ if (backend !== null && !existsSync(toolFile(folder, repo, tool.name, ".json")) && (folder.legacy === null || !existsSync(legacyFile(folder, repo, tool.name)))) {
1047
+ try {
1048
+ const listed = JSON.parse(readOwnFile(toolFile(folder, repo, tool.name, ".names.json")) ?? "null");
1049
+ names = Array.isArray(listed) ? listed.filter((one) => typeof one === "string") : null;
1050
+ }
1051
+ catch {
1052
+ names = null;
1053
+ }
1054
+ }
1055
+ if (names === null) {
1056
+ const values = readToolSecrets(repo, tool.name, state);
1057
+ names = Object.keys(values).filter(name => values[name] !== "");
1058
+ if (backend !== null)
1059
+ writeNames(folder, repo, tool.name, names);
1060
+ }
1061
+ return tool.secrets.filter(one => names.includes(one.name)).map(one => one.name);
1062
+ }
1063
+ /** Where this computer keeps tool secrets, in the words Settings and `toolroll integrations` share; `problem` when they can't be used. */
1064
+ export function toolSecretStorage() {
1065
+ return secretStorage();
770
1066
  }
771
1067
  /** A command line split into program and arguments, honouring "double" and 'single' quotes. */
772
1068
  export function splitCommandLine(line) {