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.
- package/CHANGELOG.md +80 -0
- package/README.md +66 -8
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +203 -10
- package/dist/agent-fence.js +623 -41
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +36 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +263 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +6 -1
- package/dist/builder.js +34 -13
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +199 -17
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +20 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +5 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +24 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +30 -4
- package/dist/contracts/console-api.js +13 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/discord-chat.js +4 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +81 -0
- package/dist/exec.js +368 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +35 -11
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +84 -11
- package/dist/flow-triggers.js +554 -87
- 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 +63 -12
- package/dist/flows-ui.d.ts +2 -3
- package/dist/flows-ui.js +56 -5
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- 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.d.ts +17 -0
- package/dist/invoke.js +510 -182
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/landed-cleanup.d.ts +3 -2
- package/dist/landed-cleanup.js +12 -5
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +163 -4
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +258 -0
- package/dist/live-terminal.js +649 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +282 -18
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- 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-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +41 -9
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +12 -6
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +103 -27
- package/dist/project-tools.js +399 -100
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +42 -5
- package/dist/provider.js +97 -19
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/remote-main.d.ts +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +171 -6
- package/dist/scope.js +251 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +258 -43
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- package/dist/server/flows.js +65 -13
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +4 -1
- package/dist/server/render-pages.js +5 -4
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +110 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +49 -5
- package/dist/server/runtime.d.ts +34 -3
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +132 -13
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +320 -74
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +12 -5
- package/dist/storage-sweep-process.js +23 -6
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +466 -19
- package/dist/store.js +1185 -63
- package/dist/subagent-desk.js +1 -1
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/summary.js +3 -0
- package/dist/surface.js +6 -4
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +39 -0
- package/dist/tool-broker.js +370 -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-ui.js +2 -2
- package/dist/toolroll-update.js +14 -3
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +19 -10
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +21 -5
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +10 -4
- package/dist/worktree.js +70 -29
- package/package.json +13 -2
- package/scripts/provider-canary.mjs +210 -31
package/dist/invoke.js
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
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
|
+
import { ACP_AGENTS, isAcpProvider, isKeyedProvider } from "./acp-agents.js";
|
|
22
|
+
import { launchFromJson, verifyLaunch } from "./acp-attest.js";
|
|
21
23
|
import { classifyTerminal } from "./exhaustion.js";
|
|
22
24
|
import { liftAuthPause } from "./provider-auth.js";
|
|
23
25
|
import { attestProvider } from "./attest.js";
|
|
@@ -26,11 +28,16 @@ import { currentContainment } from "./containment.js";
|
|
|
26
28
|
import { witnessedRunner } from "./process-custody.js";
|
|
27
29
|
import { underStopWatch } from "./task-control.js";
|
|
28
30
|
import { childDatabaseEnv, isolatedChildDatabase, removeChildDatabase as removeAgentDatabase } from "./child-database.js";
|
|
29
|
-
import { noToolsArgs, prepareRunTools } from "./project-tools.js";
|
|
30
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
31
|
+
import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
|
|
32
|
+
import { mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
31
33
|
import { homedir } from "node:os";
|
|
32
|
-
import { join } from "node:path";
|
|
33
|
-
import {
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import { poolRootOf } from "./worktree.js";
|
|
36
|
+
import { BRIDGE_DIR } from "./agent-fence.js";
|
|
37
|
+
import { agentFence, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
|
|
38
|
+
import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
|
|
39
|
+
import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
|
|
40
|
+
import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
|
|
34
41
|
import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
|
|
35
42
|
/** The claude binary name — kept for the legacy quota rows and tests that
|
|
36
43
|
* describe history; new code carries a resolved AgentSpec instead. */
|
|
@@ -98,13 +105,6 @@ function transportSessionId(id) {
|
|
|
98
105
|
const normalized = id.trim();
|
|
99
106
|
return normalized === "" ? null : normalized;
|
|
100
107
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Spend money, on the record. Throws — never refuses quietly — when the run
|
|
103
|
-
* is missing, already finished, or recorded against a different provider:
|
|
104
|
-
* a caller that reaches for a harness without a matching open run is a
|
|
105
|
-
* bug, not a case. Attestation and protocol refusals are VALUES (above),
|
|
106
|
-
* not throws — those are cases, and they must dispose cleanly.
|
|
107
|
-
*/
|
|
108
108
|
export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
109
109
|
const clock = options.clock ?? (() => new Date());
|
|
110
110
|
if (options.accumulateUsage && (invocation.phase !== "review" || invocation.resumeSession === null)) {
|
|
@@ -173,7 +173,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
173
173
|
// Tier-2 attestation (A2/B3): the authoritative check, immediately
|
|
174
174
|
// before the spawn it authorizes. Tier-1 providers return null here and
|
|
175
175
|
// keep their road byte-identical.
|
|
176
|
-
|
|
176
|
+
// An ACP agent is admitted by its own record (acp-attest.ts), read below; the version ranges are for the others.
|
|
177
|
+
const attested = isAcpProvider(spec.provider) ? null : await attestProvider(spec.provider, adapter.binary, options.versionProbe);
|
|
177
178
|
if (attested !== null && !attested.ok) {
|
|
178
179
|
if (attested.version !== null)
|
|
179
180
|
store.stampProviderVersion(runId, attested.version);
|
|
@@ -184,13 +185,61 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
184
185
|
diagnostic: attested.problem,
|
|
185
186
|
};
|
|
186
187
|
}
|
|
188
|
+
// AN ACP AGENT'S ADMISSION (docs/plans/acp-adapter.md § Admission): a passed conformance check of the exact binary
|
|
189
|
+
// on this runner, or nothing runs — absence is denial. It builds and repairs only, on a model it offered when it was
|
|
190
|
+
// checked, and no dollar cap rides it (it can't hold one; the scheduler refuses capped work first, this is the belt).
|
|
191
|
+
// An ACP spawn is admitted by its agent's attestation on this runner — or, for the conformance check alone, by the
|
|
192
|
+
// check request that will create it (acpCheck: set only by the check job). The two never stand in for each other.
|
|
193
|
+
const acpCheckAsk = options.acpCheck ?? null;
|
|
194
|
+
const acpAttestation = isAcpProvider(spec.provider) && acpCheckAsk === null ? store.acpAttestationFor(spec.provider, run.runner) : null;
|
|
195
|
+
const acpCheckRequest = isAcpProvider(spec.provider) && acpCheckAsk !== null ? store.acpCheckAdmitting(acpCheckAsk.requestId, acpCheckAsk.step, runId, clock()) : null;
|
|
196
|
+
const acpCheckLaunch = acpCheckRequest?.launchJson == null ? null : launchFromJson(acpCheckRequest.launchJson);
|
|
197
|
+
/** The plane's own isolation reading for a check's first step (below, before spawn); null when none was asked. */
|
|
198
|
+
let acpIsolation = null;
|
|
199
|
+
/** What starts the agent, and who the handshake must say it is (unknown, for a check: the check learns it). */
|
|
200
|
+
const acpLaunch = acpAttestation !== null ? acpAttestation.launch : acpCheckLaunch;
|
|
201
|
+
if (!isAcpProvider(spec.provider) && acpCheckAsk !== null)
|
|
202
|
+
throw new Error("only an ACP agent is checked");
|
|
203
|
+
if (isAcpProvider(spec.provider)) {
|
|
204
|
+
const name = ACP_AGENTS[spec.provider].name;
|
|
205
|
+
const refuse = (diagnostic) => ({ kind: "refused", reason: "provider-unattested", providerVersion: acpAttestation?.agent.version ?? null, diagnostic });
|
|
206
|
+
if (acpCheckAsk !== null) {
|
|
207
|
+
if (acpCheckRequest === null || acpCheckLaunch === null)
|
|
208
|
+
return refuse(`this check isn't running, has expired, lost its watch, or isn't this step's run`);
|
|
209
|
+
if (acpCheckRequest.provider !== spec.provider || acpCheckRequest.runner !== run.runner || acpCheckRequest.model !== spec.model || acpCheckRequest.taskRef !== run.taskRef) {
|
|
210
|
+
return refuse("this run isn't the check's: another agent, runner, model or task");
|
|
211
|
+
}
|
|
212
|
+
if (invocation.phase !== "build")
|
|
213
|
+
return refuse(`${name}'s check runs as a build`);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
if (acpAttestation === null)
|
|
217
|
+
return refuse(`${name} isn't checked on ${run.runner} — run \`toolroll agents check ${spec.provider}\` there`);
|
|
218
|
+
if (invocation.phase !== "build" && invocation.phase !== "repair")
|
|
219
|
+
return refuse(`${name} builds and repairs only`);
|
|
220
|
+
if (spec.model === null || !acpAttestation.models.includes(spec.model))
|
|
221
|
+
return refuse(`${name} didn't offer ${spec.model ?? "a model"} when it was checked — check it again, or choose one it offered`);
|
|
222
|
+
}
|
|
223
|
+
if (invocation.maxBudgetUsd !== undefined)
|
|
224
|
+
return refuse(`${name} can't hold a dollar cap, so it doesn't run capped work (budget-unenforceable)`);
|
|
225
|
+
// The billing the run was admitted under must be for the model about to be chosen.
|
|
226
|
+
const stamp = store.acpBillingOfRun(runId);
|
|
227
|
+
if (stamp !== null && stamp.model !== spec.model)
|
|
228
|
+
return refuse(`this run was admitted to bill ${stamp.model}, not ${spec.model}`);
|
|
229
|
+
// And the money moment holds it to the hard monthly budgets as stamped — immutable since admission, so a
|
|
230
|
+
// declaration changed between a gate and this spawn changes nothing: metered work a budget holds never starts.
|
|
231
|
+
const billed = stamp?.kind === "plan" ? "subscription" : "api-key";
|
|
232
|
+
const held = store.budgetGate(clock())({ ...store.budgetSubject(run.taskRef), agents: [{ provider: spec.provider, billing: billed }] });
|
|
233
|
+
if (held.over !== null)
|
|
234
|
+
return refuse(`a monthly budget holds metered work, and ${name} on ${spec.model ?? "this model"} is metered here (budget)`);
|
|
235
|
+
}
|
|
187
236
|
// Auth mode decides how the credential reaches the process (the operator
|
|
188
237
|
// keeps BOTH and prefers the subscription): in "api-key" mode the
|
|
189
238
|
// managed file wins, the ambient env is a real fallback (explicit
|
|
190
239
|
// re-supply survives resolveChildEnv's chat-key strip); in
|
|
191
240
|
// "subscription" mode the key is STRIPPED from the child so the CLI's
|
|
192
241
|
// own login is used — its key is retained, just not handed over.
|
|
193
|
-
const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, ...runOptions } = options;
|
|
242
|
+
const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, ...runOptions } = options;
|
|
194
243
|
// The live setting is read STRICTLY (atomic authority closure): a
|
|
195
244
|
// present mode file that says neither word is a value-shaped refusal
|
|
196
245
|
// before any stamp or process — never the subscription default a
|
|
@@ -211,9 +260,12 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
211
260
|
if (problem !== null)
|
|
212
261
|
return { kind: "refused", reason: "auth-mode", providerVersion: attested?.version ?? null, diagnostic: problem };
|
|
213
262
|
}
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
263
|
+
// An ACP agent has no key Toolroll holds or injects: its own and everyone else's are shed (adapter.extraOmitEnv).
|
|
264
|
+
const keyed = isKeyedProvider(spec.provider) ? spec.provider : null;
|
|
265
|
+
const ownKeyEnv = keyed === null ? [] : OWN_KEY_ENV[keyed];
|
|
266
|
+
const keyEnvName = keyed === null ? null : PROVIDER_KEY_ENV[keyed];
|
|
267
|
+
const managedKey = authMode === "api-key" && keyed !== null && keyEnvName !== null
|
|
268
|
+
? readProviderKey(keyed, keyHome) ?? (process.env[keyEnvName] || null)
|
|
217
269
|
: null;
|
|
218
270
|
// Resume XOR mint, enforced at the GATEWAY (Codex gemini verify round 2,
|
|
219
271
|
// finding 3): a resume and a minted start id are mutually exclusive —
|
|
@@ -227,73 +279,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
227
279
|
if (startSessionId !== undefined) {
|
|
228
280
|
store.stampRun(runId, { sessionId: startSessionId });
|
|
229
281
|
}
|
|
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
282
|
const spawn = witnessedRunner(store, runId, clock, runner ?? adapter.defaultRunner);
|
|
298
283
|
let transportAnnouncedSessionId = null;
|
|
299
284
|
let transportSessionConflict = false;
|
|
@@ -304,23 +289,153 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
304
289
|
// already ambient in the environment keeps working; the managed file,
|
|
305
290
|
// being deliberate, wins.
|
|
306
291
|
const isolatedDb = isolatedAgentDatabase(runId);
|
|
292
|
+
// Where the build runs: Claude reads the repository's CLAUDE.md from here.
|
|
293
|
+
const workspace = invocation.phase === "review" ? null : runOptions.cwd ?? run.worktree ?? null;
|
|
294
|
+
// How a tool server the broker starts is fenced: as its agent is, once that fence is fixed below.
|
|
295
|
+
let serverFence = null;
|
|
296
|
+
// The run's containment, fixed before any tool is prepared: this platform and the OS fence its run gets (a build
|
|
297
|
+
// has one wherever the plane has a database to fence). It decides which tools may hold secrets in a process the
|
|
298
|
+
// build could otherwise read; nothing the launch or the checkout writes feeds it.
|
|
299
|
+
const containment = { platform: process.platform, fence: invocation.phase === "review" ? "none" : fenceMethod(spec.provider, store.databaseFile() !== null) };
|
|
307
300
|
// The project's tools (v80): exactly its MCP servers, and none of the
|
|
308
|
-
// operator's global or a repository's own
|
|
309
|
-
|
|
301
|
+
// operator's global or a repository's own, served by this launch's broker
|
|
302
|
+
// (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
|
|
303
|
+
const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
|
|
304
|
+
env: { ...process.env, ...(runOptions.env ?? {}) },
|
|
305
|
+
omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
|
|
306
|
+
pass: childDatabaseEnv(isolatedDb.file),
|
|
307
|
+
...(workspace === null ? {} : { cwd: workspace }),
|
|
308
|
+
wrap: () => serverFence,
|
|
309
|
+
});
|
|
310
|
+
if (launchTools !== null && "refused" in launchTools) {
|
|
311
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
312
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: launchTools.refused };
|
|
313
|
+
}
|
|
314
|
+
const tools = launchTools;
|
|
315
|
+
const codexHarness = spec.provider === "codex" || spec.provider === "openrouter";
|
|
316
|
+
// A Codex build's own CODEX_HOME (provider-home.ts): the operator's sign-in and sessions, linked, and nothing else.
|
|
317
|
+
let codexHome = null;
|
|
318
|
+
if (codexHarness && invocation.phase !== "review") {
|
|
319
|
+
try {
|
|
320
|
+
codexHome = isolatedCodexHome(codexHomeDir({ ...process.env, ...(runOptions.env ?? {}) }, keyHome ?? homedir()));
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
tools?.cleanup();
|
|
324
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
325
|
+
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." };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
310
328
|
// The agent fence: Toolroll's own secrets, database and other runs'
|
|
311
329
|
// evidence stay out of reach, whatever the permission mode. A review is
|
|
312
330
|
// already confined to its sealed files.
|
|
313
331
|
// 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
|
-
|
|
332
|
+
// the repositories beside its own, fresh and resumed alike. Codex starts
|
|
333
|
+
// tool servers outside its command sandbox, so its own launch folder is
|
|
334
|
+
// fenced too; Claude reads its config from there, inside the sandbox.
|
|
335
|
+
const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
|
|
336
|
+
const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools);
|
|
337
|
+
if (fenceOf.problem !== undefined) {
|
|
338
|
+
tools?.cleanup();
|
|
339
|
+
codexHome?.cleanup();
|
|
340
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
341
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: fenceOf.problem };
|
|
342
|
+
}
|
|
343
|
+
const fence = [...fenceOf.fence, ...(fenceOf.fence.length > 0 && codexHarness ? [ownTools, codexHome === null ? null : realpathSync(codexHome.dir)].filter((path) => path !== null) : [])];
|
|
344
|
+
const grants = { ...fenceOf.grants };
|
|
345
|
+
// The run's network (egress.ts): sealed by its approval, or the provider's hosts alone, or the caller's own proxy.
|
|
346
|
+
const network = launchNetwork ?? { policy: providerOnlyNetwork(spec.provider) };
|
|
347
|
+
// Codex's command sandbox has no proxy (its argv is fixed just below): its commands are online only for a sealed
|
|
348
|
+
// open network, and offline otherwise.
|
|
349
|
+
if (fence.length > 0 && codexHarness) {
|
|
350
|
+
grants.network = "policy" in network && network.policy.mode === "open" ? { mode: "open" }
|
|
351
|
+
: { mode: "allowlist", proxyPort: 0, brokerPorts: [], loopback: "proxy-only", socketRoots: [] };
|
|
352
|
+
}
|
|
353
|
+
const inherited = spec.model === null && invocation.phase !== "review" ? inheritedDefaults(spec.provider, { ...process.env, ...(runOptions.env ?? {}) }, keyHome ?? homedir()) : undefined;
|
|
354
|
+
const buildInvocation = { ...resolvedInvocation, ...(tools === null ? {} : { toolArgv: tools.argv, fence, fenceGrants: grants, fileToolFence: fenceOf.fileTools }), ...(workspace === null ? {} : { workspace }), ...(inherited === undefined ? {} : { inherited }) };
|
|
322
355
|
const launchArgv = tools === null ? argv : adapter.argv(buildInvocation);
|
|
323
356
|
const fenced = fenceLaunch(spec.provider, fence);
|
|
357
|
+
// An ACP agent runs only inside the OS fence: nothing else holds it (it may run commands without asking).
|
|
358
|
+
if (isAcpProvider(spec.provider) && fenced.method !== "macos-sandbox" && fenced.method !== "linux-bubblewrap") {
|
|
359
|
+
tools?.cleanup();
|
|
360
|
+
codexHome?.cleanup();
|
|
361
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
362
|
+
return { kind: "refused", reason: "containment", providerVersion: acpAttestation?.agent.version ?? null, diagnostic: `${ACP_AGENTS[spec.provider].name} runs only inside the OS fence (the macOS sandbox or bubblewrap), and this launch has neither (fence-unavailable)` };
|
|
363
|
+
}
|
|
364
|
+
// The tools were chosen for the fence decided above: a launch that got any other starts nothing.
|
|
365
|
+
// Only a prepared project-tool broker depends on the earlier decision. Empty Claude configs also have a privateDir.
|
|
366
|
+
if (tools?.serverPids !== undefined && fenced.method !== containment.fence) {
|
|
367
|
+
tools.cleanup();
|
|
368
|
+
codexHome?.cleanup();
|
|
369
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
370
|
+
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." };
|
|
371
|
+
}
|
|
372
|
+
// A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
|
|
373
|
+
// profile fences only its agent's commands, and the broker starts servers outside it.
|
|
374
|
+
if (fence.length > 0) {
|
|
375
|
+
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network.
|
|
376
|
+
const serverGrants = () => { const { network: _agentOnly, ...rest } = grants; return rest; };
|
|
377
|
+
if (macosFenceAvailable())
|
|
378
|
+
serverFence = (file, args) => macosFenced(file, args, fence, serverGrants());
|
|
379
|
+
else if (linuxFenceAvailable())
|
|
380
|
+
serverFence = (file, args) => linuxFenced(file, args, fence, serverGrants());
|
|
381
|
+
}
|
|
382
|
+
// No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
|
|
383
|
+
// rules, Gemini to nothing — doesn't start unless the instance operator allowed unconfined agents here. An injected
|
|
384
|
+
// runner (tests) starts no real agent.
|
|
385
|
+
const unconfined = fence.length > 0 && (fenced.method === "claude-rules" || fenced.method === "none");
|
|
386
|
+
if (unconfined && runner === undefined && !store.unfencedAgentsAllowed()) {
|
|
387
|
+
tools?.cleanup();
|
|
388
|
+
codexHome?.cleanup();
|
|
389
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
390
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
391
|
+
diagnostic: "This machine can't confine an agent (no macOS sandbox, no working bubblewrap), so nothing was started. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`." };
|
|
392
|
+
}
|
|
393
|
+
let egress = null;
|
|
394
|
+
let bridge = null;
|
|
395
|
+
let runTemp = null;
|
|
396
|
+
const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
|
|
397
|
+
if (fence.length > 0 && !codexHarness && confinedOs) {
|
|
398
|
+
const brokerPorts = tools?.brokerPort === undefined ? [] : [tools.brokerPort];
|
|
399
|
+
// Unix sockets only under this run's own folders: its checkout, and a temp folder of its own inside its private
|
|
400
|
+
// launch folder (the system temp folder holds other programs' sockets — editors, agents, password managers).
|
|
401
|
+
if (ownTools !== null) {
|
|
402
|
+
runTemp = join(ownTools, "tmp");
|
|
403
|
+
mkdirSync(runTemp, { recursive: true, mode: 0o700 });
|
|
404
|
+
}
|
|
405
|
+
const socketRoots = [...(runTemp === null ? [] : [runTemp]), ...(workspace === null ? [] : [realpathSync(workspace)])];
|
|
406
|
+
try {
|
|
407
|
+
if ("via" in network) {
|
|
408
|
+
const [proxyPort, ...more] = network.via;
|
|
409
|
+
grants.network = { mode: "allowlist", proxyPort: proxyPort ?? 0, brokerPorts: [...more, ...brokerPorts], loopback: "proxy-only", socketRoots };
|
|
410
|
+
}
|
|
411
|
+
else if (network.policy.mode === "open") {
|
|
412
|
+
grants.network = { mode: "open" };
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
egress = await startEgressProxy({ hosts: network.policy.hosts });
|
|
416
|
+
grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots };
|
|
417
|
+
}
|
|
418
|
+
if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist") {
|
|
419
|
+
const database = store.databaseFile();
|
|
420
|
+
// The plane's own fenced folder, unless a socket there would be too long a path (a unix socket's path is capped
|
|
421
|
+
// near 100 bytes): then the system temp folder, which a confined Linux build doesn't see (its /tmp is its own).
|
|
422
|
+
const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
|
|
423
|
+
bridge = await startEgressBridge([grants.network.proxyPort, ...grants.network.brokerPorts], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
|
|
424
|
+
grants.network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
|
|
425
|
+
// Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
|
|
426
|
+
grants.network.keep = [...(workspace === null ? [] : [realpathSync(workspace)]), ...(ownTools === null ? [] : [ownTools]), ...(fenceOf.gitPaths ?? [])];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
await egress?.close();
|
|
431
|
+
await bridge?.close();
|
|
432
|
+
tools?.cleanup();
|
|
433
|
+
codexHome?.cleanup();
|
|
434
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
435
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
436
|
+
diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
324
439
|
if (invocation.phase !== "review")
|
|
325
440
|
store.recordRunFence(runId, { method: fenced.method, paths: fence.length }, clock());
|
|
326
441
|
const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
|
|
@@ -329,87 +444,248 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
329
444
|
const codexLogin = spec.provider === "codex" && authMode === "subscription" && runner === undefined
|
|
330
445
|
? await codexLoginBilling(attested !== null ? attested.executable : adapter.binary, [...(runOptions.omitEnv ?? []), ...adapter.extraOmitEnv, ...ownKeyEnv])
|
|
331
446
|
: null;
|
|
447
|
+
const childEnv = {
|
|
448
|
+
...(runOptions.env ?? {}),
|
|
449
|
+
...(adapter.env?.(buildInvocation) ?? {}),
|
|
450
|
+
...(tools?.env ?? {}),
|
|
451
|
+
// A fenced build's git never expires a reflog, so gc never rewrites another task's (agent-fence.ts).
|
|
452
|
+
...(fence.length > 0 ? fencedGitEnv({ ...process.env, ...(runOptions.env ?? {}) }) : {}),
|
|
453
|
+
...(codexHome === null ? {} : { CODEX_HOME: codexHome.dir }),
|
|
454
|
+
...(managedKey === null || keyEnvName === null ? {} : { [keyEnvName]: managedKey }),
|
|
455
|
+
// The run's own proxy, in every spelling a program reads (egress.ts): after every other setting, so no caller's
|
|
456
|
+
// environment points the build around it. A program that ignores them is refused by the OS fence instead.
|
|
457
|
+
...(egress === null || !("policy" in network) ? {} : proxyEnv(egress, network.policy.loopback)),
|
|
458
|
+
// Its own temp folder, where its tools' sockets may live (the only one its fence admits besides its checkout).
|
|
459
|
+
...(runTemp === null ? {} : { TMPDIR: runTemp, TMP: runTemp, TEMP: runTemp }),
|
|
460
|
+
// This key is deliberately last: no caller may point an agent back at
|
|
461
|
+
// the live control database through a generic RunOptions override.
|
|
462
|
+
...childDatabaseEnv(isolatedDb.file),
|
|
463
|
+
};
|
|
464
|
+
const childOmitEnv = [
|
|
465
|
+
...(runOptions.omitEnv ?? []),
|
|
466
|
+
...adapter.extraOmitEnv,
|
|
467
|
+
// A Claude build loads no plugin, from settings or the environment (a review's safe mode already loads none).
|
|
468
|
+
...(spec.provider === "claude" && invocation.phase !== "review" ? claudePluginEnv({ ...process.env, ...(runOptions.env ?? {}) }) : []),
|
|
469
|
+
// Subscription mode: shed this provider's OWN key too, so an ambient
|
|
470
|
+
// one cannot force API billing over the login the operator prefers.
|
|
471
|
+
// Api-key mode: shed every OTHER own-key alias (finding 1) — gemini
|
|
472
|
+
// reads GOOGLE_API_KEY as well as GEMINI_API_KEY, and a stray alias
|
|
473
|
+
// must not override the ONE canonical key this mode injected.
|
|
474
|
+
...(authMode === "subscription"
|
|
475
|
+
? ownKeyEnv
|
|
476
|
+
: ownKeyEnv.filter(name => name !== keyEnvName)),
|
|
477
|
+
];
|
|
478
|
+
const launchOptions = {
|
|
479
|
+
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
|
|
480
|
+
...runOptions,
|
|
481
|
+
...(workspace === null ? {} : { cwd: workspace }),
|
|
482
|
+
// v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
|
|
483
|
+
onStreamEvent: event => {
|
|
484
|
+
if (event["type"] === "system" && event["subtype"] === "init") {
|
|
485
|
+
if (typeof event["apiKeySource"] === "string")
|
|
486
|
+
billingSeen.keySource = event["apiKeySource"].slice(0, 80);
|
|
487
|
+
if (typeof event["model"] === "string")
|
|
488
|
+
billingSeen.model = event["model"].slice(0, 200);
|
|
489
|
+
}
|
|
490
|
+
else if (event["type"] === "rate_limit_event") {
|
|
491
|
+
billingSeen.planWindows = true;
|
|
492
|
+
}
|
|
493
|
+
else if (event["type"] === "result" && event["is_error"] !== true && event["subtype"] === "success") {
|
|
494
|
+
billingSeen.answered = true;
|
|
495
|
+
}
|
|
496
|
+
runOptions.onStreamEvent?.(event);
|
|
497
|
+
},
|
|
498
|
+
owner: runOwnerTag(store, runId),
|
|
499
|
+
beforeSpawn: () => store.applicableStopFor(runId) === null && store.getRun(runId)?.outcome === null,
|
|
500
|
+
onSpawn: pid => {
|
|
501
|
+
runOptions.onSpawn?.(pid);
|
|
502
|
+
if (store.applicableStopFor(runId) !== null)
|
|
503
|
+
throw new Error("the attempt was stopped before spawn custody completed");
|
|
504
|
+
},
|
|
505
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
506
|
+
...(liveInput === null ? {} : { liveInput }),
|
|
507
|
+
// One ACP conversation: the brief, the sealed model, and who the check says the agent is.
|
|
508
|
+
...(acpLaunch === null || spec.model === null ? {} : { acp: { prompt: buildInvocation.brief, model: spec.model, expect: acpAttestation?.agent ?? null, ...(runOptions.acp?.onControl === undefined ? {} : { onControl: runOptions.acp.onControl }), ...(runOptions.acp?.handshakeMs === undefined ? {} : { handshakeMs: runOptions.acp.handshakeMs }) } }),
|
|
509
|
+
...(hardTimeoutMs === undefined ? {} : { timeoutMs: hardTimeoutMs }),
|
|
510
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
511
|
+
...(defaultTimeoutMs === undefined ? {} : { timeoutMs: defaultTimeoutMs }),
|
|
512
|
+
env: childEnv,
|
|
513
|
+
omitEnv: childOmitEnv,
|
|
514
|
+
// Providers run in their own process group (M6.12): the harness spawns
|
|
515
|
+
// shells and tools of its own, and both the timeout and the watch's
|
|
516
|
+
// hard stop must end the whole tree, not orphan the grandchildren.
|
|
517
|
+
processGroup: true,
|
|
518
|
+
// The session registry's crash guarantee (M6.9): the id is stamped the
|
|
519
|
+
// moment the stream announces it, first write wins — a daemon that
|
|
520
|
+
// dies mid-turn still knows which session to offer the successor.
|
|
521
|
+
onSessionId: id => {
|
|
522
|
+
const normalized = transportSessionId(id);
|
|
523
|
+
if (normalized === null)
|
|
524
|
+
return;
|
|
525
|
+
if (transportAnnouncedSessionId === null)
|
|
526
|
+
transportAnnouncedSessionId = normalized;
|
|
527
|
+
else if (transportAnnouncedSessionId !== normalized)
|
|
528
|
+
transportSessionConflict = true;
|
|
529
|
+
store.stampRun(runId, { sessionId: normalized });
|
|
530
|
+
},
|
|
531
|
+
};
|
|
332
532
|
let result;
|
|
333
|
-
|
|
533
|
+
let spawnedAt;
|
|
334
534
|
try {
|
|
535
|
+
if (fence.length > 0 && workspace !== null && (codexHarness || launchOptions.fence !== undefined)) {
|
|
536
|
+
const inheritedEnv = launchOptions.envAllowlist === undefined ? process.env
|
|
537
|
+
: Object.fromEntries(launchOptions.envAllowlist.map(name => [name, process.env[name]]));
|
|
538
|
+
const probeOptions = {
|
|
539
|
+
cwd: workspace, env: checkoutGitEnv({ ...inheritedEnv, ...childEnv }), omitEnv: childOmitEnv, envAllowlist: [],
|
|
540
|
+
};
|
|
541
|
+
// A broken checkout is not evidence of a broken fence. No provider is invoked by either check.
|
|
542
|
+
const gitArgs = [...HARDENED_GIT_ARGS, "rev-parse", "HEAD"];
|
|
543
|
+
const control = await runCommand("git", gitArgs, probeOptions);
|
|
544
|
+
if (control.code === 0) {
|
|
545
|
+
const executable = attested !== null ? attested.executable : adapter.binary;
|
|
546
|
+
const insideFence = (file, args) => codexHarness
|
|
547
|
+
? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
|
|
548
|
+
"-P", "standing-orders", "--", file, ...args], probeOptions)
|
|
549
|
+
: runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
|
|
550
|
+
...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
|
|
551
|
+
const check = await insideFence("git", gitArgs);
|
|
552
|
+
if (check.code !== 0) {
|
|
553
|
+
const trivial = await insideFence("true", []);
|
|
554
|
+
if (trivial.code === 0)
|
|
555
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
556
|
+
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) };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// THE CHECK'S ISOLATION, PROVEN BY THE PLANE (docs/plans/acp-adapter.md § Layer E): inside the exact fence and
|
|
561
|
+
// child environment the agent is about to get, a canary file in the fenced state must be unreadable and a canary
|
|
562
|
+
// key in the launching environment must be absent. No agent takes part, so no agent can fake it.
|
|
563
|
+
if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
|
|
564
|
+
// processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
|
|
565
|
+
const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
|
|
566
|
+
const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
|
|
567
|
+
const env = await runCommand("/usr/bin/env", [], probe);
|
|
568
|
+
acpIsolation = {
|
|
569
|
+
fileDenied: read.code !== 0 && !read.stdout.includes(acpCheckAsk.isolation.fileCanary),
|
|
570
|
+
envClear: env.code === 0 && !env.stdout.includes(acpCheckAsk.isolation.envValue),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// The stamp precedes the spawn, so a crash between the two leaves a run
|
|
574
|
+
// that claims spend which never happened — the honest direction. A spawn
|
|
575
|
+
// before the stamp would leave spend no record claims, which is the lie
|
|
576
|
+
// the invariant exists to rule out. For attested providers the probed
|
|
577
|
+
// version rides the SAME durable write (B2).
|
|
578
|
+
// The spawn leg of the runner gate (MCP spec v6, review finding 4):
|
|
579
|
+
// the tuple re-proven against LIVE rows AFTER every awaited step, in
|
|
580
|
+
// the same breath as the stamp — a takeover during the attestation
|
|
581
|
+
// await can no longer slip a stale process through.
|
|
582
|
+
// THE PLANNER'S ROUTE, RE-PROVED AT THE SPAWN (final authority closure):
|
|
583
|
+
// the provenance the planner was admitted under is held to the authority
|
|
584
|
+
// its task holds RIGHT NOW — the strict working projection of a filed
|
|
585
|
+
// scope (exact terms, resolved profile, route parity, digest,
|
|
586
|
+
// live auth mode), or the bare word `legacy` on a task with no scope. A
|
|
587
|
+
// scope rewritten, corrupted, or unresolved between the claim and this
|
|
588
|
+
// instant invokes no provider: a value-shaped refusal, no spend.
|
|
589
|
+
if (run.role === "planner") {
|
|
590
|
+
const proved = store.proveRouteForSpawn(runId, clock());
|
|
591
|
+
if (!proved.ok) {
|
|
592
|
+
return {
|
|
593
|
+
kind: "refused",
|
|
594
|
+
reason: "route-authority",
|
|
595
|
+
providerVersion: attested === null ? null : attested.version,
|
|
596
|
+
diagnostic: `the planner's route authority lapsed before spawn — ${proved.problem}`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!store.proveRunnerCustodyForSpawn(runId, clock())) {
|
|
601
|
+
return {
|
|
602
|
+
kind: "refused",
|
|
603
|
+
reason: "runner-custody",
|
|
604
|
+
providerVersion: attested === null ? null : attested.version,
|
|
605
|
+
diagnostic: "the run's runner custody lapsed before spawn — the lease, the runner, or its repo binding no longer stands",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// THE STOP FENCE at the spawn (v52): a stop recorded against this run
|
|
609
|
+
// — or the attempt it runs under — before this instant spawns nothing.
|
|
610
|
+
// A value-shaped refusal, no stamp, no process, no spend; the road's
|
|
611
|
+
// settlement seals the attempt as interrupted.
|
|
612
|
+
const stopBeforeSpawn = store.applicableStopFor(runId);
|
|
613
|
+
if (stopBeforeSpawn !== null) {
|
|
614
|
+
return {
|
|
615
|
+
kind: "refused",
|
|
616
|
+
reason: "stopped",
|
|
617
|
+
providerVersion: attested === null ? null : attested.version,
|
|
618
|
+
diagnostic: `stopped by ${stopBeforeSpawn.requestedBy} (run #${stopBeforeSpawn.run}) before the provider spawned — nothing was spent`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
// THE CONTAINMENT FENCE at the spawn (OS containment plan): a required
|
|
622
|
+
// native policy this runner cannot meet — macOS, an undelegated cgroup,
|
|
623
|
+
// a missing helper — spawns nothing. A value-shaped refusal in the
|
|
624
|
+
// policy's own words, no stamp, no process, no spend.
|
|
625
|
+
const containment = currentContainment();
|
|
626
|
+
if (containment.refusal !== null) {
|
|
627
|
+
return {
|
|
628
|
+
kind: "refused",
|
|
629
|
+
reason: "containment",
|
|
630
|
+
providerVersion: attested === null ? null : attested.version,
|
|
631
|
+
diagnostic: containment.refusal,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// The ACP launch re-proved HERE, after every await: each bound file is still the one that passed its check.
|
|
635
|
+
if (acpCheckAsk !== null && acpCheckRequest !== null && acpCheckLaunch !== null) {
|
|
636
|
+
// The check request is read again here, after every await: still running, unexpired, owned, this step's run.
|
|
637
|
+
const still = store.acpCheckAdmitting(acpCheckAsk.requestId, acpCheckAsk.step, runId, clock());
|
|
638
|
+
if (still === null || still.launchJson !== acpCheckRequest.launchJson) {
|
|
639
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: null, diagnostic: "the check stopped admitting this run while it was being set up — nothing was started" };
|
|
640
|
+
}
|
|
641
|
+
const verified = verifyLaunch(acpCheckLaunch);
|
|
642
|
+
if (!verified.ok)
|
|
643
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: null, diagnostic: verified.problem };
|
|
644
|
+
}
|
|
645
|
+
if (acpAttestation !== null) {
|
|
646
|
+
// The record is read again here, after every await: a check revoked or replaced during setup starts nothing.
|
|
647
|
+
const live = store.acpAttestationFor(spec.provider, run.runner);
|
|
648
|
+
if (live === null || live.id !== acpAttestation.id) {
|
|
649
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: acpAttestation.agent.version, diagnostic: `${ACP_AGENTS[spec.provider].name}'s check was revoked or replaced while this run was being set up — nothing was started` };
|
|
650
|
+
}
|
|
651
|
+
const verified = verifyLaunch(live.launch);
|
|
652
|
+
if (!verified.ok) {
|
|
653
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: acpAttestation.agent.version, diagnostic: verified.problem };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (attested !== null)
|
|
657
|
+
store.stampProviderStart(runId, clock(), attested.version);
|
|
658
|
+
else if (acpAttestation !== null)
|
|
659
|
+
store.stampProviderStart(runId, clock(), acpAttestation.agent.version);
|
|
660
|
+
else
|
|
661
|
+
store.stampProviderStart(runId, clock());
|
|
662
|
+
// The brief is queued only once the checkout has passed the fence check.
|
|
663
|
+
liveInput?.begin(invocation.brief);
|
|
664
|
+
spawnedAt = Date.now();
|
|
335
665
|
// Under the stop watch (v52): the run's stop row is re-read while the
|
|
336
666
|
// provider runs, and a stop kills THIS run's process group through the
|
|
337
667
|
// handle registered under its owner tag — the exact-attempt road,
|
|
338
668
|
// never the global sweep. Settlement then reads the same row.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
...
|
|
342
|
-
|
|
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
|
-
}));
|
|
669
|
+
// An ACP agent starts through its attested launch (the pinned interpreter for a script), never a PATH lookup.
|
|
670
|
+
result = await underStopWatch(store, runId, () => acpLaunch !== null
|
|
671
|
+
? spawn(acpLaunch.file, [...acpLaunch.prefix, ...launchArgv], launchOptions)
|
|
672
|
+
: spawn(attested !== null ? attested.executable : adapter.binary, launchArgv, launchOptions));
|
|
409
673
|
}
|
|
410
674
|
finally {
|
|
411
675
|
removeAgentDatabase(isolatedDb.dir);
|
|
412
676
|
tools?.cleanup();
|
|
677
|
+
codexHome?.cleanup();
|
|
678
|
+
// What the run's proxy saw (shown on its result): connections it made and refused. A connection the OS refused
|
|
679
|
+
// outright never reached it, and isn't counted.
|
|
680
|
+
if (invocation.phase !== "review" && grants.network !== undefined) {
|
|
681
|
+
store.recordRunNetwork(runId, {
|
|
682
|
+
mode: grants.network.mode, phase: "agent",
|
|
683
|
+
...("via" in network ? { via: true } : { hosts: network.policy.hosts.length, loopback: network.policy.loopback }),
|
|
684
|
+
...(egress === null ? {} : { counts: egress.counts() }),
|
|
685
|
+
}, clock());
|
|
686
|
+
}
|
|
687
|
+
await egress?.close();
|
|
688
|
+
await bridge?.close();
|
|
413
689
|
}
|
|
414
690
|
// v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
|
|
415
691
|
// what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
|
|
@@ -421,12 +697,17 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
421
697
|
if (spec.provider === "claude" && managedKey === null && authMode === "subscription" && billingSeen.answered && evidence !== null) {
|
|
422
698
|
store.recordProviderBilling("claude", evidence, new Date());
|
|
423
699
|
}
|
|
424
|
-
|
|
700
|
+
// An ACP run bills as its admission stamp says (store.acpBillingOfRun): its agent's own plan, or metered.
|
|
701
|
+
const acpBilling = isAcpProvider(spec.provider) ? (store.acpBillingOfRun(runId)?.kind === "plan" ? "subscription" : "api-key") : null;
|
|
702
|
+
store.fixRunBilling(runId, acpBilling ?? (authMode === "api-key" ? "api-key" : evidence ?? billingOf(spec.provider, store.handle)), new Date());
|
|
425
703
|
const envelope = adapter.parse(result.stdout);
|
|
704
|
+
store.stampAgentReportedUsage(runId, envelope.agentReportedUsage ?? null);
|
|
426
705
|
// An installed CLI too old for a build's own setup refuses its flags
|
|
427
706
|
// before it starts: nothing ran, and the run says what to update.
|
|
428
707
|
const unsupported = result.code !== 0 && envelope.initObserved !== true && invocation.phase !== "review" ? isolationUnsupported(spec.provider, result.stderr) : null;
|
|
429
708
|
if (unsupported !== null) {
|
|
709
|
+
// The CLI refused its flags before any model turn: the run never started, whatever the stamp said.
|
|
710
|
+
store.withdrawProviderStart(runId);
|
|
430
711
|
return { kind: "refused", reason: "provider-unattested", providerVersion: attested === null ? null : attested.version, diagnostic: unsupported };
|
|
431
712
|
}
|
|
432
713
|
// The callback is the crash-safe early stamp; the parsed envelope is the
|
|
@@ -466,6 +747,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
466
747
|
...(envelope.tokensOut === null ? {} : { tokensOut: envelope.tokensOut + (options.accumulateUsage ? run.tokensOut ?? 0 : 0) }),
|
|
467
748
|
...(costUsd === null ? {} : { costUsd: costUsd + (options.accumulateUsage ? run.costUsd ?? 0 : 0) }),
|
|
468
749
|
...(usageJson === null ? {} : { usageJson }),
|
|
750
|
+
// How many agent turns it took, where the provider reports it (docs/plans/run-efficiency.md); unknown stays NULL.
|
|
751
|
+
...(envelope.ending?.turns == null ? {} : { agentTurns: envelope.ending.turns + (options.accumulateUsage ? run.agentTurns ?? 0 : 0) }),
|
|
469
752
|
});
|
|
470
753
|
// How the attempt ended, classified HERE where the structural terminal
|
|
471
754
|
// still exists; a sign-in that no longer works pauses its provider.
|
|
@@ -493,6 +776,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
493
776
|
providerVersion: attested === null ? null : attested.version,
|
|
494
777
|
diagnostic: envelope.protocolError,
|
|
495
778
|
finalMessage: envelope.finalMessage,
|
|
779
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
496
780
|
};
|
|
497
781
|
}
|
|
498
782
|
// The minted-identity proof (A5): once the harness initialized, the id
|
|
@@ -510,6 +794,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
510
794
|
? "the harness initialized without announcing its session id"
|
|
511
795
|
: "the harness announced a session id different from the one it was started under",
|
|
512
796
|
finalMessage: envelope.finalMessage,
|
|
797
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
513
798
|
};
|
|
514
799
|
}
|
|
515
800
|
// An early transport announcement, a pre-existing resume identity, and
|
|
@@ -523,6 +808,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
523
808
|
providerVersion: attested === null ? null : attested.version,
|
|
524
809
|
diagnostic: sessionIdentityProblem,
|
|
525
810
|
finalMessage: envelope.finalMessage,
|
|
811
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
526
812
|
};
|
|
527
813
|
}
|
|
528
814
|
// A provider's explicit failure terminal always wins over its process exit
|
|
@@ -535,6 +821,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
535
821
|
providerVersion: attested === null ? null : attested.version,
|
|
536
822
|
diagnostic: "the provider reported a failed terminal despite exiting with code 0",
|
|
537
823
|
finalMessage: envelope.finalMessage,
|
|
824
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
538
825
|
};
|
|
539
826
|
}
|
|
540
827
|
// The terminal contract (A4): when the audit requires it, a zero exit is
|
|
@@ -554,6 +841,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
554
841
|
? "the harness exited 0 without its success terminal — the stream ended mid-protocol"
|
|
555
842
|
: "the harness exited 0 without ever initializing"),
|
|
556
843
|
finalMessage: envelope.finalMessage,
|
|
844
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
557
845
|
};
|
|
558
846
|
}
|
|
559
847
|
// A run that worked proves its provider's sign-in works: a sign-in pause
|
|
@@ -573,7 +861,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
573
861
|
notFound: result.notFound,
|
|
574
862
|
sessionId: envelope.sessionId,
|
|
575
863
|
finalMessage: envelope.finalMessage,
|
|
864
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
576
865
|
structuredOutput: envelope.structuredOutput ?? null,
|
|
866
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
577
867
|
ending: envelope.ending ?? null,
|
|
578
868
|
usage: {
|
|
579
869
|
tokensIn: envelope.tokensIn,
|
|
@@ -608,44 +898,82 @@ function usageOf(raw) {
|
|
|
608
898
|
return {};
|
|
609
899
|
}
|
|
610
900
|
}
|
|
901
|
+
/** What the plane recorded of a checkout's setup: its project and the setup stamped on it (null: none recorded). */
|
|
902
|
+
function linkedFor(store, worktree) {
|
|
903
|
+
const row = worktree === null ? null : store.getWorktree(worktree);
|
|
904
|
+
return row === null ? null : { repo: row.repo, setupDigest: row.setupDigest ?? null };
|
|
905
|
+
}
|
|
611
906
|
/** 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
|
-
|
|
907
|
+
* ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
|
|
908
|
+
* (FenceGrants); and what only Claude's file tools refuse. */
|
|
909
|
+
function runFence(store, runId, provider, keyHome, ownTools) {
|
|
614
910
|
try {
|
|
615
911
|
// A plane always has a database file; an in-memory store (a test) has nothing to fence.
|
|
616
912
|
const databaseFile = store.databaseFile();
|
|
617
913
|
if (databaseFile === null)
|
|
618
|
-
return { fence: [], fileTools: [] };
|
|
914
|
+
return { fence: [], fileTools: [], grants: NO_GRANTS };
|
|
619
915
|
const worktree = store.getRun(runId)?.worktree ?? null;
|
|
620
916
|
const home = keyHome === undefined ? {} : { home: keyHome };
|
|
621
|
-
const
|
|
622
|
-
|
|
917
|
+
const row = worktree === null ? null : store.getWorktree(worktree);
|
|
918
|
+
if (worktree !== null && row === null)
|
|
919
|
+
throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
|
|
920
|
+
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
921
|
+
const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
|
|
922
|
+
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
|
|
923
|
+
const folders = runFolders(ownTools);
|
|
924
|
+
return {
|
|
925
|
+
fence: agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders }),
|
|
926
|
+
fileTools: boundary?.fileTools ?? [],
|
|
927
|
+
// The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
|
|
928
|
+
// Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
|
|
929
|
+
// 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.
|
|
930
|
+
grants: fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home,
|
|
931
|
+
poolRoots }),
|
|
932
|
+
// The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
|
|
933
|
+
gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
|
|
934
|
+
};
|
|
623
935
|
}
|
|
624
|
-
catch {
|
|
625
|
-
return { fence: [], fileTools: [] };
|
|
936
|
+
catch (error) {
|
|
937
|
+
return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
|
|
626
938
|
}
|
|
627
939
|
}
|
|
940
|
+
/** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
|
|
941
|
+
function inheritedDefaults(provider, env, home) {
|
|
942
|
+
if (provider === "claude")
|
|
943
|
+
return claudeDefaults(claudeConfigDir(env, home));
|
|
944
|
+
if (provider === "codex")
|
|
945
|
+
return codexDefaults(codexHomeDir(env, home));
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
628
948
|
/** How the fence reaches a provider: Codex through its own sandbox profile
|
|
629
949
|
* (already in its argv; wrapping it would nest sandboxes), Claude and
|
|
630
950
|
* Gemini inside the macOS sandbox the spawn road applies (`wrap`), Claude
|
|
631
951
|
* elsewhere through its own file-tool rules only. */
|
|
632
952
|
function fenceLaunch(provider, fence) {
|
|
633
|
-
|
|
634
|
-
|
|
953
|
+
const method = fenceMethod(provider, fence.length > 0);
|
|
954
|
+
return { wrap: method === "macos-sandbox" || method === "linux-bubblewrap" ? fence : [], method };
|
|
955
|
+
}
|
|
956
|
+
/** The fence a launch gets, from its provider, whether it has anything to fence, and what this platform supports. */
|
|
957
|
+
function fenceMethod(provider, fenced) {
|
|
958
|
+
if (!fenced)
|
|
959
|
+
return "none";
|
|
635
960
|
if (provider === "codex" || provider === "openrouter")
|
|
636
|
-
return
|
|
961
|
+
return "codex-profile";
|
|
637
962
|
if (macosFenceAvailable())
|
|
638
|
-
return
|
|
963
|
+
return "macos-sandbox";
|
|
639
964
|
if (linuxFenceAvailable())
|
|
640
|
-
return
|
|
641
|
-
return
|
|
965
|
+
return "linux-bubblewrap";
|
|
966
|
+
return provider === "claude" ? "claude-rules" : "none";
|
|
642
967
|
}
|
|
643
968
|
/** 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) {
|
|
969
|
+
async function runTools(store, runId, spec, keyHome, clock, readOnly, withheld, containment, broker) {
|
|
645
970
|
try {
|
|
646
|
-
return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), ...(readOnly === undefined ? {} : { readOnly }), ...(withheld === undefined ? {} : { withheld }) });
|
|
971
|
+
return await prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), containment, ...(readOnly === undefined ? {} : { readOnly }), ...(withheld === undefined ? {} : { withheld }), broker });
|
|
647
972
|
}
|
|
648
|
-
catch {
|
|
973
|
+
catch (error) {
|
|
974
|
+
// Sign-ins made for this run couldn't all be kept (and were revoked): it doesn't start.
|
|
975
|
+
if (error instanceof RunSignInError)
|
|
976
|
+
return { refused: error.message };
|
|
649
977
|
return noToolsArgs(spec.provider);
|
|
650
978
|
}
|
|
651
979
|
}
|