toolroll 0.9.62 → 0.9.65

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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +203 -10
  16. package/dist/agent-fence.js +623 -41
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +263 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +199 -17
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/check-levels.js +3 -0
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -9,20 +9,29 @@
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 { isAcpProvider } from "./acp-agents.js";
33
+ import { SecretStoreError, destroyOwnFile, prepareSecretFolder, registerSecretFolder, pathIn, readOwnFile, readSealed, removeValue, secretBackend, secretFolder, secretStorage, serviceOf, withSecretLock, withSecretLockAsync, writeOwnFile, writeSealed } from "./tool-secret-store.js";
34
+ import { ACCESS, GRANTED, REFRESH, mintRunToken, mintsRunTokens, oneAtATime, runScope, scopeList, signedIn } from "./oauth-run-token.js";
26
35
  import { HEADER_NAME, SECRET_NAME, TOOL_NAME as NAME, TOOL_SPEC_LIMITS as LIMITS, readToolSpec } from "./contracts/integration-metadata.js";
27
36
  /** Env names a tool may never claim: the process's own plumbing, every
28
37
  * provider credential, and anything that changes how a program or its
@@ -176,60 +185,191 @@ export function toolCommandLine(spec) {
176
185
  return [spec.command, ...spec.args].map(part => /^[A-Za-z0-9@%+=:,./_-]+$/.test(part ?? "") ? part : JSON.stringify(part)).join(" ");
177
186
  }
178
187
  // ---- 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 {};
188
+ /**
189
+ * Where tool secrets were kept before (an existing ~/.toolroll/tool-secrets, or an older name's), read once when
190
+ * Toolroll starts so their files move in on first use. Never where new secrets go.
191
+ */
192
+ export function legacyToolSecretsDir(home = homedir()) {
193
+ const found = namedPath(home, ["tool-secrets"], { dot: true });
194
+ return existsSync(found) ? found : null;
195
+ }
196
+ /** Commands that can start builds or serve the console: Toolroll's folder for secrets is made before any build runs. */
197
+ export const SECRETS_FIRST = new Set(["serve", "up", "daemon", "watch", "worker", "tick", "build", "run", "reconcile", "bridge"]);
198
+ /**
199
+ * Toolroll's folder for the secrets it holds, beside `databaseFile`, checked against the sealing key the database
200
+ * recorded: made now when `eager` (a command that can start builds), else the first time a secret is used.
201
+ * A folder that can't be used is said in Settings and `toolroll integrations`; every secret is refused until then.
202
+ */
203
+ export function startToolSecrets(store, databaseFile, clock, options) {
204
+ const anchor = { read: () => store.toolSecretKeyDigest(), swap: (expected, next) => store.swapToolSecretKeyDigest(expected, next, clock()) };
205
+ // The old folder is found once, at the first start of a command that can start builds (so before any build ran), then
206
+ // read from the record.
207
+ const state = dirname(databaseFile), legacy = store.toolSecretLegacyDir(() => legacyToolSecretsDir(), clock(), { record: options.eager });
208
+ if (options.eager)
209
+ prepareSecretFolder(state, anchor, { standing: true, legacy });
210
+ else
211
+ registerSecretFolder(state, anchor, { legacy });
212
+ }
213
+ /** Toolroll's folder for secrets (tool-secret-store.ts): beside the database Toolroll started with, or a test's `state`. */
214
+ export function toolSecretsDir(state) {
215
+ return secretFolder(state).dir;
216
+ }
217
+ const repoHash = (repo) => createHash("sha256").update(repo).digest("hex").slice(0, 16);
218
+ const toolName = (tool) => { if (!NAME.test(tool))
219
+ throw new Error("Choose a tool by its name."); return tool; };
220
+ /** A tool's own file in the folder: its secrets where there's no keychain, or the names of those set. */
221
+ const toolFile = (folder, repo, tool, suffix, make = false) => pathIn(folder.dir, [repoHash(repo), `${toolName(tool)}${suffix}`], { make });
222
+ /** The same tool's file where secrets were kept before Toolroll's folder; null when there was none. */
223
+ const legacyFile = (folder, repo, tool) => folder.legacy === null ? null : pathIn(folder.legacy, [repoHash(repo), `${toolName(tool)}.json`]);
224
+ /** A tool's keychain item: this Toolroll's folder and the project, hashed, and the tool's name. Never a secret. */
225
+ const secretItem = (folder, repo, tool) => ({ service: serviceOf(folder), account: `${repoHash(repo)}.${toolName(tool)}` });
226
+ const secretValues = (parsed) => parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ? {}
227
+ : Object.fromEntries(Object.entries(parsed).filter((entry) => SECRET_NAME.test(entry[0]) && typeof entry[1] === "string"));
228
+ const parsedValues = (text) => {
191
229
  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"));
230
+ return secretValues(JSON.parse(text));
196
231
  }
