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
package/dist/invoke.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * (`adapterFor`), and only builder, planner, reviewer, and scout import
17
17
  * `invokeAgent`.
18
18
  */
19
- import { adapterFor, auditOf, isolationUnsupported } from "./provider.js";
19
+ import { adapterFor, auditOf, claudePluginEnv, isolationUnsupported, ALL_CREDENTIAL_ENV } from "./provider.js";
20
20
  import { readProviderKey, readAuthModeStrict, PROVIDER_KEY_ENV, OWN_KEY_ENV } from "./keys.js";
21
21
  import { classifyTerminal } from "./exhaustion.js";
22
22
  import { liftAuthPause } from "./provider-auth.js";
@@ -26,11 +26,13 @@ import { currentContainment } from "./containment.js";
26
26
  import { witnessedRunner } from "./process-custody.js";
27
27
  import { underStopWatch } from "./task-control.js";
28
28
  import { childDatabaseEnv, isolatedChildDatabase, removeChildDatabase as removeAgentDatabase } from "./child-database.js";
29
- import { noToolsArgs, prepareRunTools } from "./project-tools.js";
29
+ import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
30
30
  import { readFileSync, realpathSync } from "node:fs";
31
31
  import { homedir } from "node:os";
32
- import { join } from "node:path";
33
- import { agentFence, linuxFenceAvailable, macosFenceAvailable, taskBoundary } from "./agent-fence.js";
32
+ import { dirname, join } from "node:path";
33
+ import { poolRootOf } from "./worktree.js";
34
+ import { agentFence, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
35
+ import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
34
36
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
35
37
  /** The claude binary name — kept for the legacy quota rows and tests that
36
38
  * describe history; new code carries a resolved AgentSpec instead. */
@@ -227,73 +229,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
227
229
  if (startSessionId !== undefined) {
228
230
  store.stampRun(runId, { sessionId: startSessionId });
229
231
  }
230
- // The stamp precedes the spawn, so a crash between the two leaves a run
231
- // that claims spend which never happened — the honest direction. A spawn
232
- // before the stamp would leave spend no record claims, which is the lie
233
- // the invariant exists to rule out. For attested providers the probed
234
- // version rides the SAME durable write (B2).
235
- // The spawn leg of the runner gate (MCP spec v6, review finding 4):
236
- // the tuple re-proven against LIVE rows AFTER every awaited step, in
237
- // the same breath as the stamp — a takeover during the attestation
238
- // await can no longer slip a stale process through.
239
- // THE PLANNER'S ROUTE, RE-PROVED AT THE SPAWN (final authority closure):
240
- // the provenance the planner was admitted under is held to the authority
241
- // its task holds RIGHT NOW — the strict working projection of a filed
242
- // scope (exact terms, resolved profile, route parity, digest,
243
- // live auth mode), or the bare word `legacy` on a task with no scope. A
244
- // scope rewritten, corrupted, or unresolved between the claim and this
245
- // instant invokes no provider: a value-shaped refusal, no spend.
246
- if (run.role === "planner") {
247
- const proved = store.proveRouteForSpawn(runId, clock());
248
- if (!proved.ok) {
249
- return {
250
- kind: "refused",
251
- reason: "route-authority",
252
- providerVersion: attested === null ? null : attested.version,
253
- diagnostic: `the planner's route authority lapsed before spawn — ${proved.problem}`,
254
- };
255
- }
256
- }
257
- if (!store.proveRunnerCustodyForSpawn(runId, clock())) {
258
- return {
259
- kind: "refused",
260
- reason: "runner-custody",
261
- providerVersion: attested === null ? null : attested.version,
262
- diagnostic: "the run's runner custody lapsed before spawn — the lease, the runner, or its repo binding no longer stands",
263
- };
264
- }
265
- // THE STOP FENCE at the spawn (v52): a stop recorded against this run
266
- // — or the attempt it runs under — before this instant spawns nothing.
267
- // A value-shaped refusal, no stamp, no process, no spend; the road's
268
- // settlement seals the attempt as interrupted.
269
- const stopBeforeSpawn = store.applicableStopFor(runId);
270
- if (stopBeforeSpawn !== null) {
271
- return {
272
- kind: "refused",
273
- reason: "stopped",
274
- providerVersion: attested === null ? null : attested.version,
275
- diagnostic: `stopped by ${stopBeforeSpawn.requestedBy} (run #${stopBeforeSpawn.run}) before the provider spawned — nothing was spent`,
276
- };
277
- }
278
- // THE CONTAINMENT FENCE at the spawn (OS containment plan): a required
279
- // native policy this runner cannot meet — macOS, an undelegated cgroup,
280
- // a missing helper — spawns nothing. A value-shaped refusal in the
281
- // policy's own words, no stamp, no process, no spend.
282
- const containment = currentContainment();
283
- if (containment.refusal !== null) {
284
- return {
285
- kind: "refused",
286
- reason: "containment",
287
- providerVersion: attested === null ? null : attested.version,
288
- diagnostic: containment.refusal,
289
- };
290
- }
291
- if (attested !== null)
292
- store.stampProviderStart(runId, clock(), attested.version);
293
- else
294
- store.stampProviderStart(runId, clock());
295
- // The brief is the live session's first message, queued before the spawn writes it.
296
- liveInput?.begin(invocation.brief);
297
232
  const spawn = witnessedRunner(store, runId, clock, runner ?? adapter.defaultRunner);
298
233
  let transportAnnouncedSessionId = null;
299
234
  let transportSessionConflict = false;
@@ -304,23 +239,79 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
304
239
  // already ambient in the environment keeps working; the managed file,
305
240
  // being deliberate, wins.
306
241
  const isolatedDb = isolatedAgentDatabase(runId);
242
+ // Where the build runs: Claude reads the repository's CLAUDE.md from here.
243
+ const workspace = invocation.phase === "review" ? null : runOptions.cwd ?? run.worktree ?? null;
244
+ // How a tool server the broker starts is fenced: as its agent is, once that fence is fixed below.
245
+ let serverFence = null;
246
+ // The run's containment, fixed before any tool is prepared: this platform and the OS fence its run gets (a build
247
+ // has one wherever the plane has a database to fence). It decides which tools may hold secrets in a process the
248
+ // build could otherwise read; nothing the launch or the checkout writes feeds it.
249
+ const containment = { platform: process.platform, fence: invocation.phase === "review" ? "none" : fenceMethod(spec.provider, store.databaseFile() !== null) };
307
250
  // The project's tools (v80): exactly its MCP servers, and none of the
308
- // operator's global or a repository's own. A review keeps its isolation.
309
- const tools = invocation.phase === "review" ? null : runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld);
251
+ // operator's global or a repository's own, served by this launch's broker
252
+ // (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
253
+ const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
254
+ env: { ...process.env, ...(runOptions.env ?? {}) },
255
+ omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
256
+ pass: childDatabaseEnv(isolatedDb.file),
257
+ ...(workspace === null ? {} : { cwd: workspace }),
258
+ wrap: () => serverFence,
259
+ });
260
+ if (launchTools !== null && "refused" in launchTools) {
261
+ removeAgentDatabase(isolatedDb.dir);
262
+ return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: launchTools.refused };
263
+ }
264
+ const tools = launchTools;
265
+ const codexHarness = spec.provider === "codex" || spec.provider === "openrouter";
266
+ // A Codex build's own CODEX_HOME (provider-home.ts): the operator's sign-in and sessions, linked, and nothing else.
267
+ let codexHome = null;
268
+ if (codexHarness && invocation.phase !== "review") {
269
+ try {
270
+ codexHome = isolatedCodexHome(codexHomeDir({ ...process.env, ...(runOptions.env ?? {}) }, keyHome ?? homedir()));
271
+ }
272
+ catch {
273
+ tools?.cleanup();
274
+ removeAgentDatabase(isolatedDb.dir);
275
+ return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: "The build's private Codex home could not be created; check access to its sign-in and sessions folders, then retry." };
276
+ }
277
+ }
310
278
  // The agent fence: Toolroll's own secrets, database and other runs'
