cool-workflow 0.1.78
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/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/mcp.json +10 -0
- package/.codex-plugin/plugin.json +38 -0
- package/.mcp.json +10 -0
- package/LICENSE +24 -0
- package/README.md +638 -0
- package/apps/architecture-review/app.json +51 -0
- package/apps/architecture-review/workflow.js +116 -0
- package/apps/end-to-end-golden-path/app.json +30 -0
- package/apps/end-to-end-golden-path/workflow.js +33 -0
- package/apps/pr-review-fix-ci/app.json +59 -0
- package/apps/pr-review-fix-ci/workflow.js +90 -0
- package/apps/release-cut/app.json +54 -0
- package/apps/release-cut/workflow.js +82 -0
- package/apps/research-synthesis/app.json +50 -0
- package/apps/research-synthesis/workflow.js +76 -0
- package/apps/workflow-app-framework-demo/app.json +29 -0
- package/apps/workflow-app-framework-demo/workflow.js +44 -0
- package/dist/agent-config.js +223 -0
- package/dist/candidate-scoring.js +715 -0
- package/dist/capability-core.js +630 -0
- package/dist/capability-dispatcher.js +86 -0
- package/dist/capability-registry.js +523 -0
- package/dist/cli.js +1276 -0
- package/dist/collaboration.js +727 -0
- package/dist/commit.js +570 -0
- package/dist/contract-migration.js +234 -0
- package/dist/coordinator.js +1163 -0
- package/dist/daemon.js +44 -0
- package/dist/dispatch.js +201 -0
- package/dist/drive.js +503 -0
- package/dist/error-feedback.js +415 -0
- package/dist/evidence-grounding.js +179 -0
- package/dist/evidence-reasoning.js +733 -0
- package/dist/execution-backend.js +1279 -0
- package/dist/harness.js +61 -0
- package/dist/mcp-server.js +1615 -0
- package/dist/multi-agent-eval.js +857 -0
- package/dist/multi-agent-host.js +764 -0
- package/dist/multi-agent-operator-ux.js +537 -0
- package/dist/multi-agent-trust.js +366 -0
- package/dist/multi-agent.js +1173 -0
- package/dist/node-snapshot.js +270 -0
- package/dist/observability.js +922 -0
- package/dist/operator-ux.js +971 -0
- package/dist/orchestrator/audit-operations.js +182 -0
- package/dist/orchestrator/candidate-operations.js +117 -0
- package/dist/orchestrator/cli-options.js +288 -0
- package/dist/orchestrator/collaboration-operations.js +86 -0
- package/dist/orchestrator/feedback-operations.js +81 -0
- package/dist/orchestrator/host-operations.js +78 -0
- package/dist/orchestrator/lifecycle-operations.js +462 -0
- package/dist/orchestrator/migration-operations.js +44 -0
- package/dist/orchestrator/multi-agent-operations.js +362 -0
- package/dist/orchestrator/report.js +369 -0
- package/dist/orchestrator/topology-operations.js +84 -0
- package/dist/orchestrator.js +874 -0
- package/dist/pipeline-contract.js +92 -0
- package/dist/pipeline-runner.js +285 -0
- package/dist/reclamation.js +882 -0
- package/dist/result-normalize.js +194 -0
- package/dist/run-export.js +64 -0
- package/dist/run-registry.js +1347 -0
- package/dist/run-state-schema.js +67 -0
- package/dist/sandbox-profile.js +471 -0
- package/dist/scheduler.js +266 -0
- package/dist/scheduling.js +184 -0
- package/dist/schema-validate.js +98 -0
- package/dist/state-explosion.js +1213 -0
- package/dist/state-migrations.js +463 -0
- package/dist/state-node.js +301 -0
- package/dist/state.js +308 -0
- package/dist/telemetry-attestation.js +156 -0
- package/dist/telemetry-ledger.js +145 -0
- package/dist/topology.js +527 -0
- package/dist/triggers.js +159 -0
- package/dist/trust-audit.js +475 -0
- package/dist/types/blackboard.js +2 -0
- package/dist/types/boundary.js +29 -0
- package/dist/types/candidate.js +2 -0
- package/dist/types/collaboration.js +2 -0
- package/dist/types/core.js +2 -0
- package/dist/types/drive.js +10 -0
- package/dist/types/error-feedback.js +2 -0
- package/dist/types/evidence-reasoning.js +2 -0
- package/dist/types/execution-backend.js +2 -0
- package/dist/types/multi-agent.js +2 -0
- package/dist/types/observability.js +2 -0
- package/dist/types/pipeline.js +2 -0
- package/dist/types/reclamation.js +8 -0
- package/dist/types/result.js +2 -0
- package/dist/types/run-registry.js +2 -0
- package/dist/types/run.js +2 -0
- package/dist/types/sandbox.js +2 -0
- package/dist/types/schedule.js +2 -0
- package/dist/types/state-node.js +2 -0
- package/dist/types/topology.js +2 -0
- package/dist/types/trust.js +2 -0
- package/dist/types/workbench.js +2 -0
- package/dist/types/worker.js +2 -0
- package/dist/types/workflow-app.js +2 -0
- package/dist/types.js +43 -0
- package/dist/verifier-registry.js +46 -0
- package/dist/verifier.js +78 -0
- package/dist/version.js +8 -0
- package/dist/workbench-host.js +172 -0
- package/dist/workbench.js +190 -0
- package/dist/worker-isolation.js +1028 -0
- package/dist/workflow-api.js +98 -0
- package/dist/workflow-app-framework.js +626 -0
- package/docs/agent-delegation-drive.7.md +190 -0
- package/docs/agent-framework.md +176 -0
- package/docs/candidate-scoring.7.md +106 -0
- package/docs/canonical-workflow-apps.7.md +137 -0
- package/docs/capability-topology-registry.7.md +168 -0
- package/docs/cli-mcp-parity.7.md +373 -0
- package/docs/contract-migration-tooling.7.md +123 -0
- package/docs/control-plane-scheduling.7.md +110 -0
- package/docs/coordinator-blackboard.7.md +183 -0
- package/docs/dogfood/architecture-review-cool-workflow.md +16 -0
- package/docs/dogfood-one-real-repo.7.md +168 -0
- package/docs/durable-state-and-locking.7.md +107 -0
- package/docs/end-to-end-golden-path.7.md +117 -0
- package/docs/error-feedback.7.md +153 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +270 -0
- package/docs/execution-backends.7.md +300 -0
- package/docs/getting-started.md +99 -0
- package/docs/index.md +41 -0
- package/docs/mcp-app-surface.7.md +235 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +265 -0
- package/docs/multi-agent-eval-replay-harness.7.md +302 -0
- package/docs/multi-agent-operator-ux.7.md +314 -0
- package/docs/multi-agent-runtime-core.7.md +231 -0
- package/docs/multi-agent-topologies.7.md +103 -0
- package/docs/multi-agent-trust-policy-audit.7.md +154 -0
- package/docs/node-snapshot-diff-replay.7.md +135 -0
- package/docs/observability-cost-accounting.7.md +194 -0
- package/docs/operator-ux.7.md +180 -0
- package/docs/pipeline-runner.7.md +136 -0
- package/docs/project-index.md +261 -0
- package/docs/real-execution-backends.7.md +142 -0
- package/docs/release-and-migration.7.md +280 -0
- package/docs/release-tooling.7.md +159 -0
- package/docs/routines.md +48 -0
- package/docs/run-registry-control-plane.7.md +312 -0
- package/docs/run-retention-reclamation.7.md +191 -0
- package/docs/sandbox-profiles.7.md +137 -0
- package/docs/scheduled-tasks.md +80 -0
- package/docs/security-trust-hardening.7.md +117 -0
- package/docs/state-explosion-management.7.md +264 -0
- package/docs/state-node.7.md +96 -0
- package/docs/team-collaboration.7.md +207 -0
- package/docs/unix-principles.md +192 -0
- package/docs/verifier-gated-commit.7.md +140 -0
- package/docs/web-desktop-workbench.7.md +215 -0
- package/docs/worker-isolation.7.md +167 -0
- package/docs/workflow-app-framework.7.md +274 -0
- package/manifest/README.md +43 -0
- package/manifest/plugin.manifest.json +316 -0
- package/manifest/pricing.policy.json +14 -0
- package/package.json +79 -0
- package/scripts/agents/claude-p-agent.js +104 -0
- package/scripts/agents/claude-p-agent.sh +9 -0
- package/scripts/agents/cw-attest-keygen.js +55 -0
- package/scripts/agents/cw-attest-wrap.js +143 -0
- package/scripts/block-unapproved-tag.sh +39 -0
- package/scripts/bump-version.js +249 -0
- package/scripts/canonical-apps.js +171 -0
- package/scripts/cw.js +4 -0
- package/scripts/dist-drift-check.js +79 -0
- package/scripts/dogfood-architecture-review.js +237 -0
- package/scripts/dogfood-release.js +624 -0
- package/scripts/forward-ref-docs.js +73 -0
- package/scripts/gen-manifests.js +232 -0
- package/scripts/golden-path.js +300 -0
- package/scripts/mcp-server.js +4 -0
- package/scripts/new-feature.js +121 -0
- package/scripts/parity-check.js +213 -0
- package/scripts/release-check.js +118 -0
- package/scripts/release-flow.js +272 -0
- package/scripts/release-gate.sh +85 -0
- package/scripts/sync-project-index.js +387 -0
- package/scripts/validate-run-state-schema.js +126 -0
- package/scripts/verify-container-selfref.js +64 -0
- package/scripts/version-sync-check.js +237 -0
- package/skills/cool-workflow/SKILL.md +162 -0
- package/skills/cool-workflow/references/commands.md +282 -0
- package/tsconfig.json +16 -0
- package/ui/workbench/app.css +76 -0
- package/ui/workbench/app.js +159 -0
- package/ui/workbench/index.html +32 -0
- package/workflows/architecture-review.workflow.js +84 -0
- package/workflows/research-synthesis.workflow.js +47 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Execution Backends (v0.1.29) — the driver layer.
|
|
3
|
+
//
|
|
4
|
+
// BSD discipline, modeled on a VFS / device-driver layer:
|
|
5
|
+
// - MECHANISM vs POLICY. ONE narrow `ExecutionBackend` contract (mechanism);
|
|
6
|
+
// many interchangeable drivers (node/bun/shell/container/remote/ci). The kernel
|
|
7
|
+
// (orchestrator/dispatch/pipeline-runner) never learns which backend ran a
|
|
8
|
+
// task. WHAT to run and which evidence to record is kernel policy; HOW/WHERE it
|
|
9
|
+
// runs is the driver's concern.
|
|
10
|
+
// - THE SANDBOX PROFILE IS THE CONTRACT. Every backend maps the resolved sandbox
|
|
11
|
+
// profile's five dimensions (read/write/command/network/env) onto its own
|
|
12
|
+
// enforcement and ATTESTS what it actually enforced. A backend that can neither
|
|
13
|
+
// enforce nor attest a required dimension — or that is not ready — FAILS CLOSED
|
|
14
|
+
// and refuses to run. It never silently downgrades to unsandboxed execution.
|
|
15
|
+
// - IDENTICAL ENVELOPES, ANY BACKEND. `runBackend` returns a canonical
|
|
16
|
+
// ExecutionResultEnvelope whose `result`/`evidence` are schema-identical and
|
|
17
|
+
// byte-stable across backends for the same task; the backend id + sandbox
|
|
18
|
+
// attestation are recorded AS provenance.
|
|
19
|
+
// - CW DELEGATES, IT DOES NOT BECOME THE EXECUTOR. The local drivers run a thin
|
|
20
|
+
// child process to capture verifiable evidence (exit + output digest). The
|
|
21
|
+
// container/remote/ci drivers DELEGATE and record a handle + attestation +
|
|
22
|
+
// result; they never reimplement a container runtime or a CI system.
|
|
23
|
+
//
|
|
24
|
+
// See docs/execution-backends.7.md.
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.BackendError = exports.SANDBOX_DIMENSIONS = exports.DEFAULT_BACKEND_ID = exports.EXECUTION_BACKEND_SCHEMA_VERSION = void 0;
|
|
30
|
+
exports.registerBackend = registerBackend;
|
|
31
|
+
exports.getBackendDriver = getBackendDriver;
|
|
32
|
+
exports.listBackendDescriptors = listBackendDescriptors;
|
|
33
|
+
exports.backendIds = backendIds;
|
|
34
|
+
exports.isBackendId = isBackendId;
|
|
35
|
+
exports.getBackendDescriptor = getBackendDescriptor;
|
|
36
|
+
exports.resolveBackendSelection = resolveBackendSelection;
|
|
37
|
+
exports.backendSelectionFrom = backendSelectionFrom;
|
|
38
|
+
exports.requiredSandboxDimensions = requiredSandboxDimensions;
|
|
39
|
+
exports.attestSandbox = attestSandbox;
|
|
40
|
+
exports.probeBackend = probeBackend;
|
|
41
|
+
exports.runBackend = runBackend;
|
|
42
|
+
exports.stripSecretArgs = stripSecretArgs;
|
|
43
|
+
exports.prepareAgentSpawn = prepareAgentSpawn;
|
|
44
|
+
exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
|
|
45
|
+
exports.createExecutionBackend = createExecutionBackend;
|
|
46
|
+
exports.listExecutionBackends = listExecutionBackends;
|
|
47
|
+
exports.backendListPayload = backendListPayload;
|
|
48
|
+
exports.backendShowPayload = backendShowPayload;
|
|
49
|
+
exports.backendProbePayload = backendProbePayload;
|
|
50
|
+
exports.buildChildEnv = buildChildEnv;
|
|
51
|
+
exports.sha256 = sha256;
|
|
52
|
+
exports.clearProbeCache = clearProbeCache;
|
|
53
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
54
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
55
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
56
|
+
const node_child_process_1 = require("node:child_process");
|
|
57
|
+
exports.EXECUTION_BACKEND_SCHEMA_VERSION = 1;
|
|
58
|
+
exports.DEFAULT_BACKEND_ID = "node";
|
|
59
|
+
exports.SANDBOX_DIMENSIONS = ["read", "write", "command", "network", "env"];
|
|
60
|
+
class BackendError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
details;
|
|
63
|
+
constructor(code, message, details) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "BackendError";
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.details = details;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.BackendError = BackendError;
|
|
71
|
+
const DRIVER_SPECS = [
|
|
72
|
+
{
|
|
73
|
+
id: "node",
|
|
74
|
+
title: "Node (default)",
|
|
75
|
+
description: "Default backend. Reproduces pre-v0.1.29 behavior exactly: the host runs the worker in-process under CW's worker-output acceptance. When executing a command it enforces the command + env policy via the Node child process and attests OS read/write/network isolation to the host.",
|
|
76
|
+
kind: "local",
|
|
77
|
+
locality: "local",
|
|
78
|
+
default: true,
|
|
79
|
+
readiness: "ready",
|
|
80
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "enforce" }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "bun",
|
|
84
|
+
title: "Bun",
|
|
85
|
+
description: "Bun-friendly backend. Node-compatible by default: it executes via the Node-compatible runtime so evidence is byte-stable with the node backend, and attests Bun availability in provenance. Enforces command + env via the child process; attests read/write/network to the host.",
|
|
86
|
+
kind: "local",
|
|
87
|
+
locality: "local",
|
|
88
|
+
default: false,
|
|
89
|
+
delegate: "bun",
|
|
90
|
+
readiness: "ready",
|
|
91
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "enforce" }
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "shell",
|
|
95
|
+
title: "Shell",
|
|
96
|
+
description: "Runs a command/worker via the system shell (/bin/sh -c) under the sandbox contract. Enforces command + env via the child process; attests read/write/network to the host.",
|
|
97
|
+
kind: "local",
|
|
98
|
+
locality: "local",
|
|
99
|
+
default: false,
|
|
100
|
+
delegate: "/bin/sh",
|
|
101
|
+
readiness: "ready",
|
|
102
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "enforce" }
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "container",
|
|
106
|
+
title: "Container",
|
|
107
|
+
description: "Delegates execution to a container runtime (docker/podman) and records the image@digest handle + attestation + result. A container can enforce all five dimensions via mounts, dropped capabilities, a network namespace, and a filtered env. Fails closed when no image is supplied or no runtime is present.",
|
|
108
|
+
kind: "delegating",
|
|
109
|
+
locality: "local",
|
|
110
|
+
default: false,
|
|
111
|
+
delegate: "docker",
|
|
112
|
+
readiness: "unverified",
|
|
113
|
+
support: { read: "enforce", write: "enforce", command: "enforce", network: "enforce", env: "enforce" }
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "remote",
|
|
117
|
+
title: "Remote Runner",
|
|
118
|
+
description: "Delegates execution to a remote runner and records the endpoint + job handle + attestation + result. Enforces command + env at the remote; attests read/write/network. Fails closed when no endpoint is configured.",
|
|
119
|
+
kind: "delegating",
|
|
120
|
+
locality: "remote",
|
|
121
|
+
default: false,
|
|
122
|
+
delegate: "remote-runner",
|
|
123
|
+
readiness: "unverified",
|
|
124
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "enforce" }
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "ci",
|
|
128
|
+
title: "CI Runner",
|
|
129
|
+
description: "Delegates execution to a CI runner and records the job handle + attestation + result. Enforces command + env in the CI job; attests read/write/network. Fails closed when no CI job target is configured.",
|
|
130
|
+
kind: "delegating",
|
|
131
|
+
locality: "remote",
|
|
132
|
+
default: false,
|
|
133
|
+
delegate: "ci-runner",
|
|
134
|
+
readiness: "unverified",
|
|
135
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "enforce" }
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "agent",
|
|
139
|
+
title: "Agent (external process)",
|
|
140
|
+
description: "Delegates each worker to an EXTERNAL agent process (claude -p / codex exec / an HTTP agent endpoint) and records the agent CHILD's command + exit + stdout digest as the canonical evidence triple, plus a kind:process handle and the agent-reported model + prompt/result digests as provenance. The MODEL runs in the agent's process, NEVER in CW — CW imports no model SDK and holds no API key; it spawns an out-of-process child argv-style (shell:false) or POSTs to a configured endpoint. CW enforces only the exact argv it spawns; the agent host attests read/write/network/env. Fails closed when no command-template/endpoint is configured, on non-zero exit, or on a missing/invalid result.md.",
|
|
141
|
+
kind: "delegating",
|
|
142
|
+
locality: "local",
|
|
143
|
+
default: false,
|
|
144
|
+
delegate: "agent-process",
|
|
145
|
+
readiness: "unverified",
|
|
146
|
+
support: { read: "attest", write: "attest", command: "enforce", network: "attest", env: "attest" }
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
function specCapabilities(spec) {
|
|
150
|
+
return exports.SANDBOX_DIMENSIONS.map((dimension) => ({ dimension, support: spec.support[dimension] }));
|
|
151
|
+
}
|
|
152
|
+
function specDescriptor(spec) {
|
|
153
|
+
const capabilities = specCapabilities(spec);
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
id: spec.id,
|
|
157
|
+
title: spec.title,
|
|
158
|
+
description: spec.description,
|
|
159
|
+
kind: spec.kind,
|
|
160
|
+
locality: spec.locality,
|
|
161
|
+
default: spec.default,
|
|
162
|
+
capabilities,
|
|
163
|
+
enforces: capabilities.filter((cap) => cap.support === "enforce").map((cap) => cap.dimension),
|
|
164
|
+
attests: capabilities.filter((cap) => cap.support === "attest").map((cap) => cap.dimension),
|
|
165
|
+
delegate: spec.delegate,
|
|
166
|
+
readiness: spec.readiness
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const BACKEND_REGISTRY = new Map();
|
|
170
|
+
/** Register (or override) a backend driver. The public extension seam. */
|
|
171
|
+
function registerBackend(driver) {
|
|
172
|
+
BACKEND_REGISTRY.set(driver.spec.id, driver);
|
|
173
|
+
}
|
|
174
|
+
function getBackendDriver(id) {
|
|
175
|
+
return BACKEND_REGISTRY.get(id);
|
|
176
|
+
}
|
|
177
|
+
function registeredDrivers() {
|
|
178
|
+
return [...BACKEND_REGISTRY.values()];
|
|
179
|
+
}
|
|
180
|
+
function listBackendDescriptors() {
|
|
181
|
+
return registeredDrivers()
|
|
182
|
+
.map((driver) => specDescriptor(driver.spec))
|
|
183
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
184
|
+
}
|
|
185
|
+
function backendIds() {
|
|
186
|
+
return registeredDrivers()
|
|
187
|
+
.map((driver) => driver.spec.id)
|
|
188
|
+
.sort();
|
|
189
|
+
}
|
|
190
|
+
function isBackendId(id) {
|
|
191
|
+
return Boolean(id) && BACKEND_REGISTRY.has(id);
|
|
192
|
+
}
|
|
193
|
+
function getBackendDescriptor(id) {
|
|
194
|
+
const driver = BACKEND_REGISTRY.get(id);
|
|
195
|
+
if (!driver) {
|
|
196
|
+
throw new BackendError("backend-not-found", `Execution backend not found: ${id}`, { backendId: id, available: backendIds() });
|
|
197
|
+
}
|
|
198
|
+
return specDescriptor(driver.spec);
|
|
199
|
+
}
|
|
200
|
+
// Register the built-in drivers, each as a COMPLETE self-description: spec +
|
|
201
|
+
// every behavior that used to live behind a `descriptor.id === "..."` branch
|
|
202
|
+
// (spawn style, runtime note, delegate runner, handle builder, commandless flag,
|
|
203
|
+
// readiness probe). Adding a backend now means registerBackend({ spec, ...behaviors })
|
|
204
|
+
// — no central switch to edit. Function declarations below are hoisted, so the
|
|
205
|
+
// closures resolve at call time.
|
|
206
|
+
const BUILTIN_DRIVER_BEHAVIORS = {
|
|
207
|
+
node: { spawnStyle: "direct", runtimeNote: () => "node", probe: probeNodeBackend },
|
|
208
|
+
bun: {
|
|
209
|
+
spawnStyle: "direct",
|
|
210
|
+
runtimeNote: () => (hasExecutable("bun") ? "bun (node-compatible execution)" : "node-compatible (bun not installed)"),
|
|
211
|
+
probe: probeBunBackend
|
|
212
|
+
},
|
|
213
|
+
shell: { spawnStyle: "shell", runtimeNote: () => "posix-shell", probe: probeShellBackend },
|
|
214
|
+
container: { delegateRun: ctxDelegate(runContainer), buildHandle: containerHandle, probe: probeContainerBackend },
|
|
215
|
+
remote: { delegateRun: ctxDelegate(runHttpDelegation), buildHandle: remoteHandle, probe: probeRemoteBackend },
|
|
216
|
+
ci: { delegateRun: ctxDelegate(runHttpDelegation), buildHandle: ciHandle, probe: probeCiBackend },
|
|
217
|
+
agent: {
|
|
218
|
+
delegateRun: ctxDelegate(runAgentProcess),
|
|
219
|
+
buildHandle: agentHandle,
|
|
220
|
+
commandlessDelegate: true,
|
|
221
|
+
probe: probeAgentBackend
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
for (const spec of DRIVER_SPECS) {
|
|
225
|
+
registerBackend({ spec, ...(BUILTIN_DRIVER_BEHAVIORS[spec.id] || {}) });
|
|
226
|
+
}
|
|
227
|
+
function ctxDelegate(impl) {
|
|
228
|
+
return (ctx) => impl(ctx.descriptor, ctx.policy, ctx.request, ctx.label, ctx.handle, ctx.attestation);
|
|
229
|
+
}
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Selection & resolution. `--backend <id>` (flag) > CW_BACKEND (env) > default.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
function resolveBackendSelection(requested, env = process.env) {
|
|
234
|
+
const normalizedRequested = requested && requested.trim() ? requested.trim() : undefined;
|
|
235
|
+
if (normalizedRequested) {
|
|
236
|
+
if (!isBackendId(normalizedRequested)) {
|
|
237
|
+
throw new BackendError("backend-not-found", `Unknown execution backend: ${normalizedRequested}`, {
|
|
238
|
+
backendId: normalizedRequested,
|
|
239
|
+
available: backendIds()
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return { backendId: normalizedRequested, source: "flag", requested: normalizedRequested };
|
|
243
|
+
}
|
|
244
|
+
const envBackend = env.CW_BACKEND && env.CW_BACKEND.trim() ? env.CW_BACKEND.trim() : undefined;
|
|
245
|
+
if (envBackend) {
|
|
246
|
+
if (!isBackendId(envBackend)) {
|
|
247
|
+
throw new BackendError("backend-not-found", `Unknown execution backend in CW_BACKEND: ${envBackend}`, {
|
|
248
|
+
backendId: envBackend,
|
|
249
|
+
available: backendIds()
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return { backendId: envBackend, source: "env", requested: envBackend };
|
|
253
|
+
}
|
|
254
|
+
return { backendId: exports.DEFAULT_BACKEND_ID, source: "default" };
|
|
255
|
+
}
|
|
256
|
+
function backendSelectionFrom(args, env = process.env) {
|
|
257
|
+
const requested = firstString(args.backend, args.backendId, args.executionBackend);
|
|
258
|
+
return resolveBackendSelection(requested, env);
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Sandbox dimension mapping + attestation. The sandbox profile is the contract.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
/** The dimensions a resolved profile requires to be restricted. */
|
|
264
|
+
function requiredSandboxDimensions(policy) {
|
|
265
|
+
const required = [];
|
|
266
|
+
// read/write are always bounded for a resolved CW policy (path allowlist +
|
|
267
|
+
// worker-output acceptance), so they are always required.
|
|
268
|
+
required.push("read");
|
|
269
|
+
required.push("write");
|
|
270
|
+
if (policy.execute.mode !== "any")
|
|
271
|
+
required.push("command");
|
|
272
|
+
if (policy.network.mode !== "any")
|
|
273
|
+
required.push("network");
|
|
274
|
+
if (policy.env.inherit === false)
|
|
275
|
+
required.push("env");
|
|
276
|
+
return required;
|
|
277
|
+
}
|
|
278
|
+
function attestSandbox(descriptor, policy, options = { mode: "execute" }) {
|
|
279
|
+
const required = requiredSandboxDimensions(policy);
|
|
280
|
+
const supportByDimension = new Map(descriptor.capabilities.map((cap) => [cap.dimension, cap.support]));
|
|
281
|
+
const enforced = [];
|
|
282
|
+
const attested = [];
|
|
283
|
+
const unenforceable = [];
|
|
284
|
+
for (const dimension of required) {
|
|
285
|
+
const declared = supportByDimension.get(dimension) || "unsupported";
|
|
286
|
+
let effective = declared;
|
|
287
|
+
if (options.mode === "delegate-host" && declared !== "unsupported") {
|
|
288
|
+
// The host runs the worker; CW enforces only worker-output acceptance
|
|
289
|
+
// (write). Everything else is attested to the host.
|
|
290
|
+
effective = dimension === "write" ? "enforce" : "attest";
|
|
291
|
+
}
|
|
292
|
+
if (effective === "enforce")
|
|
293
|
+
enforced.push(dimension);
|
|
294
|
+
else if (effective === "attest")
|
|
295
|
+
attested.push(dimension);
|
|
296
|
+
else
|
|
297
|
+
unenforceable.push(dimension);
|
|
298
|
+
}
|
|
299
|
+
const refusedForReadiness = options.ready === false;
|
|
300
|
+
const status = unenforceable.length || refusedForReadiness ? "refused" : enforced.length ? "enforced" : "attested";
|
|
301
|
+
return {
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
backendId: descriptor.id,
|
|
304
|
+
locality: descriptor.locality,
|
|
305
|
+
kind: descriptor.kind,
|
|
306
|
+
sandboxProfileId: policy.id,
|
|
307
|
+
required,
|
|
308
|
+
enforced,
|
|
309
|
+
attested,
|
|
310
|
+
unenforceable,
|
|
311
|
+
status,
|
|
312
|
+
enforcedByCW: policy.enforcement.enforcedByCW,
|
|
313
|
+
hostRequired: policy.enforcement.hostRequired,
|
|
314
|
+
recordedAt: options.recordedAt || new Date().toISOString(),
|
|
315
|
+
handle: options.handle,
|
|
316
|
+
notes: options.notes
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Readiness probe. Deterministic given the host (PATH + configured env).
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
function probeBackend(id, context = {}) {
|
|
323
|
+
const descriptor = getBackendDescriptor(id);
|
|
324
|
+
const driver = BACKEND_REGISTRY.get(id);
|
|
325
|
+
// The driver owns its readiness checks; probeBackend just wraps them with the
|
|
326
|
+
// descriptor-derived envelope. A driver with no probe is unverified by default.
|
|
327
|
+
const body = driver?.probe
|
|
328
|
+
? driver.probe(context)
|
|
329
|
+
: { checks: [], readiness: descriptor.readiness };
|
|
330
|
+
return {
|
|
331
|
+
schemaVersion: 1,
|
|
332
|
+
backendId: descriptor.id,
|
|
333
|
+
locality: descriptor.locality,
|
|
334
|
+
kind: descriptor.kind,
|
|
335
|
+
readiness: body.readiness,
|
|
336
|
+
ready: body.readiness === "ready",
|
|
337
|
+
enforces: descriptor.enforces,
|
|
338
|
+
attests: descriptor.attests,
|
|
339
|
+
checks: body.checks,
|
|
340
|
+
reason: body.reason
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function probeNodeBackend() {
|
|
344
|
+
const ok = hasExecutable("node");
|
|
345
|
+
return {
|
|
346
|
+
checks: [{ name: "node-runtime", ok, detail: ok ? "node on PATH" : "node not found on PATH" }],
|
|
347
|
+
readiness: ok ? "ready" : "unavailable",
|
|
348
|
+
reason: ok ? undefined : "node runtime not found on PATH"
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function probeShellBackend() {
|
|
352
|
+
const ok = hasExecutable("sh") || node_fs_1.default.existsSync("/bin/sh");
|
|
353
|
+
return {
|
|
354
|
+
checks: [{ name: "posix-shell", ok, detail: ok ? "sh available" : "no POSIX shell found" }],
|
|
355
|
+
readiness: ok ? "ready" : "unavailable",
|
|
356
|
+
reason: ok ? undefined : "POSIX shell not found"
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function probeBunBackend() {
|
|
360
|
+
const bun = hasExecutable("bun");
|
|
361
|
+
const node = hasExecutable("node");
|
|
362
|
+
return {
|
|
363
|
+
checks: [
|
|
364
|
+
{ name: "bun-runtime", ok: bun, detail: bun ? "bun on PATH" : "bun not found; node-compatible fallback" },
|
|
365
|
+
{ name: "node-compatible-fallback", ok: node, detail: node ? "node on PATH" : "node not found on PATH" }
|
|
366
|
+
],
|
|
367
|
+
readiness: bun || node ? "ready" : "unavailable",
|
|
368
|
+
reason: !bun && node ? "bun not installed; executing via node-compatible runtime" : !bun && !node ? "neither bun nor node found on PATH" : undefined
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function probeContainerBackend() {
|
|
372
|
+
const docker = hasExecutable("docker");
|
|
373
|
+
const podman = hasExecutable("podman");
|
|
374
|
+
return {
|
|
375
|
+
checks: [
|
|
376
|
+
{ name: "docker", ok: docker, detail: docker ? "docker on PATH" : "docker not found" },
|
|
377
|
+
{ name: "podman", ok: podman, detail: podman ? "podman on PATH" : "podman not found" }
|
|
378
|
+
],
|
|
379
|
+
readiness: docker || podman ? "ready" : "unavailable",
|
|
380
|
+
reason: docker || podman ? undefined : "no container runtime (docker/podman) found; supply --image to delegate explicitly"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function probeRemoteBackend() {
|
|
384
|
+
const endpoint = (process.env.CW_REMOTE_ENDPOINT || "").trim();
|
|
385
|
+
return {
|
|
386
|
+
checks: [{ name: "endpoint", ok: Boolean(endpoint), detail: endpoint ? "CW_REMOTE_ENDPOINT configured" : "CW_REMOTE_ENDPOINT not set" }],
|
|
387
|
+
readiness: endpoint ? "ready" : "unverified",
|
|
388
|
+
reason: endpoint ? undefined : "no remote endpoint configured (set CW_REMOTE_ENDPOINT or pass --endpoint)"
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function probeCiBackend() {
|
|
392
|
+
const endpoint = (process.env.CW_CI_ENDPOINT || "").trim();
|
|
393
|
+
return {
|
|
394
|
+
checks: [{ name: "ci-endpoint", ok: Boolean(endpoint), detail: endpoint ? "CW_CI_ENDPOINT configured" : "CW_CI_ENDPOINT not set" }],
|
|
395
|
+
readiness: endpoint ? "ready" : "unverified",
|
|
396
|
+
reason: endpoint ? undefined : "no CI job target configured (set CW_CI_ENDPOINT or pass --job)"
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function probeAgentBackend() {
|
|
400
|
+
// Mirrors remote/ci EXACTLY: unconfigured ⇒ `unverified` (NOT a hard refusal),
|
|
401
|
+
// configured ⇒ `ready`. "Configured" = a command-template or endpoint is set.
|
|
402
|
+
const command = (process.env.CW_AGENT_COMMAND || "").trim();
|
|
403
|
+
const endpoint = (process.env.CW_AGENT_ENDPOINT || "").trim();
|
|
404
|
+
const configured = Boolean(command || endpoint);
|
|
405
|
+
return {
|
|
406
|
+
checks: [
|
|
407
|
+
{ name: "agent-command", ok: Boolean(command), detail: command ? "CW_AGENT_COMMAND configured" : "CW_AGENT_COMMAND not set" },
|
|
408
|
+
{ name: "agent-endpoint", ok: Boolean(endpoint), detail: endpoint ? "CW_AGENT_ENDPOINT configured" : "CW_AGENT_ENDPOINT not set" }
|
|
409
|
+
],
|
|
410
|
+
readiness: configured ? "ready" : "unverified",
|
|
411
|
+
reason: configured ? undefined : "no agent configured (set CW_AGENT_COMMAND or CW_AGENT_ENDPOINT, or pass --agent-command/--agent-endpoint)"
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// The run entry. Refuses (fail closed) when the sandbox cannot be honored, the
|
|
416
|
+
// command is denied by policy, or the backend is not ready. Local drivers spawn a
|
|
417
|
+
// thin child process; delegating drivers record a handle and never execute here.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
function runBackend(request) {
|
|
420
|
+
const descriptor = getBackendDescriptor(request.backendId);
|
|
421
|
+
const policy = request.sandboxPolicy;
|
|
422
|
+
const label = request.label || request.command || `${descriptor.id}-execution`;
|
|
423
|
+
const probe = probeBackend(descriptor.id, { cwd: request.cwd });
|
|
424
|
+
// 1. Command policy. A profile that denies commands (execute.mode "none" or an
|
|
425
|
+
// allowlist miss) must refuse — never run an out-of-policy command.
|
|
426
|
+
if (request.command) {
|
|
427
|
+
const denied = commandDenied(policy, `${request.command} ${(request.args || []).join(" ")}`.trim());
|
|
428
|
+
if (denied) {
|
|
429
|
+
return refusedEnvelope(descriptor, policy, label, "sandbox-command-denied", denied, { ready: probe.ready });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// 2. Sandbox attestation (execute mode). Any unenforceable required dimension
|
|
433
|
+
// is a fail-closed refusal.
|
|
434
|
+
const attestation = attestSandbox(descriptor, policy, { mode: "execute", ready: probe.ready });
|
|
435
|
+
if (attestation.unenforceable.length) {
|
|
436
|
+
return refusedEnvelope(descriptor, policy, label, "sandbox-unenforceable", `Backend ${descriptor.id} cannot enforce or attest required sandbox dimension(s): ${attestation.unenforceable.join(", ")}`, { ready: probe.ready, attestation });
|
|
437
|
+
}
|
|
438
|
+
// 3. Delegating drivers: delegate + record a handle. No local execution.
|
|
439
|
+
if (descriptor.kind === "delegating") {
|
|
440
|
+
return delegate(descriptor, policy, request, label, probe);
|
|
441
|
+
}
|
|
442
|
+
// 4. Readiness. A local backend that is not ready refuses.
|
|
443
|
+
if (!probe.ready) {
|
|
444
|
+
return refusedEnvelope(descriptor, policy, label, "backend-not-ready", probe.reason || `Backend ${descriptor.id} is not ready`, {
|
|
445
|
+
ready: false,
|
|
446
|
+
attestation
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
// 5. Local execution: spawn a thin child process and capture verifiable
|
|
450
|
+
// evidence (exit code + output digest).
|
|
451
|
+
if (!request.command) {
|
|
452
|
+
return refusedEnvelope(descriptor, policy, label, "no-command", `Backend ${descriptor.id} requires a command to execute`, {
|
|
453
|
+
ready: probe.ready,
|
|
454
|
+
attestation
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return executeLocal(descriptor, policy, request, label, attestation);
|
|
458
|
+
}
|
|
459
|
+
function executeLocal(descriptor, policy, request, label, attestation) {
|
|
460
|
+
const command = String(request.command);
|
|
461
|
+
const args = (request.args || []).map(String);
|
|
462
|
+
const env = buildChildEnv(policy);
|
|
463
|
+
const options = {
|
|
464
|
+
cwd: request.cwd,
|
|
465
|
+
env,
|
|
466
|
+
encoding: "utf8",
|
|
467
|
+
timeout: request.timeoutMs,
|
|
468
|
+
maxBuffer: 32 * 1024 * 1024
|
|
469
|
+
};
|
|
470
|
+
// shell backend runs via /bin/sh -c; node/bun run the command directly
|
|
471
|
+
// (bun is Node-compatible by default so evidence stays byte-stable with node).
|
|
472
|
+
// spawnStyle comes from the registered driver, not a hardcoded id check.
|
|
473
|
+
const result = getBackendDriver(descriptor.id)?.spawnStyle === "shell"
|
|
474
|
+
? (0, node_child_process_1.spawnSync)([command, ...args].join(" "), { ...options, shell: true })
|
|
475
|
+
: (0, node_child_process_1.spawnSync)(command, args, { ...options, shell: false });
|
|
476
|
+
const exitCode = typeof result.status === "number" ? result.status : null;
|
|
477
|
+
const spawnError = result.error ? messageOf(result.error) : undefined;
|
|
478
|
+
const stdout = String(result.stdout || "");
|
|
479
|
+
const digest = sha256(stdout);
|
|
480
|
+
const status = spawnError ? "failed" : exitCode === 0 ? "completed" : "failed";
|
|
481
|
+
const evidence = [
|
|
482
|
+
`command:${[command, ...args].join(" ")}`,
|
|
483
|
+
`exitCode:${exitCode === null ? "null" : exitCode}`,
|
|
484
|
+
`stdoutSha256:${digest}`
|
|
485
|
+
];
|
|
486
|
+
const summary = status === "completed"
|
|
487
|
+
? `${label}: completed (exit 0)`
|
|
488
|
+
: spawnError
|
|
489
|
+
? `${label}: failed (${spawnError})`
|
|
490
|
+
: `${label}: failed (exit ${exitCode})`;
|
|
491
|
+
const resultEnvelope = { summary, findings: [], evidence };
|
|
492
|
+
const notes = [`runtime: ${runtimeNote(descriptor)}`];
|
|
493
|
+
if (spawnError)
|
|
494
|
+
notes.push(`spawn-error: ${spawnError}`);
|
|
495
|
+
return {
|
|
496
|
+
schemaVersion: 1,
|
|
497
|
+
status,
|
|
498
|
+
result: resultEnvelope,
|
|
499
|
+
evidence,
|
|
500
|
+
provenance: {
|
|
501
|
+
schemaVersion: 1,
|
|
502
|
+
backendId: descriptor.id,
|
|
503
|
+
locality: descriptor.locality,
|
|
504
|
+
kind: descriptor.kind,
|
|
505
|
+
attestation: { ...attestation, status: status === "completed" ? attestation.status : attestation.status, notes }
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function delegate(descriptor, policy, request, label, probe) {
|
|
510
|
+
const handle = delegationHandle(descriptor, request);
|
|
511
|
+
if (!handle) {
|
|
512
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-target-missing", probe.reason || `Backend ${descriptor.id} has no delegation target; refusing rather than running unsandboxed`, { ready: probe.ready });
|
|
513
|
+
}
|
|
514
|
+
// A delegating backend that really executes needs a command. Refuse otherwise
|
|
515
|
+
// rather than fabricate a completed run. A driver whose command is carried by its
|
|
516
|
+
// handle (the agent backend) sets commandlessDelegate and is exempt.
|
|
517
|
+
if (!getBackendDriver(descriptor.id)?.commandlessDelegate && !request.command) {
|
|
518
|
+
return refusedEnvelope(descriptor, policy, label, "no-command", `Backend ${descriptor.id} requires a command to delegate`, {
|
|
519
|
+
ready: probe.ready
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const attestation = attestSandbox(descriptor, policy, {
|
|
523
|
+
mode: "execute",
|
|
524
|
+
ready: true,
|
|
525
|
+
handle,
|
|
526
|
+
notes: [`delegated: ${descriptor.id} -> ${handle.ref}`]
|
|
527
|
+
});
|
|
528
|
+
// v0.1.34: drivers REALLY execute. The result/evidence are the SAME canonical
|
|
529
|
+
// shape executeLocal produces (command:/exitCode:/stdoutSha256:), so a delegated
|
|
530
|
+
// run is byte-stable against node; the handle lives in provenance, NEVER in
|
|
531
|
+
// evidence. Any runtime/transport failure FAILS CLOSED (refused), never a
|
|
532
|
+
// fabricated completion. The driver's registered delegateRun replaces the old
|
|
533
|
+
// id switch.
|
|
534
|
+
const driver = getBackendDriver(descriptor.id);
|
|
535
|
+
if (!driver?.delegateRun) {
|
|
536
|
+
return refusedEnvelope(descriptor, policy, label, "backend-not-runnable", `Backend ${descriptor.id} has no delegate runner`, {
|
|
537
|
+
ready: probe.ready,
|
|
538
|
+
attestation
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return driver.delegateRun({ descriptor, policy, request, label, handle, attestation });
|
|
542
|
+
}
|
|
543
|
+
/** Build the canonical completed/failed envelope shared by every real backend —
|
|
544
|
+
* identical to executeLocal's, so evidence is byte-stable across backends. The
|
|
545
|
+
* handle is recorded in provenance only. */
|
|
546
|
+
function delegatedEnvelope(descriptor, label, handle, attestation, command, args, exitCode, stdout) {
|
|
547
|
+
const digest = sha256(stdout);
|
|
548
|
+
const status = exitCode === 0 ? "completed" : "failed";
|
|
549
|
+
const evidence = [
|
|
550
|
+
`command:${[command, ...args].join(" ")}`,
|
|
551
|
+
`exitCode:${exitCode === null ? "null" : exitCode}`,
|
|
552
|
+
`stdoutSha256:${digest}`
|
|
553
|
+
];
|
|
554
|
+
const summary = status === "completed" ? `${label}: completed (exit 0)` : `${label}: failed (exit ${exitCode === null ? "null" : exitCode})`;
|
|
555
|
+
return {
|
|
556
|
+
schemaVersion: 1,
|
|
557
|
+
status,
|
|
558
|
+
result: { summary, findings: [], evidence },
|
|
559
|
+
evidence,
|
|
560
|
+
provenance: {
|
|
561
|
+
schemaVersion: 1,
|
|
562
|
+
backendId: descriptor.id,
|
|
563
|
+
locality: descriptor.locality,
|
|
564
|
+
kind: descriptor.kind,
|
|
565
|
+
attestation,
|
|
566
|
+
handle
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/** container — real `docker`/`podman run` under the sandbox contract. Maps the
|
|
571
|
+
* profile onto container isolation (network namespace, read-only workspace mount,
|
|
572
|
+
* filtered env) and captures the container command's exit + stdout digest. Fails
|
|
573
|
+
* closed when no runtime is on PATH, the daemon is unreachable, or the runtime
|
|
574
|
+
* itself errors (exit 125) — distinct from the command's own non-zero exit. */
|
|
575
|
+
function runContainer(descriptor, policy, request, label, handle, attestation) {
|
|
576
|
+
const runtime = hasExecutable("docker") ? "docker" : hasExecutable("podman") ? "podman" : undefined;
|
|
577
|
+
if (!runtime) {
|
|
578
|
+
return refusedEnvelope(descriptor, policy, label, "runtime-unavailable", "no container runtime (docker/podman) on PATH", {
|
|
579
|
+
attestation
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// Daemon pre-flight. A present CLI with an UNREACHABLE daemon must fail closed —
|
|
583
|
+
// never be mistaken for a container command that ran and exited non-zero (the
|
|
584
|
+
// run exit code is not a reliable daemon-down signal across runtimes). `version
|
|
585
|
+
// --format {{.Server.Version}}` returns the SERVER version only when reachable.
|
|
586
|
+
const ping = (0, node_child_process_1.spawnSync)(runtime, ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", timeout: 15000 });
|
|
587
|
+
const daemonUp = !ping.error && ping.status === 0 && String(ping.stdout || "").trim().length > 0;
|
|
588
|
+
if (!daemonUp) {
|
|
589
|
+
const why = (String(ping.stderr || "").split("\n").find((line) => line.trim()) || `${runtime} daemon not reachable`).trim();
|
|
590
|
+
return refusedEnvelope(descriptor, policy, label, "runtime-unavailable", `${runtime} daemon is not reachable: ${why}`, {
|
|
591
|
+
attestation
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const command = String(request.command);
|
|
595
|
+
const args = (request.args || []).map(String);
|
|
596
|
+
const cwd = request.cwd || process.cwd();
|
|
597
|
+
const runArgs = ["run", "--rm"];
|
|
598
|
+
// network: enforce isolation when the policy restricts it (container kernel
|
|
599
|
+
// namespace genuinely enforces this — that is why `network` is declared enforce).
|
|
600
|
+
if (policy.network.mode !== "any")
|
|
601
|
+
runArgs.push("--network", "none");
|
|
602
|
+
// read/write: mount the workspace read-only at the same path; CW's own
|
|
603
|
+
// worker-output acceptance still bounds writes. (Write-through mounts can be a
|
|
604
|
+
// later refinement; read-only is the safe default.)
|
|
605
|
+
runArgs.push("-v", `${cwd}:${cwd}:ro`, "-w", cwd);
|
|
606
|
+
// env: only the explicitly exposed names cross into the container — the image
|
|
607
|
+
// provides its own PATH/HOME, so we never inject host-specific base env.
|
|
608
|
+
if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
|
|
609
|
+
for (const name of policy.env.inherit ? Object.keys(process.env) : policy.env.expose || []) {
|
|
610
|
+
if (name === "PATH" || name === "HOME")
|
|
611
|
+
continue;
|
|
612
|
+
const value = process.env[name];
|
|
613
|
+
if (value !== undefined)
|
|
614
|
+
runArgs.push("-e", `${name}=${value}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
runArgs.push(handle.ref, command, ...args);
|
|
618
|
+
const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, {
|
|
619
|
+
cwd,
|
|
620
|
+
encoding: "utf8",
|
|
621
|
+
timeout: request.timeoutMs,
|
|
622
|
+
maxBuffer: 32 * 1024 * 1024
|
|
623
|
+
});
|
|
624
|
+
if (result.error) {
|
|
625
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, {
|
|
626
|
+
attestation
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const exitCode = typeof result.status === "number" ? result.status : null;
|
|
630
|
+
// docker/podman exit 125 = the runtime itself failed (daemon down, bad image,
|
|
631
|
+
// bad flags) — NOT the container command's exit. Fail closed, do not record a
|
|
632
|
+
// command result that never ran.
|
|
633
|
+
if (exitCode === 125 || exitCode === null) {
|
|
634
|
+
const why = (String(result.stderr || "").split("\n").find((line) => line.trim()) || "container runtime error").trim();
|
|
635
|
+
return refusedEnvelope(descriptor, policy, label, "runtime-unavailable", `${runtime} could not run the container: ${why}`, {
|
|
636
|
+
attestation
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return delegatedEnvelope(descriptor, label, handle, attestation, command, args, exitCode, String(result.stdout || ""));
|
|
640
|
+
}
|
|
641
|
+
// A self-contained Node child that performs the remote/CI delegation: it reads a
|
|
642
|
+
// JSON job on stdin, POSTs it to the endpoint, optionally polls a returned jobId,
|
|
643
|
+
// and prints `{ exitCode, stdout }` (or `{ error }`) on stdout. Node-only (global
|
|
644
|
+
// fetch, node >=18), so the driver stays portable and synchronous from CW's view.
|
|
645
|
+
const HTTP_DELEGATE_CHILD = `
|
|
646
|
+
(async () => {
|
|
647
|
+
const read = () => new Promise((res) => { let b = ""; process.stdin.on("data", (c) => (b += c)); process.stdin.on("end", () => res(b)); });
|
|
648
|
+
try {
|
|
649
|
+
const job = JSON.parse((await read()) || "{}");
|
|
650
|
+
const endpoint = process.env.CW_DELEGATE_ENDPOINT;
|
|
651
|
+
if (!endpoint) throw new Error("no endpoint");
|
|
652
|
+
const post = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(job) });
|
|
653
|
+
if (!post.ok) throw new Error("runner responded " + post.status);
|
|
654
|
+
let data = await post.json();
|
|
655
|
+
// Poll a returned jobId until the runner reports done.
|
|
656
|
+
let guard = 0;
|
|
657
|
+
while (data && data.jobId && data.done !== true && guard++ < 600) {
|
|
658
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
659
|
+
const poll = await fetch(endpoint + (endpoint.includes("?") ? "&" : "?") + "jobId=" + encodeURIComponent(data.jobId));
|
|
660
|
+
if (!poll.ok) throw new Error("poll responded " + poll.status);
|
|
661
|
+
data = await poll.json();
|
|
662
|
+
}
|
|
663
|
+
if (typeof data.exitCode !== "number") throw new Error("runner did not report an exitCode");
|
|
664
|
+
process.stdout.write(JSON.stringify({ exitCode: data.exitCode, stdout: String(data.stdout || "") }));
|
|
665
|
+
} catch (e) {
|
|
666
|
+
process.stdout.write(JSON.stringify({ error: e && e.message ? e.message : String(e) }));
|
|
667
|
+
}
|
|
668
|
+
})();
|
|
669
|
+
`;
|
|
670
|
+
/** remote / ci — real HTTP delegation. POSTs the job to the configured endpoint
|
|
671
|
+
* (and polls a returned jobId) via a Node child, then records the runner's exit +
|
|
672
|
+
* stdout digest as canonical evidence. Fails closed when the endpoint is missing,
|
|
673
|
+
* unreachable, errors, or returns no exitCode. Untestable without a live runner,
|
|
674
|
+
* but the refusal paths are exercised by the smoke. */
|
|
675
|
+
function runHttpDelegation(descriptor, policy, request, label, handle, attestation) {
|
|
676
|
+
const endpoint = handle.endpoint;
|
|
677
|
+
if (!endpoint) {
|
|
678
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-target-missing", `Backend ${descriptor.id} has no endpoint to POST to`, {
|
|
679
|
+
attestation
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const command = String(request.command);
|
|
683
|
+
const args = (request.args || []).map(String);
|
|
684
|
+
const job = JSON.stringify({
|
|
685
|
+
command,
|
|
686
|
+
args,
|
|
687
|
+
env: buildChildEnv(policy),
|
|
688
|
+
sandboxProfileId: policy.id,
|
|
689
|
+
jobId: handle.jobId
|
|
690
|
+
});
|
|
691
|
+
const child = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", HTTP_DELEGATE_CHILD], {
|
|
692
|
+
input: job,
|
|
693
|
+
env: { ...process.env, CW_DELEGATE_ENDPOINT: endpoint },
|
|
694
|
+
encoding: "utf8",
|
|
695
|
+
timeout: request.timeoutMs || 120000,
|
|
696
|
+
maxBuffer: 32 * 1024 * 1024
|
|
697
|
+
});
|
|
698
|
+
if (child.error) {
|
|
699
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `${descriptor.id} delegation failed: ${messageOf(child.error)}`, {
|
|
700
|
+
attestation
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
let parsed;
|
|
704
|
+
try {
|
|
705
|
+
parsed = JSON.parse(String(child.stdout || "").trim() || "{}");
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `${descriptor.id} runner returned an unparseable response`, {
|
|
709
|
+
attestation
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
if (parsed.error || typeof parsed.exitCode !== "number") {
|
|
713
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `${descriptor.id} runner error: ${parsed.error || "no exitCode reported"}`, { attestation });
|
|
714
|
+
}
|
|
715
|
+
return delegatedEnvelope(descriptor, label, handle, attestation, command, args, parsed.exitCode, String(parsed.stdout || ""));
|
|
716
|
+
}
|
|
717
|
+
/** Resolve the agent invocation from the request delegation > env. Vendor-neutral;
|
|
718
|
+
* the durable file config is folded in by the drive layer before this point. */
|
|
719
|
+
function resolveAgentInvocation(request) {
|
|
720
|
+
const delegation = request.delegation || {};
|
|
721
|
+
const envCommand = (process.env.CW_AGENT_COMMAND || "").trim();
|
|
722
|
+
const endpoint = delegation.endpoint || (process.env.CW_AGENT_ENDPOINT || "").trim() || undefined;
|
|
723
|
+
const model = delegation.model || (process.env.CW_AGENT_MODEL || "").trim() || undefined;
|
|
724
|
+
// Accept the invocation via delegation (preferred) OR the top-level command/args.
|
|
725
|
+
let binary = delegation.command || request.command || undefined;
|
|
726
|
+
let rawArgs = delegation.args ? [...delegation.args] : request.args ? [...request.args] : [];
|
|
727
|
+
// An env-string command ("claude -p --output-format json {{manifest}}") is split
|
|
728
|
+
// into a binary + discrete argv template — NEVER shell-interpreted.
|
|
729
|
+
if (!binary && envCommand) {
|
|
730
|
+
const parts = envCommand.split(/\s+/).filter(Boolean);
|
|
731
|
+
binary = parts[0];
|
|
732
|
+
if (!delegation.args)
|
|
733
|
+
rawArgs = parts.slice(1);
|
|
734
|
+
}
|
|
735
|
+
else if (binary && !delegation.args && /\s/.test(binary)) {
|
|
736
|
+
const parts = binary.split(/\s+/).filter(Boolean);
|
|
737
|
+
binary = parts[0];
|
|
738
|
+
rawArgs = parts.slice(1);
|
|
739
|
+
}
|
|
740
|
+
return { binary, rawArgs, endpoint, model, timeoutMs: request.timeoutMs };
|
|
741
|
+
}
|
|
742
|
+
const AGENT_SECRET_FLAGS = new Set(["--api-key", "--apikey", "--token", "--key", "--secret", "--password", "--auth", "--bearer"]);
|
|
743
|
+
/** Redact secrets from recorded agent args: a value FOLLOWING a known secret flag,
|
|
744
|
+
* an `--x-key=...` inline value, or a token that LOOKS like a credential. Never
|
|
745
|
+
* record a raw secret in provenance/evidence. Exported so the durable config
|
|
746
|
+
* surface strips the SAME way before persisting/showing a command template. */
|
|
747
|
+
function stripSecretArgs(args) {
|
|
748
|
+
const out = [];
|
|
749
|
+
for (let i = 0; i < args.length; i++) {
|
|
750
|
+
const arg = String(args[i]);
|
|
751
|
+
if (AGENT_SECRET_FLAGS.has(arg.toLowerCase())) {
|
|
752
|
+
out.push(arg);
|
|
753
|
+
if (i + 1 < args.length) {
|
|
754
|
+
out.push("<redacted>");
|
|
755
|
+
i++;
|
|
756
|
+
}
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const inline = arg.match(/^(--?[A-Za-z][\w-]*(?:key|token|secret|password|auth|bearer)[\w-]*)=.*/i);
|
|
760
|
+
if (inline) {
|
|
761
|
+
out.push(`${inline[1]}=<redacted>`);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
// Bare credential-looking token: a known provider prefix, or a long high-entropy
|
|
765
|
+
// run with NO path separators (so file paths / {{...}} substitutions survive as
|
|
766
|
+
// useful provenance). Over-redaction is safe; leaking a key is not.
|
|
767
|
+
if (/^(sk-|ghp_|gho_|github_pat_|xox[abpr]-|Bearer\s)/.test(arg) || (arg.length >= 32 && /^[A-Za-z0-9_\-]{32,}$/.test(arg))) {
|
|
768
|
+
out.push("<redacted>");
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
out.push(arg);
|
|
772
|
+
}
|
|
773
|
+
return out;
|
|
774
|
+
}
|
|
775
|
+
/** Best-effort parse of the AGENT-reported model id from its stdout. SOLELY the
|
|
776
|
+
* agent's own report — `unreported` when absent. Never CW_AGENT_MODEL. */
|
|
777
|
+
function parseAgentReport(stdout) {
|
|
778
|
+
const text = String(stdout || "").trim();
|
|
779
|
+
if (!text)
|
|
780
|
+
return {};
|
|
781
|
+
const tryObj = (value) => {
|
|
782
|
+
try {
|
|
783
|
+
const parsed = JSON.parse(value);
|
|
784
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
let obj = tryObj(text);
|
|
791
|
+
if (!obj) {
|
|
792
|
+
const line = text
|
|
793
|
+
.split(/\r?\n/)
|
|
794
|
+
.reverse()
|
|
795
|
+
.find((entry) => entry.trim().startsWith("{") && entry.trim().endsWith("}"));
|
|
796
|
+
if (line)
|
|
797
|
+
obj = tryObj(line.trim());
|
|
798
|
+
}
|
|
799
|
+
if (!obj)
|
|
800
|
+
return {};
|
|
801
|
+
const usage = obj.usage && typeof obj.usage === "object" ? obj.usage : undefined;
|
|
802
|
+
let model = typeof obj.model === "string"
|
|
803
|
+
? obj.model
|
|
804
|
+
: usage && typeof usage.model === "string"
|
|
805
|
+
? usage.model
|
|
806
|
+
: typeof obj.modelId === "string"
|
|
807
|
+
? obj.modelId
|
|
808
|
+
: undefined;
|
|
809
|
+
// Some agents (e.g. `claude -p --output-format json`) report no top-level model;
|
|
810
|
+
// the model id(s) appear as KEYS of a `modelUsage` object. Pick the primary model
|
|
811
|
+
// (the one with the most input tokens). Still SOLELY the agent's own report.
|
|
812
|
+
if (!model && obj.modelUsage && typeof obj.modelUsage === "object" && !Array.isArray(obj.modelUsage)) {
|
|
813
|
+
const entries = Object.entries(obj.modelUsage);
|
|
814
|
+
if (entries.length) {
|
|
815
|
+
const tokensOf = (value) => {
|
|
816
|
+
const record = value && typeof value === "object" ? value : {};
|
|
817
|
+
const input = Number(record.inputTokens ?? record.input_tokens ?? 0);
|
|
818
|
+
return Number.isFinite(input) ? input : 0;
|
|
819
|
+
};
|
|
820
|
+
entries.sort((left, right) => tokensOf(right[1]) - tokensOf(left[1]));
|
|
821
|
+
model = entries[0][0];
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Track 1: the executor's detached signature over its usage report, if it signs.
|
|
825
|
+
// SOLELY the agent's own field — CW verifies it later against the trust key.
|
|
826
|
+
const usageSignature = typeof obj.usageSignature === "string"
|
|
827
|
+
? obj.usageSignature
|
|
828
|
+
: typeof obj.usage_signature === "string"
|
|
829
|
+
? obj.usage_signature
|
|
830
|
+
: undefined;
|
|
831
|
+
return { model, usage, usageSignature };
|
|
832
|
+
}
|
|
833
|
+
function agentSubstitutions(request, model) {
|
|
834
|
+
const manifest = request.manifest;
|
|
835
|
+
const workerDir = manifest?.workerDir || request.cwd || "";
|
|
836
|
+
return {
|
|
837
|
+
manifest: manifest?.manifestPath || (workerDir ? node_path_1.default.join(workerDir, "manifest.json") : ""),
|
|
838
|
+
input: manifest?.inputPath || "",
|
|
839
|
+
result: manifest?.resultPath || "",
|
|
840
|
+
workerDir,
|
|
841
|
+
model: model || "",
|
|
842
|
+
prompt: manifest?.prompt || ""
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function substituteAgentArg(arg, subst) {
|
|
846
|
+
return arg.replace(/\{\{(\w+)\}\}/g, (_, key) => (key in subst ? subst[key] : `{{${key}}}`));
|
|
847
|
+
}
|
|
848
|
+
/** Build the recorded process handle for the envelope — secret-stripped + the
|
|
849
|
+
* agent-reported model. Same SHAPE that lands in provenance, never in evidence. */
|
|
850
|
+
function recordedAgentHandle(binary, endpoint, recordedArgs, model, reportedModel, reportedUsage, usageSignature) {
|
|
851
|
+
const ref = binary ? [binary, ...recordedArgs].join(" ") : endpoint || "";
|
|
852
|
+
return {
|
|
853
|
+
kind: "process",
|
|
854
|
+
ref,
|
|
855
|
+
endpoint,
|
|
856
|
+
metadata: {
|
|
857
|
+
mode: binary ? "command" : "endpoint",
|
|
858
|
+
command: binary,
|
|
859
|
+
args: recordedArgs,
|
|
860
|
+
model,
|
|
861
|
+
reportedModel,
|
|
862
|
+
// Telemetry thread-back: the agent's OWN self-reported token usage (parsed
|
|
863
|
+
// from its stdout by parseAgentReport). ATTESTED, never measured by CW —
|
|
864
|
+
// same red-line posture as reportedModel. Lands in provenance, never in the
|
|
865
|
+
// byte-stable evidence triple. Absent when the agent reported no usage.
|
|
866
|
+
...(reportedUsage ? { reportedUsage } : {}),
|
|
867
|
+
// Track 1: the executor's detached signature over its usage report. CW
|
|
868
|
+
// verifies it against the operator trust key at output intake.
|
|
869
|
+
...(usageSignature ? { usageSignature } : {})
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function runAgentProcess(descriptor, policy, request, label, handle, attestation) {
|
|
874
|
+
const resolved = resolveAgentInvocation(request);
|
|
875
|
+
const subst = agentSubstitutions(request, resolved.model);
|
|
876
|
+
if (resolved.binary) {
|
|
877
|
+
const realArgs = resolved.rawArgs.map((arg) => substituteAgentArg(arg, subst));
|
|
878
|
+
const recordedArgs = stripSecretArgs(realArgs);
|
|
879
|
+
// Spawn the agent argv-style — shell:false, never a shell-interpreted string.
|
|
880
|
+
// The agent inherits the host env so ITS OWN credentials resolve; CW neither
|
|
881
|
+
// reads nor records them. CW enforces only the exact argv it spawns.
|
|
882
|
+
// Track 2: a concurrent round pre-collects the child outcome via the batch
|
|
883
|
+
// delegate child; when present it settles through these SAME branches —
|
|
884
|
+
// identical envelopes by construction, no second mapping to drift.
|
|
885
|
+
let outcome;
|
|
886
|
+
if (request.preparedAgentOutcome) {
|
|
887
|
+
outcome = request.preparedAgentOutcome;
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
|
|
891
|
+
cwd: request.cwd,
|
|
892
|
+
env: { ...process.env },
|
|
893
|
+
encoding: "utf8",
|
|
894
|
+
timeout: resolved.timeoutMs || 600000,
|
|
895
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
896
|
+
shell: false
|
|
897
|
+
});
|
|
898
|
+
outcome = {
|
|
899
|
+
...(child.error ? { spawnError: messageOf(child.error) } : {}),
|
|
900
|
+
exitCode: typeof child.status === "number" ? child.status : null,
|
|
901
|
+
stdout: String(child.stdout || "")
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
if (outcome.spawnError) {
|
|
905
|
+
const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported");
|
|
906
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `agent process failed to spawn: ${outcome.spawnError}`, {
|
|
907
|
+
attestation: { ...attestation, handle: handleOut }
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const exitCode = outcome.exitCode;
|
|
911
|
+
const stdout = outcome.stdout;
|
|
912
|
+
const report = parseAgentReport(stdout);
|
|
913
|
+
const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
|
|
914
|
+
const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, reportedModel, report.usage, report.usageSignature);
|
|
915
|
+
if (exitCode === null) {
|
|
916
|
+
// No exit code (timeout/killed) ⇒ fail closed, never a fabricated completion.
|
|
917
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `agent process returned no exit code (timed out or killed)`, {
|
|
918
|
+
attestation: { ...attestation, handle: handleOut }
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
// Evidence triple = the agent CHILD's command/exit/stdout digest (secret-stripped
|
|
922
|
+
// command), byte-stable in SHAPE with node/container/remote. exit≠0 ⇒ failed.
|
|
923
|
+
return delegatedEnvelope(descriptor, label, handleOut, { ...attestation, handle: handleOut }, resolved.binary, recordedArgs, exitCode, stdout);
|
|
924
|
+
}
|
|
925
|
+
if (resolved.endpoint) {
|
|
926
|
+
return runAgentEndpoint(descriptor, policy, request, label, resolved, attestation);
|
|
927
|
+
}
|
|
928
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-target-missing", `Backend ${descriptor.id} has no command-template or endpoint configured`, {
|
|
929
|
+
attestation
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
/** Resolve a request to a spawn-style batch job, or undefined when the agent is
|
|
933
|
+
* endpoint-configured/unconfigured (those settle through the serial path). */
|
|
934
|
+
function prepareAgentSpawn(request) {
|
|
935
|
+
const resolved = resolveAgentInvocation(request);
|
|
936
|
+
if (!resolved.binary)
|
|
937
|
+
return undefined;
|
|
938
|
+
const subst = agentSubstitutions(request, resolved.model);
|
|
939
|
+
return {
|
|
940
|
+
binary: resolved.binary,
|
|
941
|
+
args: resolved.rawArgs.map((arg) => substituteAgentArg(arg, subst)),
|
|
942
|
+
cwd: request.cwd,
|
|
943
|
+
timeoutMs: resolved.timeoutMs || 600000
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
// Reads jobs JSON on stdin, spawns ALL concurrently (shell:false, inherited env —
|
|
947
|
+
// the agent's own credentials resolve; CW never reads them), per-job SIGTERM at
|
|
948
|
+
// timeoutMs + SIGKILL at +5s, caps each captured stdout at 32MB, and prints the
|
|
949
|
+
// outcome array when every job has settled. stderr is drained (a full pipe must
|
|
950
|
+
// never wedge a child). A kill yields exitCode null — the no-exit-code refusal.
|
|
951
|
+
const BATCH_DELEGATE_CHILD = `
|
|
952
|
+
const { spawn } = require("node:child_process");
|
|
953
|
+
let raw = "";
|
|
954
|
+
process.stdin.setEncoding("utf8");
|
|
955
|
+
process.stdin.on("data", (d) => (raw += d));
|
|
956
|
+
process.stdin.on("end", () => {
|
|
957
|
+
const jobs = JSON.parse(raw);
|
|
958
|
+
if (!jobs.length) { process.stdout.write("[]"); return; }
|
|
959
|
+
const out = new Array(jobs.length);
|
|
960
|
+
let pending = jobs.length;
|
|
961
|
+
const CAP = 32 * 1024 * 1024;
|
|
962
|
+
jobs.forEach((job, i) => {
|
|
963
|
+
let stdout = "";
|
|
964
|
+
let settled = false;
|
|
965
|
+
const settle = (o) => {
|
|
966
|
+
if (settled) return;
|
|
967
|
+
settled = true;
|
|
968
|
+
out[i] = o;
|
|
969
|
+
if (--pending === 0) process.stdout.write(JSON.stringify(out));
|
|
970
|
+
};
|
|
971
|
+
let child;
|
|
972
|
+
try {
|
|
973
|
+
child = spawn(job.binary, job.args, { cwd: job.cwd, env: process.env, shell: false });
|
|
974
|
+
} catch (error) {
|
|
975
|
+
settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout: "" });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const term = setTimeout(() => { try { child.kill("SIGTERM"); } catch {} }, job.timeoutMs);
|
|
979
|
+
const kill = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, job.timeoutMs + 5000);
|
|
980
|
+
child.stdout.on("data", (d) => { if (stdout.length < CAP) stdout += d; });
|
|
981
|
+
child.stderr.on("data", () => {});
|
|
982
|
+
child.on("error", (error) => {
|
|
983
|
+
clearTimeout(term); clearTimeout(kill);
|
|
984
|
+
settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout });
|
|
985
|
+
});
|
|
986
|
+
child.on("close", (code) => {
|
|
987
|
+
clearTimeout(term); clearTimeout(kill);
|
|
988
|
+
settle({ exitCode: typeof code === "number" ? code : null, stdout });
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
`;
|
|
993
|
+
/** Run a batch of agent spawns concurrently; outcomes index-align with jobs. The
|
|
994
|
+
* parent backstop timeout (max job timeout + 30s) means even a wedged delegate
|
|
995
|
+
* child cannot deadlock the drive: on any batch-level failure EVERY job settles
|
|
996
|
+
* as a fail-closed spawn refusal — never a fabricated completion, never a hang. */
|
|
997
|
+
function runAgentBatchOutcomes(jobs) {
|
|
998
|
+
if (!jobs.length)
|
|
999
|
+
return [];
|
|
1000
|
+
const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
|
|
1001
|
+
const child = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", BATCH_DELEGATE_CHILD], {
|
|
1002
|
+
input: JSON.stringify(jobs),
|
|
1003
|
+
encoding: "utf8",
|
|
1004
|
+
maxBuffer: 33 * 1024 * 1024 * jobs.length,
|
|
1005
|
+
timeout: maxTimeout + 30000
|
|
1006
|
+
});
|
|
1007
|
+
if (!child.error && typeof child.status === "number" && child.status === 0) {
|
|
1008
|
+
try {
|
|
1009
|
+
const parsed = JSON.parse(String(child.stdout || ""));
|
|
1010
|
+
if (Array.isArray(parsed) && parsed.length === jobs.length)
|
|
1011
|
+
return parsed;
|
|
1012
|
+
}
|
|
1013
|
+
catch {
|
|
1014
|
+
// fall through to the fail-closed mapping below
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const reason = child.error ? messageOf(child.error) : `batch delegate exited ${child.status === null ? "without an exit code (timed out or killed)" : `with ${child.status}`}`;
|
|
1018
|
+
return jobs.map(() => ({ spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" }));
|
|
1019
|
+
}
|
|
1020
|
+
/** Agent HTTP endpoint variant — POSTs the worker manifest/prompt to a configured
|
|
1021
|
+
* agent endpoint via the shared Node delegate child; if the endpoint returns a
|
|
1022
|
+
* `result` body, CW writes it to the worker's result.md (the endpoint agent is the
|
|
1023
|
+
* producer — CW is only transport). Evidence triple = the delegate child's
|
|
1024
|
+
* exit + stdout digest, identical mechanism to runHttpDelegation. Fails closed. */
|
|
1025
|
+
function runAgentEndpoint(descriptor, policy, request, label, resolved, attestation) {
|
|
1026
|
+
const endpoint = resolved.endpoint;
|
|
1027
|
+
const manifest = request.manifest;
|
|
1028
|
+
const job = JSON.stringify({
|
|
1029
|
+
manifest,
|
|
1030
|
+
prompt: manifest?.prompt,
|
|
1031
|
+
model: resolved.model,
|
|
1032
|
+
resultPath: manifest?.resultPath,
|
|
1033
|
+
sandboxProfileId: policy.id
|
|
1034
|
+
});
|
|
1035
|
+
const child = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", HTTP_DELEGATE_CHILD], {
|
|
1036
|
+
input: job,
|
|
1037
|
+
env: { ...process.env, CW_DELEGATE_ENDPOINT: endpoint },
|
|
1038
|
+
encoding: "utf8",
|
|
1039
|
+
timeout: resolved.timeoutMs || 600000,
|
|
1040
|
+
maxBuffer: 32 * 1024 * 1024
|
|
1041
|
+
});
|
|
1042
|
+
const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
|
|
1043
|
+
if (child.error) {
|
|
1044
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `agent endpoint delegation failed: ${messageOf(child.error)}`, {
|
|
1045
|
+
attestation: { ...attestation, handle: baseHandle }
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
let parsed;
|
|
1049
|
+
try {
|
|
1050
|
+
parsed = JSON.parse(String(child.stdout || "").trim() || "{}");
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `agent endpoint returned an unparseable response`, {
|
|
1054
|
+
attestation: { ...attestation, handle: baseHandle }
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
if (parsed.error || typeof parsed.exitCode !== "number") {
|
|
1058
|
+
return refusedEnvelope(descriptor, policy, label, "delegation-failed", `agent endpoint error: ${parsed.error || "no exitCode reported"}`, {
|
|
1059
|
+
attestation: { ...attestation, handle: baseHandle }
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
const stdout = String(parsed.stdout || "");
|
|
1063
|
+
// If the endpoint agent returned the result body, CW (as transport) writes it to
|
|
1064
|
+
// the worker's result.md for the separate recordWorkerOutput layer to accept.
|
|
1065
|
+
const report = parseAgentReport(stdout);
|
|
1066
|
+
if (manifest?.resultPath && report.usage === undefined) {
|
|
1067
|
+
const body = extractEndpointResult(stdout);
|
|
1068
|
+
if (body && !node_fs_1.default.existsSync(manifest.resultPath)) {
|
|
1069
|
+
try {
|
|
1070
|
+
node_fs_1.default.writeFileSync(manifest.resultPath, body, "utf8");
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
/* the accept layer will fail closed on a missing result.md */
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
|
|
1078
|
+
const handleOut = recordedAgentHandle(undefined, endpoint, [], resolved.model, reportedModel, report.usage, report.usageSignature);
|
|
1079
|
+
return delegatedEnvelope(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], parsed.exitCode, stdout);
|
|
1080
|
+
}
|
|
1081
|
+
function extractEndpointResult(stdout) {
|
|
1082
|
+
const text = String(stdout || "").trim();
|
|
1083
|
+
if (!text)
|
|
1084
|
+
return undefined;
|
|
1085
|
+
try {
|
|
1086
|
+
const parsed = JSON.parse(text);
|
|
1087
|
+
if (parsed && typeof parsed === "object") {
|
|
1088
|
+
if (typeof parsed.result === "string")
|
|
1089
|
+
return parsed.result;
|
|
1090
|
+
if (typeof parsed.resultMarkdown === "string")
|
|
1091
|
+
return parsed.resultMarkdown;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
/* not JSON — treat the raw text as the result body */
|
|
1096
|
+
return text;
|
|
1097
|
+
}
|
|
1098
|
+
return undefined;
|
|
1099
|
+
}
|
|
1100
|
+
function delegationHandle(descriptor, request) {
|
|
1101
|
+
return getBackendDriver(descriptor.id)?.buildHandle?.(request);
|
|
1102
|
+
}
|
|
1103
|
+
function containerHandle(request) {
|
|
1104
|
+
const delegation = request.delegation || {};
|
|
1105
|
+
const image = delegation.image || (process.env.CW_CONTAINER_IMAGE || "").trim() || undefined;
|
|
1106
|
+
if (!image)
|
|
1107
|
+
return undefined;
|
|
1108
|
+
const digest = delegation.digest || (process.env.CW_CONTAINER_DIGEST || "").trim() || undefined;
|
|
1109
|
+
const ref = digest ? `${image}@${digest}` : image;
|
|
1110
|
+
return { kind: "container", ref, image, digest };
|
|
1111
|
+
}
|
|
1112
|
+
function remoteHandle(request) {
|
|
1113
|
+
const delegation = request.delegation || {};
|
|
1114
|
+
const endpoint = delegation.endpoint || (process.env.CW_REMOTE_ENDPOINT || "").trim() || undefined;
|
|
1115
|
+
if (!endpoint)
|
|
1116
|
+
return undefined;
|
|
1117
|
+
const jobId = delegation.jobId || (process.env.CW_REMOTE_JOB || "").trim() || undefined;
|
|
1118
|
+
const ref = jobId ? `${endpoint}#${jobId}` : endpoint;
|
|
1119
|
+
return { kind: "remote", ref, endpoint, jobId };
|
|
1120
|
+
}
|
|
1121
|
+
function ciHandle(request) {
|
|
1122
|
+
const delegation = request.delegation || {};
|
|
1123
|
+
const endpoint = delegation.endpoint || (process.env.CW_CI_ENDPOINT || "").trim() || undefined;
|
|
1124
|
+
const jobId = delegation.jobId || (process.env.CW_CI_JOB || "").trim() || undefined;
|
|
1125
|
+
if (!endpoint && !jobId)
|
|
1126
|
+
return undefined;
|
|
1127
|
+
const ref = endpoint && jobId ? `${endpoint}#${jobId}` : jobId || endpoint || "";
|
|
1128
|
+
return { kind: "ci", ref, endpoint, jobId };
|
|
1129
|
+
}
|
|
1130
|
+
function agentHandle(request) {
|
|
1131
|
+
// The agent invocation is POLICY-as-DATA, resolved flags(delegation) > env. The
|
|
1132
|
+
// handle records ONLY secret-stripped provenance; the raw template is re-resolved
|
|
1133
|
+
// inside runAgentProcess for substitution + spawning so no secret ever lands in
|
|
1134
|
+
// a recorded handle/evidence entry.
|
|
1135
|
+
const resolved = resolveAgentInvocation(request);
|
|
1136
|
+
if (!resolved.binary && !resolved.endpoint)
|
|
1137
|
+
return undefined;
|
|
1138
|
+
const strippedArgs = stripSecretArgs(resolved.rawArgs);
|
|
1139
|
+
const ref = resolved.binary ? [resolved.binary, ...strippedArgs].join(" ") : resolved.endpoint || "";
|
|
1140
|
+
return {
|
|
1141
|
+
kind: "process",
|
|
1142
|
+
ref,
|
|
1143
|
+
endpoint: resolved.endpoint,
|
|
1144
|
+
metadata: {
|
|
1145
|
+
mode: resolved.binary ? "command" : "endpoint",
|
|
1146
|
+
command: resolved.binary,
|
|
1147
|
+
args: strippedArgs,
|
|
1148
|
+
model: resolved.model
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
function refusedEnvelope(descriptor, policy, label, code, reason, options = {}) {
|
|
1153
|
+
const attestation = options.attestation
|
|
1154
|
+
? { ...options.attestation, status: "refused", notes: [...(options.attestation.notes || []), `refused: ${code}`] }
|
|
1155
|
+
: { ...attestSandbox(descriptor, policy, { mode: "execute", ready: options.ready }), status: "refused", notes: [`refused: ${code}`] };
|
|
1156
|
+
const evidence = [`refused:${code}`, `backend:${descriptor.id}`, `sandbox:${policy.id}`];
|
|
1157
|
+
const resultEnvelope = {
|
|
1158
|
+
summary: `${label}: refused (${code}) — ${reason}`,
|
|
1159
|
+
findings: [],
|
|
1160
|
+
evidence
|
|
1161
|
+
};
|
|
1162
|
+
return {
|
|
1163
|
+
schemaVersion: 1,
|
|
1164
|
+
status: "refused",
|
|
1165
|
+
result: resultEnvelope,
|
|
1166
|
+
evidence,
|
|
1167
|
+
provenance: {
|
|
1168
|
+
schemaVersion: 1,
|
|
1169
|
+
backendId: descriptor.id,
|
|
1170
|
+
locality: descriptor.locality,
|
|
1171
|
+
kind: descriptor.kind,
|
|
1172
|
+
attestation,
|
|
1173
|
+
handle: attestation.handle
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
// The ExecutionBackend interface + driver registry.
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
function createExecutionBackend(id) {
|
|
1181
|
+
const descriptor = getBackendDescriptor(id);
|
|
1182
|
+
return {
|
|
1183
|
+
descriptor,
|
|
1184
|
+
probe: (context) => probeBackend(id, context),
|
|
1185
|
+
run: (request) => runBackend({ ...request, backendId: id })
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
function listExecutionBackends() {
|
|
1189
|
+
return backendIds().map(createExecutionBackend);
|
|
1190
|
+
}
|
|
1191
|
+
// ---- inspection payloads (shared by CLI + MCP via the orchestrator) --------
|
|
1192
|
+
function backendListPayload() {
|
|
1193
|
+
return { schemaVersion: 1, default: exports.DEFAULT_BACKEND_ID, backends: listBackendDescriptors() };
|
|
1194
|
+
}
|
|
1195
|
+
function backendShowPayload(id) {
|
|
1196
|
+
return getBackendDescriptor(id);
|
|
1197
|
+
}
|
|
1198
|
+
function backendProbePayload(id, context = {}) {
|
|
1199
|
+
if (id && id.trim())
|
|
1200
|
+
return cachedProbeBackend(id.trim(), context);
|
|
1201
|
+
return { schemaVersion: 1, default: exports.DEFAULT_BACKEND_ID, probes: backendIds().map((backendId) => cachedProbeBackend(backendId, context)) };
|
|
1202
|
+
}
|
|
1203
|
+
// ---------------------------------------------------------------------------
|
|
1204
|
+
// Helpers.
|
|
1205
|
+
// ---------------------------------------------------------------------------
|
|
1206
|
+
function buildChildEnv(policy) {
|
|
1207
|
+
if (policy.env.inherit)
|
|
1208
|
+
return { ...process.env };
|
|
1209
|
+
// A minimal base so the interpreter resolves; everything else is filtered per
|
|
1210
|
+
// the env policy. PATH is always provided; HOME is included for tool resolution.
|
|
1211
|
+
const env = {};
|
|
1212
|
+
if (process.env.PATH !== undefined)
|
|
1213
|
+
env.PATH = process.env.PATH;
|
|
1214
|
+
if (process.env.HOME !== undefined)
|
|
1215
|
+
env.HOME = process.env.HOME;
|
|
1216
|
+
for (const name of policy.env.expose || []) {
|
|
1217
|
+
if (process.env[name] !== undefined)
|
|
1218
|
+
env[name] = process.env[name];
|
|
1219
|
+
}
|
|
1220
|
+
for (const name of policy.env.deny || []) {
|
|
1221
|
+
delete env[name];
|
|
1222
|
+
}
|
|
1223
|
+
return env;
|
|
1224
|
+
}
|
|
1225
|
+
function commandDenied(policy, command) {
|
|
1226
|
+
const normalized = command.trim();
|
|
1227
|
+
if (!normalized)
|
|
1228
|
+
return "empty command";
|
|
1229
|
+
if (policy.execute.mode === "none") {
|
|
1230
|
+
return `command execution is denied by sandbox profile ${policy.id}`;
|
|
1231
|
+
}
|
|
1232
|
+
if (policy.execute.mode === "allowlist" && !(policy.execute.allow || []).includes(normalized)) {
|
|
1233
|
+
return `command is outside sandbox profile ${policy.id} allowlist`;
|
|
1234
|
+
}
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
function runtimeNote(descriptor) {
|
|
1238
|
+
return getBackendDriver(descriptor.id)?.runtimeNote?.() ?? "node";
|
|
1239
|
+
}
|
|
1240
|
+
function hasExecutable(name) {
|
|
1241
|
+
const dirs = (process.env.PATH || "").split(node_path_1.default.delimiter).filter(Boolean);
|
|
1242
|
+
for (const dir of dirs) {
|
|
1243
|
+
const candidate = node_path_1.default.join(dir, name);
|
|
1244
|
+
try {
|
|
1245
|
+
if (node_fs_1.default.existsSync(candidate) && node_fs_1.default.statSync(candidate).isFile())
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
// ignore unreadable PATH entries
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
function sha256(value) {
|
|
1255
|
+
return `sha256:${node_crypto_1.default.createHash("sha256").update(value, "utf8").digest("hex")}`;
|
|
1256
|
+
}
|
|
1257
|
+
function firstString(...values) {
|
|
1258
|
+
for (const value of values) {
|
|
1259
|
+
if (typeof value === "string" && value.trim())
|
|
1260
|
+
return value.trim();
|
|
1261
|
+
}
|
|
1262
|
+
return undefined;
|
|
1263
|
+
}
|
|
1264
|
+
function messageOf(error) {
|
|
1265
|
+
return error instanceof Error ? error.message : String(error);
|
|
1266
|
+
}
|
|
1267
|
+
// ---- Probe cache (v0.1.60) — mechanism, not policy -----------------------
|
|
1268
|
+
const _probeCache = new Map();
|
|
1269
|
+
const PROBE_CACHE_TTL_MS = 60_000; // 60s
|
|
1270
|
+
function cachedProbeBackend(id, context) {
|
|
1271
|
+
const key = `${id}:${context.cwd || ''}`;
|
|
1272
|
+
const cached = _probeCache.get(key);
|
|
1273
|
+
if (cached && Date.now() - cached.at < PROBE_CACHE_TTL_MS)
|
|
1274
|
+
return cached.result;
|
|
1275
|
+
const result = probeBackend(id, context);
|
|
1276
|
+
_probeCache.set(key, { result, at: Date.now() });
|
|
1277
|
+
return result;
|
|
1278
|
+
}
|
|
1279
|
+
function clearProbeCache() { _probeCache.clear(); }
|