197
232
  catch {
198
- return {};
233
+ throw new SecretStoreError("A tool's stored secrets are damaged.");
199
234
  }
235
+ };
236
+ /** A secrets file (owner-only, never through a link); null when there is none. */
237
+ function readSecretFile(file) {
238
+ const text = readOwnFile(file);
239
+ return text === null ? null : parsedValues(text);
200
240
  }
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) {
241
+ /** What the current store holds for a tool: its keychain item, or (no keychain) its file in Toolroll's folder; null when nothing. */
242
+ function readStored(backend, folder, repo, tool) {
243
+ if (backend === null)
244
+ return readSecretFile(toolFile(folder, repo, tool, ".json"));
245
+ const text = readSealed(backend, folder, secretItem(folder, repo, tool));
246
+ return text === null ? null : parsedValues(text);
247
+ }
248
+ function writeStored(backend, folder, repo, tool, values, options = {}) {
249
+ if (backend !== null) {
250
+ const item = secretItem(folder, repo, tool);
251
+ if (Object.keys(values).length === 0) {
252
+ removeValue(backend, item);
253
+ return "ok";
254
+ }
255
+ return writeSealed(backend, folder, item, JSON.stringify(values), options);
256
+ }
257
+ const file = toolFile(folder, repo, tool, ".json", true);
258
+ if (Object.keys(values).length === 0)
259
+ destroyOwnFile(file);
260
+ else
261
+ writeOwnFile(file, JSON.stringify(values));
262
+ return "ok";
263
+ }
264
+ /** One change to a tool's secrets at a time, across Toolroll's processes, re-entrant within one. */
265
+ const withToolLock = (folder, repo, tool, work) => withSecretLock(folder, `${repoHash(repo)}.${toolName(tool)}`, work);
266
+ /** The names of a tool's secrets that have a value, kept beside them so a page never reads a value to say so. */
267
+ function writeNames(folder, repo, tool, names) {
268
+ const file = toolFile(folder, repo, tool, ".names.json", true);
269
+ if (names.length === 0)
208
270
  rmSync(file, { force: true });
271
+ else
272
+ writeOwnFile(file, JSON.stringify([...names].sort()));
273
+ }
274
+ /**
275
+ * A tool's secrets moved into the current store from where they were kept before — the old home folder, or (now that
276
+ * there is a keychain) its file in Toolroll's folder — the first time they are used: stored, read back and compared,
277
+ * and only then is the file destroyed. What the store already holds (an earlier move, or a change since) wins over
278
+ * the file. A failure leaves the file as it was, and says so.
279
+ */
280
+ function moveIn(backend, folder, repo, tool) {
281
+ const sources = [legacyFile(folder, repo, tool), backend === null ? null : toolFile(folder, repo, tool, ".json")].filter((one) => one !== null && existsSync(one));
282
+ if (sources.length === 0)
209
283
  return;
210
- }
211
- writeFileSync(file, JSON.stringify(values), { mode: 0o600 });
212
- chmodSync(file, 0o600);
284
+ withToolLock(folder, repo, tool, () => {
285
+ for (const file of sources) {
286
+ // Damaged, a link, or not plainly Toolroll's: kept as it is, and never used.
287
+ let values;
288
+ try {
289
+ values = readSecretFile(file);
290
+ }
291
+ catch {
292
+ values = null;
293
+ }
294
+ if (values === null)
295
+ continue;
296
+ if (Object.keys(values).length > 0 && readStored(backend, folder, repo, tool) === null) {
297
+ if (writeStored(backend, folder, repo, tool, values, { create: true }) === "ok") {
298
+ let back = null;
299
+ try {
300
+ back = readStored(backend, folder, repo, tool);
301
+ }
302
+ catch {
303
+ back = null;
304
+ }
305
+ if (back === null || JSON.stringify(back) !== JSON.stringify(values)) {
306
+ try {
307
+ if (backend !== null)
308
+ removeValue(backend, secretItem(folder, repo, tool));
309
+ }
310
+ catch { /* the file stays the copy that counts */ }
311
+ throw new SecretStoreError("A tool's secrets couldn't be moved to the keychain; they stay in their file until they can.");
312
+ }
313
+ }
314
+ }
315
+ const kept = readStored(backend, folder, repo, tool) ?? {};
316
+ writeNames(folder, repo, tool, Object.keys(kept));
317
+ destroyOwnFile(file);
318
+ }
319
+ });
320
+ }
321
+ /** A tool's secret values. Read only to test it, change it or start a run: a page reads which are set (secretsSetFor). */
322
+ export function readToolSecrets(repo, tool, state) {
323
+ const folder = secretFolder(state);
324
+ const backend = secretBackend();
325
+ moveIn(backend, folder, repo, tool);
326
+ return readStored(backend, folder, repo, tool) ?? {};
213
327
  }