311
279
  // evidence stay out of reach, whatever the permission mode. A review is
312
280
  // already confined to its sealed files.
313
281
  // A build's fence also holds its task boundary: home configuration and
314
- // the repositories beside its own, fresh and resumed alike.
315
- const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [] } : runFence(store, runId, spec.provider, options.keyHome);
316
- const baseFence = fenceOf.fence;
317
- const toolsDir = (spec.provider === "codex" || spec.provider === "openrouter") && baseFence.length > 0 ? tools?.privateDir : undefined;
318
- const fence = toolsDir === undefined ? baseFence : [...baseFence, realpathSync(toolsDir)];
319
- // Where the build runs: Claude reads the repository's CLAUDE.md from here.
320
- const workspace = invocation.phase === "review" ? null : runOptions.cwd ?? run.worktree ?? null;
321
- const buildInvocation = { ...resolvedInvocation, ...(tools === null ? {} : { toolArgv: tools.argv, fence, fileToolFence: fenceOf.fileTools }), ...(workspace === null ? {} : { workspace }) };
282
+ // the repositories beside its own, fresh and resumed alike. Codex starts
283
+ // tool servers outside its command sandbox, so its own launch folder is
284
+ // fenced too; Claude reads its config from there, inside the sandbox.
285
+ const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
286
+ const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools);
287
+ if (fenceOf.problem !== undefined) {
288
+ tools?.cleanup();
289
+ codexHome?.cleanup();
290
+ removeAgentDatabase(isolatedDb.dir);
291
+ return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: fenceOf.problem };
292
+ }
293
+ const fence = [...fenceOf.fence, ...(fenceOf.fence.length > 0 && codexHarness ? [ownTools, codexHome === null ? null : realpathSync(codexHome.dir)].filter((path) => path !== null) : [])];
294
+ const grants = fenceOf.grants;
295
+ const inherited = spec.model === null && invocation.phase !== "review" ? inheritedDefaults(spec.provider, { ...process.env, ...(runOptions.env ?? {}) }, keyHome ?? homedir()) : undefined;
296
+ const buildInvocation = { ...resolvedInvocation, ...(tools === null ? {} : { toolArgv: tools.argv, fence, fenceGrants: grants, fileToolFence: fenceOf.fileTools }), ...(workspace === null ? {} : { workspace }), ...(inherited === undefined ? {} : { inherited }) };
322
297
  const launchArgv = tools === null ? argv : adapter.argv(buildInvocation);
