toolroll 0.9.63 → 0.9.67
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 +108 -0
- package/README.md +33 -5
- 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 +117 -2
- package/dist/agent-fence.js +439 -12
- 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 +39 -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/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +228 -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 +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +10 -1
- package/dist/builder.js +87 -24
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- 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/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -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/console-api.d.ts +13 -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/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/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- 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/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- 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 +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +35 -0
- package/dist/invoke.js +347 -28
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- 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 +152 -1
- 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 +269 -0
- package/dist/live-terminal.js +686 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- 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/operate.d.ts +4 -0
- package/dist/operate.js +335 -19
- 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/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/project-delete-ui.d.ts +18 -0
- package/dist/project-delete-ui.js +60 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- 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.d.ts +25 -2
- package/dist/provider.js +63 -9
- 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-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +10 -1
- package/dist/release.js +34 -15
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-follow-ups.js +3 -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 +183 -5
- package/dist/scope.js +256 -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 +248 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +16 -2
- package/dist/server/chrome.js +63 -11
- package/dist/server/console.css +107 -42
- package/dist/server/flows.js +38 -8
- 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 +32 -4
- package/dist/server/render-pages.js +83 -13
- 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 +111 -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 +47 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +142 -10
- 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 +327 -78
- 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-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +440 -15
- package/dist/store.js +1072 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- 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 +22 -0
- package/dist/teardown-git.js +136 -2
- 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 +14 -0
- package/dist/tool-broker.js +67 -2
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- 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 +16 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +15 -2
package/dist/invoke.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
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";
|
|
@@ -27,11 +29,14 @@ 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
31
|
import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
|
|
30
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
31
|
-
import { homedir } from "node:os";
|
|
32
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
32
34
|
import { dirname, join } from "node:path";
|
|
33
35
|
import { poolRootOf } from "./worktree.js";
|
|
34
|
-
import {
|
|
36
|
+
import { BRIDGE_DIR } from "./agent-fence.js";
|
|
37
|
+
import { agentFence, buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, 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";
|
|
35
40
|
import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
|
|
36
41
|
import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
|
|
37
42
|
/** The claude binary name — kept for the legacy quota rows and tests that
|
|
@@ -100,13 +105,6 @@ function transportSessionId(id) {
|
|
|
100
105
|
const normalized = id.trim();
|
|
101
106
|
return normalized === "" ? null : normalized;
|
|
102
107
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Spend money, on the record. Throws — never refuses quietly — when the run
|
|
105
|
-
* is missing, already finished, or recorded against a different provider:
|
|
106
|
-
* a caller that reaches for a harness without a matching open run is a
|
|
107
|
-
* bug, not a case. Attestation and protocol refusals are VALUES (above),
|
|
108
|
-
* not throws — those are cases, and they must dispose cleanly.
|
|
109
|
-
*/
|
|
110
108
|
export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
111
109
|
const clock = options.clock ?? (() => new Date());
|
|
112
110
|
if (options.accumulateUsage && (invocation.phase !== "review" || invocation.resumeSession === null)) {
|
|
@@ -175,7 +173,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
175
173
|
// Tier-2 attestation (A2/B3): the authoritative check, immediately
|
|
176
174
|
// before the spawn it authorizes. Tier-1 providers return null here and
|
|
177
175
|
// keep their road byte-identical.
|
|
178
|
-
|
|
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);
|
|
179
178
|
if (attested !== null && !attested.ok) {
|
|
180
179
|
if (attested.version !== null)
|
|
181
180
|
store.stampProviderVersion(runId, attested.version);
|
|
@@ -186,13 +185,61 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
186
185
|
diagnostic: attested.problem,
|
|
187
186
|
};
|
|
188
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
|
+
}
|
|
189
236
|
// Auth mode decides how the credential reaches the process (the operator
|
|
190
237
|
// keeps BOTH and prefers the subscription): in "api-key" mode the
|
|
191
238
|
// managed file wins, the ambient env is a real fallback (explicit
|
|
192
239
|
// re-supply survives resolveChildEnv's chat-key strip); in
|
|
193
240
|
// "subscription" mode the key is STRIPPED from the child so the CLI's
|
|
194
241
|
// own login is used — its key is retained, just not handed over.
|
|
195
|
-
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, writes: launchWrites, ...runOptions } = options;
|
|
196
243
|
// The live setting is read STRICTLY (atomic authority closure): a
|
|
197
244
|
// present mode file that says neither word is a value-shaped refusal
|
|
198
245
|
// before any stamp or process — never the subscription default a
|
|
@@ -213,9 +260,12 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
213
260
|
if (problem !== null)
|
|
214
261
|
return { kind: "refused", reason: "auth-mode", providerVersion: attested?.version ?? null, diagnostic: problem };
|
|
215
262
|
}
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
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)
|
|
219
269
|
: null;
|
|
220
270
|
// Resume XOR mint, enforced at the GATEWAY (Codex gemini verify round 2,
|
|
221
271
|
// finding 3): a resume and a minted start id are mutually exclusive —
|
|
@@ -250,12 +300,21 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
250
300
|
// The project's tools (v80): exactly its MCP servers, and none of the
|
|
251
301
|
// operator's global or a repository's own, served by this launch's broker
|
|
252
302
|
// (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
|
|
303
|
+
// Set once the fence is known: a fenced build's servers never start outside a fence.
|
|
304
|
+
let serverMustBeFenced = false;
|
|
305
|
+
// What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
|
|
306
|
+
const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
|
|
253
307
|
const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
|
|
254
308
|
env: { ...process.env, ...(runOptions.env ?? {}) },
|
|
255
309
|
omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
|
|
256
310
|
pass: childDatabaseEnv(isolatedDb.file),
|
|
257
|
-
|
|
311
|
+
// A tool server starts in a folder the build can't write, and never runs the build's own bytes (arc 2).
|
|
312
|
+
cwd: keyHome ?? homedir(),
|
|
313
|
+
untrusted: serverUntrusted,
|
|
258
314
|
wrap: () => serverFence,
|
|
315
|
+
// A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
|
|
316
|
+
unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
|
|
317
|
+
? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
|
|
259
318
|
});
|
|
260
319
|
if (launchTools !== null && "refused" in launchTools) {
|
|
261
320
|
removeAgentDatabase(isolatedDb.dir);
|
|
@@ -283,7 +342,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
283
342
|
// tool servers outside its command sandbox, so its own launch folder is
|
|
284
343
|
// fenced too; Claude reads its config from there, inside the sandbox.
|
|
285
344
|
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);
|
|
345
|
+
const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], cache: undefined, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools, launchWrites ?? WRITES_DEFAULT);
|
|
287
346
|
if (fenceOf.problem !== undefined) {
|
|
288
347
|
tools?.cleanup();
|
|
289
348
|
codexHome?.cleanup();
|
|
@@ -291,11 +350,26 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
291
350
|
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null, diagnostic: fenceOf.problem };
|
|
292
351
|
}
|
|
293
352
|
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;
|
|
353
|
+
const grants = { ...fenceOf.grants };
|
|
354
|
+
// The run's network (egress.ts): sealed by its approval, or the provider's hosts alone, or the caller's own proxy.
|
|
355
|
+
const network = launchNetwork ?? { policy: providerOnlyNetwork(spec.provider) };
|
|
356
|
+
// Codex's command sandbox has no proxy (its argv is fixed just below): its commands are online only for a sealed
|
|
357
|
+
// open network, and offline otherwise.
|
|
358
|
+
if (fence.length > 0 && codexHarness) {
|
|
359
|
+
grants.network = "policy" in network && network.policy.mode === "open" ? { mode: "open" }
|
|
360
|
+
: { mode: "allowlist", proxyPort: 0, brokerPorts: [], loopback: "proxy-only", socketRoots: [] };
|
|
361
|
+
}
|
|
295
362
|
const inherited = spec.model === null && invocation.phase !== "review" ? inheritedDefaults(spec.provider, { ...process.env, ...(runOptions.env ?? {}) }, keyHome ?? homedir()) : undefined;
|
|
296
363
|
const buildInvocation = { ...resolvedInvocation, ...(tools === null ? {} : { toolArgv: tools.argv, fence, fenceGrants: grants, fileToolFence: fenceOf.fileTools }), ...(workspace === null ? {} : { workspace }), ...(inherited === undefined ? {} : { inherited }) };
|
|
297
364
|
const launchArgv = tools === null ? argv : adapter.argv(buildInvocation);
|
|
298
365
|
const fenced = fenceLaunch(spec.provider, fence);
|
|
366
|
+
// An ACP agent runs only inside the OS fence: nothing else holds it (it may run commands without asking).
|
|
367
|
+
if (isAcpProvider(spec.provider) && fenced.method !== "macos-sandbox" && fenced.method !== "linux-bubblewrap") {
|
|
368
|
+
tools?.cleanup();
|
|
369
|
+
codexHome?.cleanup();
|
|
370
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
371
|
+
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)` };
|
|
372
|
+
}
|
|
299
373
|
// The tools were chosen for the fence decided above: a launch that got any other starts nothing.
|
|
300
374
|
// Only a prepared project-tool broker depends on the earlier decision. Empty Claude configs also have a privateDir.
|
|
301
375
|
if (tools?.serverPids !== undefined && fenced.method !== containment.fence) {
|
|
@@ -307,10 +381,73 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
307
381
|
// A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
|
|
308
382
|
// profile fences only its agent's commands, and the broker starts servers outside it.
|
|
309
383
|
if (fence.length > 0) {
|
|
384
|
+
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
|
|
385
|
+
// nor its write allowlist (a server keeps its own state where it always has).
|
|
386
|
+
const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
|
|
387
|
+
// Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
|
|
388
|
+
const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
|
|
310
389
|
if (macosFenceAvailable())
|
|
311
|
-
serverFence = (file, args) => macosFenced(file, args,
|
|
390
|
+
serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
|
|
312
391
|
else if (linuxFenceAvailable())
|
|
313
|
-
serverFence = (file, args) => linuxFenced(file, args,
|
|
392
|
+
serverFence = (file, args) => linuxFenced(file, args, serverDenies, serverGrants());
|
|
393
|
+
serverMustBeFenced = true;
|
|
394
|
+
}
|
|
395
|
+
// No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
|
|
396
|
+
// rules, Gemini to nothing — doesn't start unless the instance operator allowed unconfined agents here. An injected
|
|
397
|
+
// runner (tests) starts no real agent.
|
|
398
|
+
const unconfined = fence.length > 0 && (fenced.method === "claude-rules" || fenced.method === "none");
|
|
399
|
+
if (unconfined && runner === undefined && !store.unfencedAgentsAllowed()) {
|
|
400
|
+
tools?.cleanup();
|
|
401
|
+
codexHome?.cleanup();
|
|
402
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
403
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
404
|
+
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`." };
|
|
405
|
+
}
|
|
406
|
+
let egress = null;
|
|
407
|
+
let bridge = null;
|
|
408
|
+
let runTemp = null;
|
|
409
|
+
const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
|
|
410
|
+
if (fence.length > 0 && !codexHarness && confinedOs) {
|
|
411
|
+
const brokerPorts = tools?.brokerPort === undefined ? [] : [tools.brokerPort];
|
|
412
|
+
// Unix sockets only under this run's own folders: its checkout, and a temp folder of its own inside its private
|
|
413
|
+
// launch folder (the system temp folder holds other programs' sockets — editors, agents, password managers).
|
|
414
|
+
if (ownTools !== null) {
|
|
415
|
+
runTemp = join(ownTools, "tmp");
|
|
416
|
+
mkdirSync(runTemp, { recursive: true, mode: 0o700 });
|
|
417
|
+
}
|
|
418
|
+
const socketRoots = [...(runTemp === null ? [] : [runTemp]), ...(workspace === null ? [] : [realpathSync(workspace)])];
|
|
419
|
+
try {
|
|
420
|
+
if ("via" in network) {
|
|
421
|
+
const [proxyPort, ...more] = network.via;
|
|
422
|
+
grants.network = { mode: "allowlist", proxyPort: proxyPort ?? 0, brokerPorts: [...more, ...brokerPorts], loopback: "proxy-only", socketRoots };
|
|
423
|
+
}
|
|
424
|
+
else if (network.policy.mode === "open") {
|
|
425
|
+
grants.network = { mode: "open" };
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
egress = await startEgressProxy({ hosts: network.policy.hosts });
|
|
429
|
+
grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots };
|
|
430
|
+
}
|
|
431
|
+
if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist") {
|
|
432
|
+
const database = store.databaseFile();
|
|
433
|
+
// The plane's own fenced folder, unless a socket there would be too long a path (a unix socket's path is capped
|
|
434
|
+
// near 100 bytes): then the system temp folder, which a confined Linux build doesn't see (its /tmp is its own).
|
|
435
|
+
const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
|
|
436
|
+
bridge = await startEgressBridge([grants.network.proxyPort, ...grants.network.brokerPorts], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
|
|
437
|
+
grants.network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
|
|
438
|
+
// Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
|
|
439
|
+
grants.network.keep = [...(workspace === null ? [] : [realpathSync(workspace)]), ...(ownTools === null ? [] : [ownTools]), ...(fenceOf.gitPaths ?? [])];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
await egress?.close();
|
|
444
|
+
await bridge?.close();
|
|
445
|
+
tools?.cleanup();
|
|
446
|
+
codexHome?.cleanup();
|
|
447
|
+
removeAgentDatabase(isolatedDb.dir);
|
|
448
|
+
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
449
|
+
diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
|
|
450
|
+
}
|
|
314
451
|
}
|
|
315
452
|
if (invocation.phase !== "review")
|
|
316
453
|
store.recordRunFence(runId, { method: fenced.method, paths: fence.length }, clock());
|
|
@@ -327,7 +464,16 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
327
464
|
// A fenced build's git never expires a reflog, so gc never rewrites another task's (agent-fence.ts).
|
|
328
465
|
...(fence.length > 0 ? fencedGitEnv({ ...process.env, ...(runOptions.env ?? {}) }) : {}),
|
|
329
466
|
...(codexHome === null ? {} : { CODEX_HOME: codexHome.dir }),
|
|
330
|
-
...(managedKey === null ? {} : { [
|
|
467
|
+
...(managedKey === null || keyEnvName === null ? {} : { [keyEnvName]: managedKey }),
|
|
468
|
+
// The run's own proxy, in every spelling a program reads (egress.ts): after every other setting, so no caller's
|
|
469
|
+
// environment points the build around it. A program that ignores them is refused by the OS fence instead.
|
|
470
|
+
...(egress === null || !("policy" in network) ? {} : proxyEnv(egress, network.policy.loopback)),
|
|
471
|
+
// Its own temp folder, where its tools' sockets may live (the only one its fence admits besides its checkout).
|
|
472
|
+
...(runTemp === null ? {} : { TMPDIR: runTemp, TMP: runTemp, TEMP: runTemp }),
|
|
473
|
+
// Claude's own working folder goes there too (it is otherwise the shared /tmp/claude-<uid>).
|
|
474
|
+
...(runTemp === null || spec.provider !== "claude" ? {} : { CLAUDE_CODE_TMPDIR: runTemp }),
|
|
475
|
+
// A workspace build's package managers keep their caches in its project's own folder (arc 2).
|
|
476
|
+
...(fenceOf.cache === undefined || grants.writes === undefined ? {} : buildCacheEnv(fenceOf.cache)),
|
|
331
477
|
// This key is deliberately last: no caller may point an agent back at
|
|
332
478
|
// the live control database through a generic RunOptions override.
|
|
333
479
|
...childDatabaseEnv(isolatedDb.file),
|
|
@@ -344,7 +490,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
344
490
|
// must not override the ONE canonical key this mode injected.
|
|
345
491
|
...(authMode === "subscription"
|
|
346
492
|
? ownKeyEnv
|
|
347
|
-
: ownKeyEnv.filter(name => name !==
|
|
493
|
+
: ownKeyEnv.filter(name => name !== keyEnvName)),
|
|
348
494
|
];
|
|
349
495
|
const launchOptions = {
|
|
350
496
|
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
|
|
@@ -375,6 +521,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
375
521
|
},
|
|
376
522
|
...(stdin === undefined ? {} : { stdin }),
|
|
377
523
|
...(liveInput === null ? {} : { liveInput }),
|
|
524
|
+
// One ACP conversation: the brief, the sealed model, and who the check says the agent is.
|
|
525
|
+
...(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 }) } }),
|
|
378
526
|
...(hardTimeoutMs === undefined ? {} : { timeoutMs: hardTimeoutMs }),
|
|
379
527
|
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
380
528
|
...(defaultTimeoutMs === undefined ? {} : { timeoutMs: defaultTimeoutMs }),
|
|
@@ -426,6 +574,19 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
426
574
|
}
|
|
427
575
|
}
|
|
428
576
|
}
|
|
577
|
+
// THE CHECK'S ISOLATION, PROVEN BY THE PLANE (docs/plans/acp-adapter.md § Layer E): inside the exact fence and
|
|
578
|
+
// child environment the agent is about to get, a canary file in the fenced state must be unreadable and a canary
|
|
579
|
+
// key in the launching environment must be absent. No agent takes part, so no agent can fake it.
|
|
580
|
+
if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
|
|
581
|
+
// processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
|
|
582
|
+
const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
|
|
583
|
+
const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
|
|
584
|
+
const env = await runCommand("/usr/bin/env", [], probe);
|
|
585
|
+
acpIsolation = {
|
|
586
|
+
fileDenied: read.code !== 0 && !read.stdout.includes(acpCheckAsk.isolation.fileCanary),
|
|
587
|
+
envClear: env.code === 0 && !env.stdout.includes(acpCheckAsk.isolation.envValue),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
429
590
|
// The stamp precedes the spawn, so a crash between the two leaves a run
|
|
430
591
|
// that claims spend which never happened — the honest direction. A spawn
|
|
431
592
|
// before the stamp would leave spend no record claims, which is the lie
|
|
@@ -487,8 +648,32 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
487
648
|
diagnostic: containment.refusal,
|
|
488
649
|
};
|
|
489
650
|
}
|
|
651
|
+
// The ACP launch re-proved HERE, after every await: each bound file is still the one that passed its check.
|
|
652
|
+
if (acpCheckAsk !== null && acpCheckRequest !== null && acpCheckLaunch !== null) {
|
|
653
|
+
// The check request is read again here, after every await: still running, unexpired, owned, this step's run.
|
|
654
|
+
const still = store.acpCheckAdmitting(acpCheckAsk.requestId, acpCheckAsk.step, runId, clock());
|
|
655
|
+
if (still === null || still.launchJson !== acpCheckRequest.launchJson) {
|
|
656
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: null, diagnostic: "the check stopped admitting this run while it was being set up — nothing was started" };
|
|
657
|
+
}
|
|
658
|
+
const verified = verifyLaunch(acpCheckLaunch);
|
|
659
|
+
if (!verified.ok)
|
|
660
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: null, diagnostic: verified.problem };
|
|
661
|
+
}
|
|
662
|
+
if (acpAttestation !== null) {
|
|
663
|
+
// The record is read again here, after every await: a check revoked or replaced during setup starts nothing.
|
|
664
|
+
const live = store.acpAttestationFor(spec.provider, run.runner);
|
|
665
|
+
if (live === null || live.id !== acpAttestation.id) {
|
|
666
|
+
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` };
|
|
667
|
+
}
|
|
668
|
+
const verified = verifyLaunch(live.launch);
|
|
669
|
+
if (!verified.ok) {
|
|
670
|
+
return { kind: "refused", reason: "provider-unattested", providerVersion: acpAttestation.agent.version, diagnostic: verified.problem };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
490
673
|
if (attested !== null)
|
|
491
674
|
store.stampProviderStart(runId, clock(), attested.version);
|
|
675
|
+
else if (acpAttestation !== null)
|
|
676
|
+
store.stampProviderStart(runId, clock(), acpAttestation.agent.version);
|
|
492
677
|
else
|
|
493
678
|
store.stampProviderStart(runId, clock());
|
|
494
679
|
// The brief is queued only once the checkout has passed the fence check.
|
|
@@ -498,12 +683,26 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
498
683
|
// provider runs, and a stop kills THIS run's process group through the
|
|
499
684
|
// handle registered under its owner tag — the exact-attempt road,
|
|
500
685
|
// never the global sweep. Settlement then reads the same row.
|
|
501
|
-
|
|
686
|
+
// An ACP agent starts through its attested launch (the pinned interpreter for a script), never a PATH lookup.
|
|
687
|
+
result = await underStopWatch(store, runId, () => acpLaunch !== null
|
|
688
|
+
? spawn(acpLaunch.file, [...acpLaunch.prefix, ...launchArgv], launchOptions)
|
|
689
|
+
: spawn(attested !== null ? attested.executable : adapter.binary, launchArgv, launchOptions));
|
|
502
690
|
}
|
|
503
691
|
finally {
|
|
504
692
|
removeAgentDatabase(isolatedDb.dir);
|
|
505
693
|
tools?.cleanup();
|
|
506
694
|
codexHome?.cleanup();
|
|
695
|
+
// What the run's proxy saw (shown on its result): connections it made and refused. A connection the OS refused
|
|
696
|
+
// outright never reached it, and isn't counted.
|
|
697
|
+
if (invocation.phase !== "review" && grants.network !== undefined) {
|
|
698
|
+
store.recordRunNetwork(runId, {
|
|
699
|
+
mode: grants.network.mode, phase: "agent",
|
|
700
|
+
...("via" in network ? { via: true } : { hosts: network.policy.hosts.length, loopback: network.policy.loopback }),
|
|
701
|
+
...(egress === null ? {} : { counts: egress.counts() }),
|
|
702
|
+
}, clock());
|
|
703
|
+
}
|
|
704
|
+
await egress?.close();
|
|
705
|
+
await bridge?.close();
|
|
507
706
|
}
|
|
508
707
|
// v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
|
|
509
708
|
// what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
|
|
@@ -515,8 +714,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
515
714
|
if (spec.provider === "claude" && managedKey === null && authMode === "subscription" && billingSeen.answered && evidence !== null) {
|
|
516
715
|
store.recordProviderBilling("claude", evidence, new Date());
|
|
517
716
|
}
|
|
518
|
-
|
|
717
|
+
// An ACP run bills as its admission stamp says (store.acpBillingOfRun): its agent's own plan, or metered.
|
|
718
|
+
const acpBilling = isAcpProvider(spec.provider) ? (store.acpBillingOfRun(runId)?.kind === "plan" ? "subscription" : "api-key") : null;
|
|
719
|
+
store.fixRunBilling(runId, acpBilling ?? (authMode === "api-key" ? "api-key" : evidence ?? billingOf(spec.provider, store.handle)), new Date());
|
|
519
720
|
const envelope = adapter.parse(result.stdout);
|
|
721
|
+
store.stampAgentReportedUsage(runId, envelope.agentReportedUsage ?? null);
|
|
520
722
|
// An installed CLI too old for a build's own setup refuses its flags
|
|
521
723
|
// before it starts: nothing ran, and the run says what to update.
|
|
522
724
|
const unsupported = result.code !== 0 && envelope.initObserved !== true && invocation.phase !== "review" ? isolationUnsupported(spec.provider, result.stderr) : null;
|
|
@@ -562,6 +764,8 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
562
764
|
...(envelope.tokensOut === null ? {} : { tokensOut: envelope.tokensOut + (options.accumulateUsage ? run.tokensOut ?? 0 : 0) }),
|
|
563
765
|
...(costUsd === null ? {} : { costUsd: costUsd + (options.accumulateUsage ? run.costUsd ?? 0 : 0) }),
|
|
564
766
|
...(usageJson === null ? {} : { usageJson }),
|
|
767
|
+
// How many agent turns it took, where the provider reports it (docs/plans/run-efficiency.md); unknown stays NULL.
|
|
768
|
+
...(envelope.ending?.turns == null ? {} : { agentTurns: envelope.ending.turns + (options.accumulateUsage ? run.agentTurns ?? 0 : 0) }),
|
|
565
769
|
});
|
|
566
770
|
// How the attempt ended, classified HERE where the structural terminal
|
|
567
771
|
// still exists; a sign-in that no longer works pauses its provider.
|
|
@@ -589,6 +793,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
589
793
|
providerVersion: attested === null ? null : attested.version,
|
|
590
794
|
diagnostic: envelope.protocolError,
|
|
591
795
|
finalMessage: envelope.finalMessage,
|
|
796
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
592
797
|
};
|
|
593
798
|
}
|
|
594
799
|
// The minted-identity proof (A5): once the harness initialized, the id
|
|
@@ -606,6 +811,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
606
811
|
? "the harness initialized without announcing its session id"
|
|
607
812
|
: "the harness announced a session id different from the one it was started under",
|
|
608
813
|
finalMessage: envelope.finalMessage,
|
|
814
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
609
815
|
};
|
|
610
816
|
}
|
|
611
817
|
// An early transport announcement, a pre-existing resume identity, and
|
|
@@ -619,6 +825,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
619
825
|
providerVersion: attested === null ? null : attested.version,
|
|
620
826
|
diagnostic: sessionIdentityProblem,
|
|
621
827
|
finalMessage: envelope.finalMessage,
|
|
828
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
622
829
|
};
|
|
623
830
|
}
|
|
624
831
|
// A provider's explicit failure terminal always wins over its process exit
|
|
@@ -631,6 +838,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
631
838
|
providerVersion: attested === null ? null : attested.version,
|
|
632
839
|
diagnostic: "the provider reported a failed terminal despite exiting with code 0",
|
|
633
840
|
finalMessage: envelope.finalMessage,
|
|
841
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
634
842
|
};
|
|
635
843
|
}
|
|
636
844
|
// The terminal contract (A4): when the audit requires it, a zero exit is
|
|
@@ -650,6 +858,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
650
858
|
? "the harness exited 0 without its success terminal — the stream ended mid-protocol"
|
|
651
859
|
: "the harness exited 0 without ever initializing"),
|
|
652
860
|
finalMessage: envelope.finalMessage,
|
|
861
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
653
862
|
};
|
|
654
863
|
}
|
|
655
864
|
// A run that worked proves its provider's sign-in works: a sign-in pause
|
|
@@ -669,7 +878,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
669
878
|
notFound: result.notFound,
|
|
670
879
|
sessionId: envelope.sessionId,
|
|
671
880
|
finalMessage: envelope.finalMessage,
|
|
881
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
672
882
|
structuredOutput: envelope.structuredOutput ?? null,
|
|
883
|
+
...(envelope.acp === undefined ? {} : { acp: { ...envelope.acp, ...(acpIsolation === null ? {} : { isolation: acpIsolation }) } }),
|
|
673
884
|
ending: envelope.ending ?? null,
|
|
674
885
|
usage: {
|
|
675
886
|
tokensIn: envelope.tokensIn,
|
|
@@ -712,7 +923,7 @@ function linkedFor(store, worktree) {
|
|
|
712
923
|
/** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
|
|
713
924
|
* ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
|
|
714
925
|
* (FenceGrants); and what only Claude's file tools refuse. */
|
|
715
|
-
function runFence(store, runId, provider, keyHome, ownTools) {
|
|
926
|
+
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
|
|
716
927
|
try {
|
|
717
928
|
// A plane always has a database file; an in-memory store (a test) has nothing to fence.
|
|
718
929
|
const databaseFile = store.databaseFile();
|
|
@@ -727,20 +938,128 @@ function runFence(store, runId, provider, keyHome, ownTools) {
|
|
|
727
938
|
const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
|
|
728
939
|
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
|
|
729
940
|
const folders = runFolders(ownTools);
|
|
941
|
+
// A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
|
|
942
|
+
// Git data, its launch folder, its project's caches and the provider state its CLI must write.
|
|
943
|
+
const allowed = worktree === null || row === null || writes !== "workspace" ? null
|
|
944
|
+
: writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
|
|
945
|
+
const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
|
|
946
|
+
const grants = fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home, poolRoots });
|
|
730
947
|
return {
|
|
731
|
-
fence
|
|
948
|
+
fence,
|
|
732
949
|
fileTools: boundary?.fileTools ?? [],
|
|
733
950
|
// The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
|
|
734
951
|
// Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
|
|
735
952
|
// 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:
|
|
737
|
-
|
|
953
|
+
grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
|
|
954
|
+
// The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
|
|
955
|
+
gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
|
|
956
|
+
...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
|
|
738
957
|
};
|
|
739
958
|
}
|
|
740
959
|
catch (error) {
|
|
741
960
|
return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
|
|
742
961
|
}
|
|
743
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
|
|
965
|
+
* credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
|
|
966
|
+
* its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
|
|
967
|
+
* Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
|
|
968
|
+
* (a checkout whose binding no longer proves) runs nothing.
|
|
969
|
+
*/
|
|
970
|
+
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
971
|
+
// An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
|
|
972
|
+
if (inner !== runCommand)
|
|
973
|
+
return inner;
|
|
974
|
+
return async (file, args, options) => {
|
|
975
|
+
// As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
976
|
+
if (!available()) {
|
|
977
|
+
if (store.unfencedAgentsAllowed())
|
|
978
|
+
return inner(file, args, options);
|
|
979
|
+
return { code: 1, stdout: "", stderr: "This machine can't confine the build's commands (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
|
|
980
|
+
}
|
|
981
|
+
const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
|
|
982
|
+
try {
|
|
983
|
+
const temp = join(own, "tmp");
|
|
984
|
+
mkdirSync(temp, { mode: 0o700 });
|
|
985
|
+
const fenced = runFence(store, runId, "command", undefined, own, writes);
|
|
986
|
+
if (fenced.problem !== undefined)
|
|
987
|
+
return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
|
|
988
|
+
if (fenced.fence.length === 0)
|
|
989
|
+
return inner(file, args, options);
|
|
990
|
+
// A self-hosted check's own database (child-database.ts) is the command's to write too.
|
|
991
|
+
const database = options?.env?.["TOOLROLL_DB"];
|
|
992
|
+
const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
|
|
993
|
+
const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
|
|
994
|
+
return await inner(file, args, {
|
|
995
|
+
...options,
|
|
996
|
+
fence: fenced.fence,
|
|
997
|
+
fenceGrants: grants,
|
|
998
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
rmSync(own, { recursive: true, force: true });
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
|
|
1008
|
+
* the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
|
|
1009
|
+
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
1010
|
+
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
1011
|
+
*/
|
|
1012
|
+
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
1013
|
+
if (inner !== runCommand)
|
|
1014
|
+
return inner;
|
|
1015
|
+
return async (file, args, options) => {
|
|
1016
|
+
if (!available()) {
|
|
1017
|
+
if (store.unfencedAgentsAllowed())
|
|
1018
|
+
return inner(file, args, options);
|
|
1019
|
+
return { code: 1, stdout: "", stderr: "This machine can't confine the project's checks (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
|
|
1020
|
+
}
|
|
1021
|
+
const databaseFile = store.databaseFile();
|
|
1022
|
+
if (databaseFile === null)
|
|
1023
|
+
return inner(file, args, options);
|
|
1024
|
+
const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
|
|
1025
|
+
try {
|
|
1026
|
+
const temp = join(own, "tmp");
|
|
1027
|
+
mkdirSync(temp, { mode: 0o700 });
|
|
1028
|
+
let binding;
|
|
1029
|
+
try {
|
|
1030
|
+
binding = trustedWorktreeBinding(repo, checkout);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
|
|
1034
|
+
}
|
|
1035
|
+
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1036
|
+
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
|
|
1037
|
+
const folders = runFolders(own);
|
|
1038
|
+
const cache = buildCacheFolder(repo);
|
|
1039
|
+
const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
|
|
1040
|
+
const database = options?.env?.["TOOLROLL_DB"];
|
|
1041
|
+
const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
|
|
1042
|
+
const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
|
|
1043
|
+
const grants = allowed === null ? base
|
|
1044
|
+
: { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
|
|
1045
|
+
return await inner(file, args, {
|
|
1046
|
+
...options,
|
|
1047
|
+
fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
|
|
1048
|
+
fenceGrants: grants,
|
|
1049
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
rmSync(own, { recursive: true, force: true });
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/** What a build writes that a tool server must never run from: its checkout and its project's caches. A checkout with
|
|
1058
|
+
* no record never gets this far: its fence refuses the launch (runFence) before any tool server can start. */
|
|
1059
|
+
function toolUntrusted(store, workspace, keyHome) {
|
|
1060
|
+
const row = store.getWorktree(workspace);
|
|
1061
|
+
return [workspace, ...(row === null ? [] : [buildCacheFolder(row.repo, keyHome ?? homedir())])];
|
|
1062
|
+
}
|
|
744
1063
|
/** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
|
|
745
1064
|
function inheritedDefaults(provider, env, home) {
|
|
746
1065
|
if (provider === "claude")
|