214
328
  /** 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);
329
+ export function setToolSecret(repo, tool, name, value, state) {
330
+ setToolSecrets(repo, tool, { [name]: value }, state);
226
331
  }
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);
332
+ /** Set (or, with empty values, clear) some secrets of one tool, in one change. */
333
+ export function setToolSecrets(repo, tool, values, state) {
334
+ for (const [name, value] of Object.entries(values)) {
335
+ if (!SECRET_NAME.test(name) || RESERVED_ENV.has(name))
336
+ throw new Error("Choose one of the tool's secrets.");
337
+ if (value.length > 8_192 || /[\u0000\r\n]/.test(value))
338
+ throw new Error("A secret is one line, up to 8,192 characters.");
339
+ }
340
+ const folder = secretFolder(state);
341
+ withToolLock(folder, repo, tool, () => {
342
+ const current = readToolSecrets(repo, tool, state);
343
+ for (const [name, value] of Object.entries(values)) {
344
+ if (value === "")
345
+ delete current[name];
346
+ else
347
+ current[name] = value;
348
+ }
349
+ writeStored(secretBackend(), folder, repo, tool, current);
350
+ writeNames(folder, repo, tool, Object.keys(current));
351
+ });
352
+ }
353
+ export function clearToolSecrets(repo, tool, state) {
354
+ const folder = secretFolder(state);
355
+ withToolLock(folder, repo, tool, () => {
356
+ const backend = secretBackend();
357
+ if (backend !== null)
358
+ removeValue(backend, secretItem(folder, repo, tool));
359
+ for (const file of [toolFile(folder, repo, tool, ".json"), legacyFile(folder, repo, tool)])
360
+ if (file !== null && existsSync(file))
361
+ destroyOwnFile(file);
362
+ rmSync(toolFile(folder, repo, tool, ".names.json"), { force: true });
363
+ });
230
364
  }