323
298
  const fenced = fenceLaunch(spec.provider, fence);
299
+ // The tools were chosen for the fence decided above: a launch that got any other starts nothing.
300
+ // Only a prepared project-tool broker depends on the earlier decision. Empty Claude configs also have a privateDir.
301
+ if (tools?.serverPids !== undefined && fenced.method !== containment.fence) {
302
+ tools.cleanup();
303
+ codexHome?.cleanup();
304
+ removeAgentDatabase(isolatedDb.dir);
305
+ return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: "The build's fence changed while its tools were prepared. Nothing was started; retry the task." };
306
+ }
307
+ // A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
308
+ // profile fences only its agent's commands, and the broker starts servers outside it.
309
+ if (fence.length > 0) {
310
+ if (macosFenceAvailable())
311
+ serverFence = (file, args) => macosFenced(file, args, fence, grants);
312
+ else if (linuxFenceAvailable())
313
+ serverFence = (file, args) => linuxFenced(file, args, fence, grants);
314
+ }
324
315
  if (invocation.phase !== "review")
325
316
  store.recordRunFence(runId, { method: fenced.method, paths: fence.length }, clock());
326
317
  const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
@@ -329,87 +320,190 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
329
320
  const codexLogin = spec.provider === "codex" && authMode === "subscription" && runner === undefined
