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.
- package/CHANGELOG.md +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- 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 {
|
|
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
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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),
|
|
613
|
-
|
|
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
|
|
622
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
|
765
|
+
return "codex-profile";
|
|
637
766
|
if (macosFenceAvailable())
|
|
638
|
-
return
|
|
767
|
+
return "macos-sandbox";
|
|
639
768
|
if (linuxFenceAvailable())
|
|
640
|
-
return
|
|
641
|
-
return
|
|
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
|
}
|