231
- export function clearToolSecrets(repo, tool, home = homedir()) {
232
- rmSync(secretFile(repo, tool, home), { force: true });
365
+ /**
366
+ * One renewal of a sign-in at a time, across Toolroll's processes (a lock in its folder) and within one: a service
367
+ * that rotates its refresh token on every use would otherwise see the same one twice, and whichever answer was saved
368
+ * last would be the newest only by luck.
369
+ */
370
+ export function renewingSignIn(repo, tool, state, work) {
371
+ const folder = secretFolder(state);
372
+ return oneAtATime(`${folder.root}\0${repo}\0${tool}`, () => withSecretLockAsync(folder, `${repoHash(repo)}.${toolName(tool)}.renew`, work));
233
373
  }
234
374
  /** Required secrets with no value yet: the tool is left out of builds until they are set. */
235
375
  export function missingSecrets(spec, values) {
@@ -540,26 +680,28 @@ function safeLine(text) {
540
680
  }
541
681
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
542
682
  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
- }
683
+ /** The launch's broker, holding its tools' values; the agent gets only its address and token. */
684
+ function brokerFor(launch, options) {
685
+ 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);
686
+ }
687
+ /** A private 0700 folder for one launch's files, and its cleanup with the broker's. */
688
+ function launchFolder(broker) {
561
689
  const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
562
690
  chmodSync(dir, 0o700);
691
+ return { dir, cleanup: () => { broker?.close(); rmSync(dir, { recursive: true, force: true }); } };
692
+ }
693
+ /**
694
+ * Claude: only these servers, in a private 0600 file removed after the run
695
+ * (the provider adds `--strict-mcp-config`). Each is the broker's address
696
+ * with this launch's token: Claude and its shell share one sandbox, so the
697
+ * file holds nothing a tool signs in with.
698
+ */
699
+ export async function claudeToolArgs(launch, options = {}) {
700
+ const broker = launch.tools.length === 0 ? null : await brokerFor(launch, options);
701
+ const servers = {};
702
+ for (const tool of launch.tools)
703
+ servers[tool.spec.name] = { type: "http", url: broker.url(tool.spec.name), headers: { Authorization: `Bearer ${broker.token}` } };
704
+ const { dir, cleanup } = launchFolder(broker);
563
705
  const file = join(dir, "mcp.json");
564
706
  writeFileSync(file, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
565
707
  return {
@@ -567,32 +709,38 @@ export function claudeToolArgs(launch) {
567
709
  // A first `npx` download can take longer than the default start-up wait.
568
710
  env: launch.tools.length > 0 ? { MCP_TIMEOUT: "60000" } : {},
569
711
  omitEnv: [],
570
- cleanup: () => rmSync(dir, { recursive: true, force: true }),
712
+ cleanup,
713
+ privateDir: dir,
714
+ ...(broker === null ? {} : { serverPids: broker.pids, brokerPort: broker.port }),
571
715
  };
572
716
  }
573
717
  /** Where the tool launcher lives beside this module, built. */
574
718
  export function toolLauncherPath() {
575
719
  return fileURLToPath(new URL("./tool-launcher.js", import.meta.url));
576
720
  }
721
+ /** The secret name a launch file gives the broker's token. */
722
+ const BROKER_TOKEN = "TOOLROLL_TOOL_TOKEN";
577
723
  /**
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.
724
+ * Codex: each tool is named by `-c` and started through the launcher, which
725
+ * relays it to the launch's broker. The operator's config.toml, rules, apps,
726
+ * plugins and hooks are already off (the provider's
727
+ * CODEX_BUILD_ISOLATION_ARGV), and nothing of config.toml is carried. Codex
728
+ * gives its own environment to the agent's shell whatever its policy says,
729
+ * so nothing rides it: the launch files name the broker and its token, and
730
+ * the folder holding them is fenced from the agent.
584
731
  */
585
- export function codexToolArgs(launch, options = {}) {
732
+ export async function codexToolArgs(launch, options = {}) {
586
733
  const argv = [];
587
734
  if (launch.tools.length === 0)
588
735
  return { argv, env: {}, omitEnv: [], cleanup: () => undefined };
589
- const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
590
- chmodSync(dir, 0o700);
736
+ const broker = await brokerFor(launch, options);
737
+ const { dir, cleanup } = launchFolder(broker);
591
738
  const launcher = options.launcher ?? toolLauncherPath();
592
739
  const node = options.node ?? process.execPath;
593
740
  for (const tool of launch.tools) {
594
741
  const file = join(dir, `${tool.spec.name}.json`);
595
- writeFileSync(file, JSON.stringify({ spec: tool.spec, values: tool.values }), { mode: 0o600 });
742
+ 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: {} };
743
+ writeFileSync(file, JSON.stringify({ spec: relay, values: { [BROKER_TOKEN]: broker.token } }), { mode: 0o600 });
596
744
  const key = `mcp_servers.${tool.spec.name}`;
597
745
  argv.push("-c", `${key}.command=${toml(node)}`, "-c", `${key}.args=${tomlList([launcher, file])}`, "-c", `${key}.startup_timeout_sec=60`,
598
746
  // Adding a tool to a project is the operator's yes to its calls: an unattended build has no one to ask.
@@ -602,7 +750,7 @@ export function codexToolArgs(launch, options = {}) {
602
750
  if (enabled !== undefined)
603
751
  argv.push("-c", `${key}.enabled_tools=${tomlList(enabled)}`);
604
752
  }
605
- return { argv, env: {}, omitEnv: [], cleanup: () => rmSync(dir, { recursive: true, force: true }), privateDir: dir };
753
+ return { argv, env: {}, omitEnv: [], cleanup, privateDir: dir, serverPids: broker.pids, brokerPort: broker.port };
606
754
  }
607
755
  /** Gemini: no way yet to hand it exactly these servers, so it gets none. */
608
756
  export const GEMINI_NO_TOOLS_ARGV = ["--allowed-mcp-server-names", "standing-orders-none"];
@@ -631,14 +779,14 @@ export function projectToolsOf(store, repo) {
631
779
  * sealed gets today's list), each with every required secret set. What is
632
780
  * left out says why, in words the task page can show.
633
781
  */
634
- export function toolLaunchFor(store, runId, home = homedir()) {
782
+ export function toolLaunchFor(store, runId, home) {
635
783
  const run = store.getRun(runId);
636
784
  const ref = run === null ? null : store.refById(run.taskRef);
637
785
  if (ref === null || ref.repo === null)
638
786
  return { tools: [], skipped: [] };
639
787
  const approved = store.getScope(ref.externalId)?.approvedDigest ?? null;
640
788
  const seal = approved === null ? null : store.toolSealFor(ref.externalId, approved);
641
- const launch = { tools: [], skipped: [] };
789
+ const launch = { tools: [], skipped: [], repo: ref.repo };
642
790
  const claimed = new Map();
643
791
  const policy = store.orgPolicy();
644
792
  for (const tool of projectToolsOf(store, ref.repo)) {
@@ -652,13 +800,18 @@ export function toolLaunchFor(store, runId, home = homedir()) {
652
800
  launch.skipped.push({ name: tool.name, reason: "added or changed after this task was approved; approve it again to use it" });
653
801
  continue;
654
802
  }
655
- const values = readToolSecrets(ref.repo, tool.name, home);
803
+ // A tool that declares no secret never reads the store: a store that isn't ready (a process that didn't start
804
+ // Toolroll's folder) or a keychain that refuses can't take a keyless tool out of the run.
805
+ const values = tool.spec.secrets.length === 0 ? {} : readToolSecrets(ref.repo, tool.name, home);
656
806
  const missing = missingSecrets(tool.spec, values);
657
807
  if (missing.length > 0) {
658
808
  launch.skipped.push({ name: tool.name, reason: `needs ${missing.join(", ")} set on the Tools page` });
659
809
  continue;
660
810
  }
661
- const kept = Object.fromEntries(tool.spec.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => [one.name, values[one.name]]));
811
+ // A tool connected by signing in launches with its access token alone: its refresh token, client and the rest
812
+ // stay in the store, and a run that renews gets an access token of its own (prepareRunTools).
813
+ const kept = signedIn(tool.spec) ? { [ACCESS]: values[ACCESS] }
814
+ : Object.fromEntries(tool.spec.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => [one.name, values[one.name]]));
662
815
  const clash = Object.entries(kept).find(([name, value]) => claimed.has(name) && claimed.get(name) !== value);
663
816
  if (clash !== undefined) {
664
817
  launch.skipped.push({ name: tool.name, reason: `uses ${clash[0]} with a different value than another tool` });
@@ -670,33 +823,145 @@ export function toolLaunchFor(store, runId, home = homedir()) {
670
823
  }
671
824
  return launch;
672
825
  }
826
+ /**
827
+ * Why a local tool that carries secrets can't start for this launch, or null when it can. Its server's environment
828
+ * holds the values, and a process of the same user can read another's (KERN_PROCARGS2, /proc/<pid>/environ) unless
829
+ * an OS fence stops it: only the macOS sandbox around a Claude build does. Codex's filesystem policy can't deny that
830
+ * read (OpenRouter uses the same harness); Claude's own file rules and Linux's bubblewrap don't stop it, and on Linux
831
+ * hidepid hides nothing from the same user, so Linux never starts one.
832
+ */
833
+ export function secretToolRefusal(provider, containment) {
834
+ if (provider === "codex" || provider === "openrouter")
835
+ return "This tool needs a Claude build to protect its sign-in details.";
836
+ if (containment.platform === "linux")
837
+ return "Toolroll doesn't start tools with sign-in details on Linux: a build there could read them from the running tool.";
838
+ if (containment.platform !== "darwin" || containment.fence !== "macos-sandbox")
839
+ return "This build has no macOS sandbox, so it could read this tool's sign-in details. Toolroll didn't start it.";
840
+ return null;
841
+ }
673
842
  /**
674
843
  * One attempt's tools, ready for its provider, and recorded on the run:
675
844
  * what it launched with and what was left out and why. The caller spawns
676
845
  * with `argv`/`env` and calls `cleanup` when the process is done.
677
846
  */
678
- export function prepareRunTools(store, runId, provider, options) {
847
+ export async function prepareRunTools(store, runId, provider, options) {
679
848
  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 };
684
- const skipped = provider === "gemini"
685
- ? [...launch.skipped, ...launch.tools.map(one => ({ name: one.spec.name, reason: "Gemini builds can't use project tools yet" }))]
849
+ // A local server with secrets starts only where the build can't read its environment; the rest are left out,
850
+ // saying why, before the broker exists. Gemini and ACP agents start none (below).
851
+ const toolless = provider === "gemini" || isAcpProvider(provider);
852
+ const refusal = toolless ? null : secretToolRefusal(provider, options.containment);
853
+ if (refusal !== null) {
854
+ launch.tools = launch.tools.filter(one => {
855
+ if (one.spec.transport === "http" || Object.keys(one.values).length === 0)
856
+ return true;
857
+ launch.skipped.push({ name: one.spec.name, reason: refusal });
858
+ return false;
859
+ });
860
+ }
861
+ const revokes = toolless ? [] : await signInRun(launch, { readOnly: options.readOnly !== undefined, now: options.now, ...(options.home === undefined ? {} : { home: options.home }), ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
862
+ const revoke = () => Promise.all(revokes.splice(0).map(one => one())).then(() => undefined);
863
+ const offered = { ...(options.readOnly === undefined ? {} : { enabledTools: options.readOnly }), ...(options.broker === undefined ? {} : { broker: options.broker }) };
864
+ let launched;
865
+ try {
866
+ launched = provider === "claude" ? await claudeToolArgs(launch, offered)
867
+ : provider === "codex" || provider === "openrouter" ? await codexToolArgs(launch, offered)
868
+ : provider === "gemini" ? { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined }
869
+ : { argv: [], env: {}, omitEnv: [], cleanup: () => undefined };
870
+ }
871
+ catch (error) {
872
+ // Tokens were made for a run that won't start: each is revoked, and the run with them.
873
+ if (revokes.length === 0)
874
+ throw error;
875
+ await revoke();
876
+ throw new RunSignInError("This run's tools couldn't be prepared, so it didn't start; the sign-ins made for it were revoked.");
877
+ }
878
+ // The run's own sign-ins end with it: the broker closes first, so nothing uses a token as it is revoked.
879
+ const prepared = revokes.length === 0 ? launched : { ...launched, cleanup: () => { launched.cleanup(); void revoke(); } };
880
+ const skipped = toolless
881
+ ? [...launch.skipped, ...launch.tools.map(one => ({ name: one.spec.name, reason: provider === "gemini" ? "Gemini builds can't use project tools yet" : "ACP agents can't use project tools yet" }))]
686
882
  : 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);
883
+ try {
884
+ store.recordRunTools(runId, JSON.stringify({
885
+ provider,
886
+ tools: toolless ? [] : launch.tools.map(one => ({ name: one.spec.name, digest: one.digest })),
887
+ skipped,
888
+ }), options.now);
889
+ }
890
+ catch (error) {
891
+ launched.cleanup();
892
+ await revoke();
893
+ 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.");
894
+ }
692
895
  return prepared;
693
896
  }
897
+ /** A run whose sign-ins couldn't all be made and kept: it doesn't start, and every token made for it is revoked. */
898
+ export class RunSignInError extends Error {
899
+ }
900
+ /**
901
+ * Each signed-in tool's own access token for this run (oauth-run-token.ts), read and renewed here, outside the fence,
902
+ * one renewal at a time across Toolroll's processes. A rotated refresh token is saved before the run is let in; when
903
+ * it can't be, or anything else fails once a token is made, every token made for this run is revoked, the saved
904
+ * sign-in is left as it was, and the run doesn't start (RunSignInError). Only the new access token joins the launch.
905
+ * A tool that can't be signed in is left out, saying why. Returns how to revoke the run's tokens.
906
+ */
907
+ async function signInRun(launch, options) {
908
+ const repo = launch.repo;
909
+ const revokes = [];
910
+ if (repo === undefined)
911
+ return revokes;
912
+ const kept = [];
913
+ try {
914
+ for (const tool of launch.tools) {
915
+ if (!signedIn(tool.spec)) {
916
+ kept.push(tool);
917
+ continue;
918
+ }
919
+ const minted = await renewingSignIn(repo, tool.spec.name, options.home, async () => {
920
+ const values = readToolSecrets(repo, tool.spec.name, options.home);
921
+ // No refresh token: the one access token the sign-in gave stays in the broker, like a key.
922
+ if (!mintsRunTokens(values))
923
+ return { ok: true, token: null };
924
+ const scope = runScope(scopeList(values[GRANTED]), options.readOnly);
925
+ const answer = await mintRunToken(values, { scope, now: options.now, ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
926
+ if (answer.ok && answer.token.revoke !== null)
927
+ revokes.push(answer.token.revoke);
928
+ if (answer.ok && answer.token.rotated !== null) {
929
+ try {
930
+ setToolSecrets(repo, tool.spec.name, { [REFRESH]: answer.token.rotated }, options.home);
931
+ }
932
+ catch {
933
+ 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.`);
934
+ }
935
+ }
936
+ return answer;
937
+ });
938
+ if (!minted.ok) {
939
+ launch.skipped.push({ name: tool.spec.name, reason: minted.reason });
940
+ continue;
941
+ }
942
+ if (minted.token === null) {
943
+ kept.push(tool);
944
+ continue;
945
+ }
946
+ kept.push({ ...tool, values: { [ACCESS]: minted.token.access } });
947
+ }
948
+ }
949
+ catch (error) {
950
+ await Promise.all(revokes.splice(0).map(one => one()));
951
+ if (error instanceof RunSignInError)
952
+ throw error;
953
+ 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.");
954
+ }
955
+ launch.tools = kept;
956
+ return revokes;
957
+ }
694
958
  /** 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
959
  function limitedTo(launch, readOnly, withheld = {}) {
696
960
  if (readOnly === undefined)
697
961
  return launch;
698
962
  const readable = (name) => (readOnly[name] ?? []).length > 0;
699
963
  return {
964
+ ...launch,
700
965
  tools: launch.tools.filter(one => readable(one.spec.name)),
701
966
  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
967
  };
@@ -707,7 +972,7 @@ export function noToolsArgs(provider) {
707
972
  return { argv: ["--mcp-config", '{"mcpServers":{}}'], env: {}, omitEnv: [], cleanup: () => undefined };
708
973
  if (provider === "gemini")
709
974
  return { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
710
- return codexToolArgs({ tools: [], skipped: [] });
975
+ return { argv: [], env: {}, omitEnv: [], cleanup: () => undefined };
711
976
  }
712
977
  /** Add one tool (and, for an import, the values it brings) to a project's builds. */
713
978
  export function addToolTo(store, repo, input, source, by, now, options = {}) {
@@ -763,10 +1028,44 @@ export function toolStanding(tool, secretsSet) {
763
1028
  return { ready: false, words: "Last test failed" };
764
1029
  return { ready: true, words: `Working · ${tool.lastTest.tools.length} tool${tool.lastTest.tools.length === 1 ? "" : "s"}` };
765
1030
  }
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);
1031
+ export const CREDENTIAL_MODES = {
1032
+ "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." },
1033
+ "held-key": { words: "Key held by Toolroll", detail: "Runs reach this tool through Toolroll, which adds the key. The key never enters a build." },
1034
+ "no-key": { words: "No key needed", detail: null },
1035
+ };
1036
+ /** 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. */
1037
+ export function credentialModeOf(spec, secretsSet) {
1038
+ const mode = spec.secrets.every(one => one.optional && !secretsSet.includes(one.name)) ? "no-key"
1039
+ : signedIn(spec) && mintsRunTokens(secretsSet) ? "run-token"
1040
+ : spec.secrets.some(one => !one.optional && !secretsSet.includes(one.name)) ? null
1041
+ : "held-key";
1042
+ return mode === null ? null : { mode, ...CREDENTIAL_MODES[mode] };
1043
+ }
1044
+ /** Names of a tool's secrets that have a value, never the values: in the keychain, from the names kept beside them. */
1045
+ export function secretsSetFor(repo, tool, state) {
1046
+ const folder = secretFolder(state);
1047
+ let names = null;
1048
+ const backend = secretBackend();
1049
+ if (backend !== null && !existsSync(toolFile(folder, repo, tool.name, ".json")) && (folder.legacy === null || !existsSync(legacyFile(folder, repo, tool.name)))) {
1050
+ try {
1051
+ const listed = JSON.parse(readOwnFile(toolFile(folder, repo, tool.name, ".names.json")) ?? "null");
1052
+ names = Array.isArray(listed) ? listed.filter((one) => typeof one === "string") : null;
1053
+ }
1054
+ catch {
1055
+ names = null;
1056
+ }
1057
+ }
1058
+ if (names === null) {
1059
+ const values = readToolSecrets(repo, tool.name, state);
1060
+ names = Object.keys(values).filter(name => values[name] !== "");
1061
+ if (backend !== null)
1062
+ writeNames(folder, repo, tool.name, names);
1063
+ }
1064
+ return tool.secrets.filter(one => names.includes(one.name)).map(one => one.name);
1065
+ }
1066
+ /** Where this computer keeps tool secrets, in the words Settings and `toolroll integrations` share; `problem` when they can't be used. */
1067
+ export function toolSecretStorage() {
1068
+ return secretStorage();
770
1069
  }
771
1070
  /** A command line split into program and arguments, honouring "double" and 'single' quotes. */
772
1071
  export function splitCommandLine(line) {