330
321
  ? await codexLoginBilling(attested !== null ? attested.executable : adapter.binary, [...(runOptions.omitEnv ?? []), ...adapter.extraOmitEnv, ...ownKeyEnv])
331
322
  : null;
323
+ const childEnv = {
324
+ ...(runOptions.env ?? {}),
325
+ ...(adapter.env?.(buildInvocation) ?? {}),
326
+ ...(tools?.env ?? {}),
327
+ // A fenced build's git never expires a reflog, so gc never rewrites another task's (agent-fence.ts).
328
+ ...(fence.length > 0 ? fencedGitEnv({ ...process.env, ...(runOptions.env ?? {}) }) : {}),
329
+ ...(codexHome === null ? {} : { CODEX_HOME: codexHome.dir }),
330
+ ...(managedKey === null ? {} : { [PROVIDER_KEY_ENV[spec.provider]]: managedKey }),
331
+ // This key is deliberately last: no caller may point an agent back at
332
+ // the live control database through a generic RunOptions override.
333
+ ...childDatabaseEnv(isolatedDb.file),
334
+ };
335
+ const childOmitEnv = [
336
+ ...(runOptions.omitEnv ?? []),
337
+ ...adapter.extraOmitEnv,
338
+ // A Claude build loads no plugin, from settings or the environment (a review's safe mode already loads none).
339
+ ...(spec.provider === "claude" && invocation.phase !== "review" ? claudePluginEnv({ ...process.env, ...(runOptions.env ?? {}) }) : []),
340
+ // Subscription mode: shed this provider's OWN key too, so an ambient
341
+ // one cannot force API billing over the login the operator prefers.
342
+ // Api-key mode: shed every OTHER own-key alias (finding 1) — gemini
343
+ // reads GOOGLE_API_KEY as well as GEMINI_API_KEY, and a stray alias
344
+ // must not override the ONE canonical key this mode injected.
345
+ ...(authMode === "subscription"
346
+ ? ownKeyEnv
347
+ : ownKeyEnv.filter(name => name !== PROVIDER_KEY_ENV[spec.provider])),
348
+ ];
349
+ const launchOptions = {
350
+ ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
351
+ ...runOptions,
352
+ ...(workspace === null ? {} : { cwd: workspace }),
353
+ // v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
354
+ onStreamEvent: event => {
355
+ if (event["type"] === "system" && event["subtype"] === "init") {
356
+ if (typeof event["apiKeySource"] === "string")
357
+ billingSeen.keySource = event["apiKeySource"].slice(0, 80);
358
+ if (typeof event["model"] === "string")
359
+ billingSeen.model = event["model"].slice(0, 200);
360
+ }
361
+ else if (event["type"] === "rate_limit_event") {
362
+ billingSeen.planWindows = true;
363
+ }
364
+ else if (event["type"] === "result" && event["is_error"] !== true && event["subtype"] === "success") {
365
+ billingSeen.answered = true;
366
+ }
367
+ runOptions.onStreamEvent?.(event);
368
+ },
369
+ owner: runOwnerTag(store, runId),
370
+ beforeSpawn: () => store.applicableStopFor(runId) === null && store.getRun(runId)?.outcome === null,
371
+ onSpawn: pid => {
372
+ runOptions.onSpawn?.(pid);
373
+ if (store.applicableStopFor(runId) !== null)
374
+ throw new Error("the attempt was stopped before spawn custody completed");
375
+ },
376
+ ...(stdin === undefined ? {} : { stdin }),
377
+ ...(liveInput === null ? {} : { liveInput }),
378
+ ...(hardTimeoutMs === undefined ? {} : { timeoutMs: hardTimeoutMs }),
379
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
380
+ ...(defaultTimeoutMs === undefined ? {} : { timeoutMs: defaultTimeoutMs }),
381
+ env: childEnv,
382
+ omitEnv: childOmitEnv,
383
+ // Providers run in their own process group (M6.12): the harness spawns
384
+ // shells and tools of its own, and both the timeout and the watch's
385
+ // hard stop must end the whole tree, not orphan the grandchildren.
386
+ processGroup: true,
387
+ // The session registry's crash guarantee (M6.9): the id is stamped the
388
+ // moment the stream announces it, first write wins — a daemon that
389
+ // dies mid-turn still knows which session to offer the successor.
390
+ onSessionId: id => {
391
+ const normalized = transportSessionId(id);
392
+ if (normalized === null)
393
+ return;
394
+ if (transportAnnouncedSessionId === null)
395
+ transportAnnouncedSessionId = normalized;
396
+ else if (transportAnnouncedSessionId !== normalized)
397
+ transportSessionConflict = true;
398
+ store.stampRun(runId, { sessionId: normalized });
399
+ },
400
+ };
332
401
  let result;
333
- const spawnedAt = Date.now();
402
+ let spawnedAt;
334
403
  try {
404
+ if (fence.length > 0 && workspace !== null && (codexHarness || launchOptions.fence !== undefined)) {
405
+ const inheritedEnv = launchOptions.envAllowlist === undefined ? process.env
406
+ : Object.fromEntries(launchOptions.envAllowlist.map(name => [name, process.env[name]]));
407
+ const probeOptions = {
408
+ cwd: workspace, env: checkoutGitEnv({ ...inheritedEnv, ...childEnv }), omitEnv: childOmitEnv, envAllowlist: [],
409
+ };
410
+ // A broken checkout is not evidence of a broken fence. No provider is invoked by either check.
411
+ const gitArgs = [...HARDENED_GIT_ARGS, "rev-parse", "HEAD"];
412
+ const control = await runCommand("git", gitArgs, probeOptions);
413
+ if (control.code === 0) {
414
+ const executable = attested !== null ? attested.executable : adapter.binary;
415
+ const insideFence = (file, args) => codexHarness
416
+ ? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
417
+ "-P", "standing-orders", "--", file, ...args], probeOptions)
418
+ : runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
419
+ ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
420
+ const check = await insideFence("git", gitArgs);
421
+ if (check.code !== 0) {
422
+ const trivial = await insideFence("true", []);
423
+ if (trivial.code === 0)
424
+ return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
425
+ diagnostic: "The build fence blocks this checkout's own Git data.\n\nDetails:\n" + check.stderr.slice(0, 2000) + "\nDenied paths:\n" + fence.join("\n").slice(0, 12000) };
426
+ }
427
+ }
428
+ }
429
+ // The stamp precedes the spawn, so a crash between the two leaves a run
430
+ // that claims spend which never happened — the honest direction. A spawn
431
+ // before the stamp would leave spend no record claims, which is the lie
432
+ // the invariant exists to rule out. For attested providers the probed
433
+ // version rides the SAME durable write (B2).
434
+ // The spawn leg of the runner gate (MCP spec v6, review finding 4):
435
+ // the tuple re-proven against LIVE rows AFTER every awaited step, in
436
+ // the same breath as the stamp — a takeover during the attestation
437
+ // await can no longer slip a stale process through.
438
+ // THE PLANNER'S ROUTE, RE-PROVED AT THE SPAWN (final authority closure):
439
+ // the provenance the planner was admitted under is held to the authority
440
+ // its task holds RIGHT NOW — the strict working projection of a filed
441
+ // scope (exact terms, resolved profile, route parity, digest,
442
+ // live auth mode), or the bare word `legacy` on a task with no scope. A
443
+ // scope rewritten, corrupted, or unresolved between the claim and this
444
+ // instant invokes no provider: a value-shaped refusal, no spend.
445
+ if (run.role === "planner") {
446
+ const proved = store.proveRouteForSpawn(runId, clock());
447
+ if (!proved.ok) {
448
+ return {
449
+ kind: "refused",
450
+ reason: "route-authority",
451
+ providerVersion: attested === null ? null : attested.version,
452
+ diagnostic: `the planner's route authority lapsed before spawn — ${proved.problem}`,
453
+ };
454
+ }
455
+ }
456
+ if (!store.proveRunnerCustodyForSpawn(runId, clock())) {
457
+ return {
458
+ kind: "refused",
459
+ reason: "runner-custody",
460
+ providerVersion: attested === null ? null : attested.version,
461
+ diagnostic: "the run's runner custody lapsed before spawn — the lease, the runner, or its repo binding no longer stands",
462
+ };
463
+ }
464
+ // THE STOP FENCE at the spawn (v52): a stop recorded against this run
465
+ // — or the attempt it runs under — before this instant spawns nothing.
466
+ // A value-shaped refusal, no stamp, no process, no spend; the road's
467
+ // settlement seals the attempt as interrupted.
468
+ const stopBeforeSpawn = store.applicableStopFor(runId);
469
+ if (stopBeforeSpawn !== null) {
470
+ return {
471
+ kind: "refused",
472
+ reason: "stopped",
473
+ providerVersion: attested === null ? null : attested.version,
474
+ diagnostic: `stopped by ${stopBeforeSpawn.requestedBy} (run #${stopBeforeSpawn.run}) before the provider spawned — nothing was spent`,
475
+ };
476
+ }
477
+ // THE CONTAINMENT FENCE at the spawn (OS containment plan): a required
478
+ // native policy this runner cannot meet — macOS, an undelegated cgroup,
479
+ // a missing helper — spawns nothing. A value-shaped refusal in the
480
+ // policy's own words, no stamp, no process, no spend.
481
+ const containment = currentContainment();
482
+ if (containment.refusal !== null) {
483
+ return {
484
+ kind: "refused",
485
+ reason: "containment",
486
+ providerVersion: attested === null ? null : attested.version,
487
+ diagnostic: containment.refusal,
488
+ };
489
+ }
490
+ if (attested !== null)
491
+ store.stampProviderStart(runId, clock(), attested.version);
492
+ else
493
+ store.stampProviderStart(runId, clock());
494
+ // The brief is queued only once the checkout has passed the fence check.
495
+ liveInput?.begin(invocation.brief);
496
+ spawnedAt = Date.now();
335
497
  // Under the stop watch (v52): the run's stop row is re-read while the
336
498
  // provider runs, and a stop kills THIS run's process group through the
337
499
  // handle registered under its owner tag — the exact-attempt road,
338
500
  // never the global sweep. Settlement then reads the same row.
339
- result = await underStopWatch(store, runId, () => spawn(attested !== null ? attested.executable : adapter.binary, launchArgv, {
340
- ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap }),
341
- ...runOptions,
342
- // v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
343
- onStreamEvent: event => {
344
- if (event["type"] === "system" && event["subtype"] === "init") {
345
- if (typeof event["apiKeySource"] === "string")
346
- billingSeen.keySource = event["apiKeySource"].slice(0, 80);
347
- if (typeof event["model"] === "string")
348
- billingSeen.model = event["model"].slice(0, 200);
349
- }
350
- else if (event["type"] === "rate_limit_event") {
351
- billingSeen.planWindows = true;
352
- }
353
- else if (event["type"] === "result" && event["is_error"] !== true && event["subtype"] === "success") {
354
- billingSeen.answered = true;
355
- }
356
- runOptions.onStreamEvent?.(event);
357
- },
358
- owner: runOwnerTag(store, runId),
359
- beforeSpawn: () => store.applicableStopFor(runId) === null && store.getRun(runId)?.outcome === null,
360
- onSpawn: pid => {
361
- runOptions.onSpawn?.(pid);
362
- if (store.applicableStopFor(runId) !== null)
363
- throw new Error("the attempt was stopped before spawn custody completed");
364
- },
365
- ...(stdin === undefined ? {} : { stdin }),
366
- ...(liveInput === null ? {} : { liveInput }),
367
- ...(hardTimeoutMs === undefined ? {} : { timeoutMs: hardTimeoutMs }),
368
- ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
369
- ...(defaultTimeoutMs === undefined ? {} : { timeoutMs: defaultTimeoutMs }),
370
- env: {
371
- ...(runOptions.env ?? {}),
372
- ...(adapter.env?.(buildInvocation) ?? {}),
373
- ...(tools?.env ?? {}),
374
- ...(managedKey === null ? {} : { [PROVIDER_KEY_ENV[spec.provider]]: managedKey }),
375
- // This key is deliberately last: no caller may point an agent back at
376
- // the live control database through a generic RunOptions override.
377
- ...childDatabaseEnv(isolatedDb.file),
378
- },
379
- omitEnv: [
380
- ...(runOptions.omitEnv ?? []),
381
- ...adapter.extraOmitEnv,
382
- // Subscription mode: shed this provider's OWN key too, so an ambient
383
- // one cannot force API billing over the login the operator prefers.
384
- // Api-key mode: shed every OTHER own-key alias (finding 1) — gemini
385
- // reads GOOGLE_API_KEY as well as GEMINI_API_KEY, and a stray alias
386
- // must not override the ONE canonical key this mode injected.
387
- ...(authMode === "subscription"
388
- ? ownKeyEnv
389
- : ownKeyEnv.filter(name => name !== PROVIDER_KEY_ENV[spec.provider])),
390
- ],
391
- // Providers run in their own process group (M6.12): the harness spawns
392
- // shells and tools of its own, and both the timeout and the watch's
393
- // hard stop must end the whole tree, not orphan the grandchildren.
394
- processGroup: true,
395
- // The session registry's crash guarantee (M6.9): the id is stamped the
396
- // moment the stream announces it, first write wins — a daemon that
397
- // dies mid-turn still knows which session to offer the successor.
398
- onSessionId: id => {
399
- const normalized = transportSessionId(id);
400
- if (normalized === null)
401
- return;
402
- if (transportAnnouncedSessionId === null)
403
- transportAnnouncedSessionId = normalized;
404
- else if (transportAnnouncedSessionId !== normalized)
405
- transportSessionConflict = true;
406
- store.stampRun(runId, { sessionId: normalized });
407
- },
408
- }));
501
+ result = await underStopWatch(store, runId, () => spawn(attested !== null ? attested.executable : adapter.binary, launchArgv, launchOptions));
409
502
  }
410
503
  finally {
411
504
  removeAgentDatabase(isolatedDb.dir);
412
505
  tools?.cleanup();
506
+ codexHome?.cleanup();
413
507
  }
414
508
  // v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
415
509
  // what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
@@ -427,6 +521,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
427
521
  // before it starts: nothing ran, and the run says what to update.
428
522
  const unsupported = result.code !== 0 && envelope.initObserved !== true && invocation.phase !== "review" ? isolationUnsupported(spec.provider, result.stderr) : null;
429
523
  if (unsupported !== null) {
524
+ // The CLI refused its flags before any model turn: the run never started, whatever the stamp said.
525
+ store.withdrawProviderStart(runId);
430
526
  return { kind: "refused", reason: "provider-unattested", providerVersion: attested === null ? null : attested.version, diagnostic: unsupported };
431
527
  }
432
528
  // The callback is the crash-safe early stamp; the parsed envelope is the
@@ -608,44 +704,80 @@ function usageOf(raw) {
608
704
  return {};
609
705
  }
610
706
  }
707
+ /** What the plane recorded of a checkout's setup: its project and the setup stamped on it (null: none recorded). */
708
+ function linkedFor(store, worktree) {
709
+ const row = worktree === null ? null : store.getWorktree(worktree);
710
+ return row === null ? null : { repo: row.repo, setupDigest: row.setupDigest ?? null };
711
+ }
611
712
  /** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
612
- * ~/.toolroll (or its older name), and its task boundary; plus what only Claude's file tools refuse. */
613
- function runFence(store, runId, provider, keyHome) {
713
+ * ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
714
+ * (FenceGrants); and what only Claude's file tools refuse. */
715
+ function runFence(store, runId, provider, keyHome, ownTools) {
614
716
  try {
615
717
  // A plane always has a database file; an in-memory store (a test) has nothing to fence.
616
718
  const databaseFile = store.databaseFile();
617
719
  if (databaseFile === null)
618
- return { fence: [], fileTools: [] };
720
+ return { fence: [], fileTools: [], grants: NO_GRANTS };
619
721
  const worktree = store.getRun(runId)?.worktree ?? null;
620
722
  const home = keyHome === undefined ? {} : { home: keyHome };
621
- const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home });
622
- return { fence: agentFence({ databaseFile, worktree, ...home, boundary }), fileTools: boundary?.fileTools ?? [] };
723
+ const row = worktree === null ? null : store.getWorktree(worktree);
724
+ if (worktree !== null && row === null)
725
+ throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
726
+ const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
727
+ const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
728
+ const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
729
+ const folders = runFolders(ownTools);
730
+ return {
731
+ fence: agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders }),
732
+ fileTools: boundary?.fileTools ?? [],
733
+ // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
734
+ // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
735
+ // checkout was recorded in (a --pool elsewhere), so another project's worktree, present at launch, is out of reach. Native wrappers also cover later creations.
736
+ grants: fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home,
737
+ poolRoots }),
738
+ };
623
739
  }
624
- catch {
625
- return { fence: [], fileTools: [] };
740
+ catch (error) {
741
+ return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
626
742
  }
627
743
  }
744
+ /** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
745
+ function inheritedDefaults(provider, env, home) {
746
+ if (provider === "claude")
747
+ return claudeDefaults(claudeConfigDir(env, home));
748
+ if (provider === "codex")
749
+ return codexDefaults(codexHomeDir(env, home));
750
+ return undefined;
751
+ }
628
752
  /** How the fence reaches a provider: Codex through its own sandbox profile
629
753
  * (already in its argv; wrapping it would nest sandboxes), Claude and
630
754
  * Gemini inside the macOS sandbox the spawn road applies (`wrap`), Claude
631
755
  * elsewhere through its own file-tool rules only. */
632
756
  function fenceLaunch(provider, fence) {
633
- if (fence.length === 0)
634
- return { wrap: [], method: "none" };
757
+ const method = fenceMethod(provider, fence.length > 0);
758
+ return { wrap: method === "macos-sandbox" || method === "linux-bubblewrap" ? fence : [], method };
759
+ }
760
+ /** The fence a launch gets, from its provider, whether it has anything to fence, and what this platform supports. */
761
+ function fenceMethod(provider, fenced) {
762
+ if (!fenced)
763
+ return "none";
635
764
  if (provider === "codex" || provider === "openrouter")
636
- return { wrap: [], method: "codex-profile" };
765
+ return "codex-profile";
637
766
  if (macosFenceAvailable())
638
- return { wrap: fence, method: "macos-sandbox" };
767
+ return "macos-sandbox";
639
768
  if (linuxFenceAvailable())
640
- return { wrap: fence, method: "linux-bubblewrap" };
641
- return { wrap: [], method: provider === "claude" ? "claude-rules" : "none" };
769
+ return "linux-bubblewrap";
770
+ return provider === "claude" ? "claude-rules" : "none";
642
771
  }
643
772
  /** One launch's tools, or — when they cannot be prepared — the same isolation with none, never the operator's global servers. */
644
- function runTools(store, runId, spec, keyHome, clock, readOnly, withheld) {
773
+ async function runTools(store, runId, spec, keyHome, clock, readOnly, withheld, containment, broker) {
645
774
  try {
646
- return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), ...(readOnly === undefined ? {} : { readOnly }), ...(withheld === undefined ? {} : { withheld }) });
775
+ return await prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), containment, ...(readOnly === undefined ? {} : { readOnly }), ...(withheld === undefined ? {} : { withheld }), broker });
647
776
  }
648
- catch {
777
+ catch (error) {
778
+ // Sign-ins made for this run couldn't all be kept (and were revoked): it doesn't start.
779
+ if (error instanceof RunSignInError)
780
+ return { refused: error.message };
649
781
  return noToolsArgs(spec.provider);
650
782
  }
651
783
  }