@loucompanion/forge-bridge 0.1.3-dev.f2b8822ce953 → 0.1.3-dev.fbe15023cdfe
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/README.md +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +970 -24
- package/dist/bridge/bridge.types.d.ts +172 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +351 -22
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +350 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +561 -0
- package/dist/session/phase-completion.artifacts.js +32 -4
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +20 -6
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +129 -7
- package/dist/session/phase-completion.schemas.js +132 -5
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
- package/dist/session/provider-cli/provider-cli.process.js +223 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +17 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +226 -18
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +19 -3
- package/dist/session/provider-cli/provider-cli.service.js +503 -98
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +463 -58
- package/dist/session/session.types.d.ts +144 -1
- package/dist/session/session.types.js +279 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +21 -1
- package/dist/worktree/dependency-warmer.js +138 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,2338 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { failureResult } from "../session/session.types.js";
|
|
6
|
+
import { dockerfileUpstreamImages, validateDockerfilePolicy, validateSandboxContract } from "./contract.js";
|
|
7
|
+
import { requireSandboxProcess, runSandboxProcess } from "./process.js";
|
|
8
|
+
import { createLeaseUpdate } from "./lease-watchdog.js";
|
|
9
|
+
import { evaluateSandboxImagePolicy, hasSandboxImagePolicyRecord, sandboxImagePolicyConfigurationDigest, verifySandboxImageSignature } from "./image-policy.js";
|
|
10
|
+
import { SandboxEventBudget } from "./event-budget.js";
|
|
11
|
+
import { RepositoryCoordinatorLock } from "./repository-lock.js";
|
|
12
|
+
import { redactText } from "../shared/redaction/redaction.js";
|
|
13
|
+
import { createRuntimeIsolationLease, renewRuntimeIsolationLease, transitionRuntimeIsolationLease, validRuntimeIsolationLease, withRuntimeIsolationResources, } from "../runtime/runtime-isolation-lease.js";
|
|
14
|
+
import { sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "./provider-auth/codex.js";
|
|
15
|
+
const managedLabel = "dev.forge.managed=true";
|
|
16
|
+
const bridgeRuntimeDigestLabel = "dev.forge.bridge-runtime-digest";
|
|
17
|
+
export class DockerSandboxRuntime {
|
|
18
|
+
config;
|
|
19
|
+
publicationGrantRedeemer;
|
|
20
|
+
active = new Map();
|
|
21
|
+
degraded = new Set();
|
|
22
|
+
builds = new Map();
|
|
23
|
+
verifiedCatalogImages = new Set();
|
|
24
|
+
buildTail = Promise.resolve();
|
|
25
|
+
constructor(config, publicationGrantRedeemer) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.publicationGrantRedeemer = publicationGrantRedeemer;
|
|
28
|
+
}
|
|
29
|
+
async runOnce(dispatch, sink, signal) {
|
|
30
|
+
if (!dispatch.sandbox || dispatch.sandbox.executionMode !== "docker" || dispatch.sandbox.runtime !== "docker") {
|
|
31
|
+
throw new Error("Docker runtime requires a protocol-v2 Docker sandbox dispatch.");
|
|
32
|
+
}
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const abort = () => controller.abort(signal?.reason ?? "sandbox cancelled");
|
|
35
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
36
|
+
const runtimeLease = createRuntimeIsolationLease({
|
|
37
|
+
leaseId: dispatch.sandbox.executionLeaseId,
|
|
38
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
39
|
+
executionLeaseId: dispatch.sandbox.executionLeaseId,
|
|
40
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
41
|
+
runnerId: dispatch.execution.runnerId ?? "",
|
|
42
|
+
runnerInstanceId: dispatch.sandbox.runnerInstanceId,
|
|
43
|
+
worktreeKey: dispatch.lease.worktreeKey,
|
|
44
|
+
worktreePath: "/workspace",
|
|
45
|
+
mode: "docker",
|
|
46
|
+
expiresAt: dispatch.lease.expiresAt,
|
|
47
|
+
resources: {
|
|
48
|
+
memoryReservationBytes: dispatch.sandbox.requestedResources.memoryBytes,
|
|
49
|
+
cpuMillis: dispatch.sandbox.requestedResources.cpuMillis,
|
|
50
|
+
pids: dispatch.sandbox.requestedResources.pids,
|
|
51
|
+
ports: [],
|
|
52
|
+
paths: [
|
|
53
|
+
{ path: "/workspace", role: "worktree" },
|
|
54
|
+
{ path: "/tmp", role: "temporary" },
|
|
55
|
+
{ path: "/control/output/logs", role: "logs" },
|
|
56
|
+
{ path: "/tmp/forge-browser-profile", role: "browser_profile" },
|
|
57
|
+
],
|
|
58
|
+
processSupervisorId: null,
|
|
59
|
+
dockerResources: [],
|
|
60
|
+
singletonKeys: [],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const active = {
|
|
64
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
65
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
66
|
+
expiresAt: dispatch.lease.expiresAt,
|
|
67
|
+
controller,
|
|
68
|
+
phase: "starting",
|
|
69
|
+
runtimeLease,
|
|
70
|
+
ledger: null,
|
|
71
|
+
leaseControlKey: randomBytes(32).toString("base64url")
|
|
72
|
+
};
|
|
73
|
+
this.active.set(active.dispatchAttemptId, active);
|
|
74
|
+
const watchdog = setInterval(() => {
|
|
75
|
+
if (Date.parse(active.expiresAt) <= Date.now() && !controller.signal.aborted) {
|
|
76
|
+
controller.abort("dispatch lease expired");
|
|
77
|
+
}
|
|
78
|
+
}, 500);
|
|
79
|
+
watchdog.unref?.();
|
|
80
|
+
const runtimeSignal = controller.signal;
|
|
81
|
+
try {
|
|
82
|
+
const identity = identityFor(dispatch);
|
|
83
|
+
const attemptRoot = path.join(this.config.sandboxStateRoot, "attempts", identity.attemptHex);
|
|
84
|
+
await fs.mkdir(path.join(attemptRoot, "input"), { recursive: true, mode: 0o700 });
|
|
85
|
+
await fs.mkdir(path.join(attemptRoot, "output"), { recursive: true, mode: 0o700 });
|
|
86
|
+
const repoPath = dispatch.repo.serverLocalPath ? path.resolve(dispatch.repo.serverLocalPath) : null;
|
|
87
|
+
if (!repoPath)
|
|
88
|
+
throw new Error("sandbox_input_failed: bridge repository mapping is required for bundle handoff.");
|
|
89
|
+
await sink.emit(event("sandbox_preparing", `Preparing Docker sandbox attempt ${dispatch.sandbox.dispatchAttemptId}.`));
|
|
90
|
+
const git = new HostGit(this.config, repoPath);
|
|
91
|
+
const prepared = await git.prepare(dispatch, attemptRoot, runtimeSignal);
|
|
92
|
+
const validated = validateSandboxContract(prepared.contractSource);
|
|
93
|
+
validateSandboxMemoryReservation(dispatch, validated);
|
|
94
|
+
validateDockerfilePolicy(prepared.dockerfile, dispatch.sandbox.executionPolicy === "sandbox_required");
|
|
95
|
+
const upstreamImages = dockerfileUpstreamImages(prepared.dockerfile);
|
|
96
|
+
const securityOptions = resolveSandboxSecurityOptions(dispatch.sandbox.executionPolicy);
|
|
97
|
+
const builtImage = await this.buildImage(prepared.contextRoot, prepared.contextDigest, dispatch.repo.name, validated, upstreamImages, dispatch.sandbox.executionPolicy, sink, runtimeSignal);
|
|
98
|
+
const imageDigest = builtImage.imageDigest;
|
|
99
|
+
const runtimeDispatch = withSandboxMetadata(dispatch, prepared.inputCommitSha, prepared.configCommitSha, validated.digest, imageDigest);
|
|
100
|
+
const providerAuthAdapter = sandboxProviderAuthAdapterFor(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
|
|
101
|
+
const providerAuthUnavailable = sandboxProviderAuthUnavailableMessage(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
|
|
102
|
+
if (providerAuthUnavailable) {
|
|
103
|
+
throw new Error(`sandbox_auth_unavailable: ${providerAuthUnavailable}`);
|
|
104
|
+
}
|
|
105
|
+
await sink.emit(event("sandbox_metadata", "Resolved immutable sandbox inputs.", {
|
|
106
|
+
inputCommitSha: prepared.inputCommitSha,
|
|
107
|
+
sandboxConfigCommitSha: prepared.configCommitSha,
|
|
108
|
+
contractDigest: validated.digest,
|
|
109
|
+
imageDigest
|
|
110
|
+
}));
|
|
111
|
+
const resources = resourceNames(identity.attemptHex, validated.contract.caches.filter(cache => cache.mode === "per_attempt").map(cache => cache.name));
|
|
112
|
+
const labels = sandboxResourceLabels(runtimeDispatch, imageDigest, validated.digest);
|
|
113
|
+
const networkArgs = networkCreateArgs(runtimeDispatch, process.env, this.config.allowOpenPublicSandbox);
|
|
114
|
+
active.runtimeLease = withRuntimeIsolationResources(transitionRuntimeIsolationLease(active.runtimeLease, "starting"), {
|
|
115
|
+
...active.runtimeLease.resources,
|
|
116
|
+
dockerResources: plannedDockerLeaseResources(resources, runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist", Boolean(providerAuthAdapter)),
|
|
117
|
+
});
|
|
118
|
+
active.ledger = {
|
|
119
|
+
schemaVersion: 3,
|
|
120
|
+
runtimeLease: active.runtimeLease,
|
|
121
|
+
resources,
|
|
122
|
+
labels,
|
|
123
|
+
includeCredentials: Boolean(providerAuthAdapter)
|
|
124
|
+
};
|
|
125
|
+
await this.writeLedger(active.ledger);
|
|
126
|
+
let teardownComplete = false;
|
|
127
|
+
let memoryTelemetryEvent = null;
|
|
128
|
+
try {
|
|
129
|
+
const internalNetworkId = dockerObjectId((await this.docker(["network", "create", ...networkArgs, ...labelArgs(labels), resources.network], 30_000, runtimeSignal)).stdout, "network");
|
|
130
|
+
await this.docker(["volume", "create", ...labelArgs(labels), resources.workspaceVolume], 30_000, runtimeSignal);
|
|
131
|
+
await this.docker(["volume", "create", ...labelArgs(labels), resources.controlVolume], 30_000, runtimeSignal);
|
|
132
|
+
await this.docker(["volume", "create", ...labelArgs(labels), resources.secretsVolume], 30_000, runtimeSignal);
|
|
133
|
+
if (providerAuthAdapter) {
|
|
134
|
+
await this.docker(["volume", "create", ...labelArgs(labels), resources.credentialsVolume], 30_000, runtimeSignal);
|
|
135
|
+
}
|
|
136
|
+
for (const cacheVolume of Object.values(resources.cacheVolumes)) {
|
|
137
|
+
await this.docker(["volume", "create", ...labelArgs(labels), cacheVolume], 30_000, runtimeSignal);
|
|
138
|
+
}
|
|
139
|
+
let egressNetworkId = null;
|
|
140
|
+
if (runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist") {
|
|
141
|
+
egressNetworkId = dockerObjectId((await this.docker(["network", "create", ...labelArgs(labels), resources.egressNetwork], 30_000, runtimeSignal)).stdout, "network");
|
|
142
|
+
await this.docker(["volume", "create", ...labelArgs(labels), resources.egressConfigVolume], 30_000, runtimeSignal);
|
|
143
|
+
}
|
|
144
|
+
await this.initializeVolumes(builtImage.helperImageDigest, resources, labels, Boolean(providerAuthAdapter), runtimeSignal);
|
|
145
|
+
await this.initializeProviderCredentials(builtImage.helperImageDigest, runtimeDispatch, resources, labels, runtimeSignal);
|
|
146
|
+
await sink.emit(event("sandbox_cache_policy", "Runtime caches are builder-managed or isolated to this attempt.", {
|
|
147
|
+
builderManaged: validated.contract.caches.filter(cache => cache.mode === "readonly").map(cache => cache.name),
|
|
148
|
+
perAttempt: Object.keys(resources.cacheVolumes)
|
|
149
|
+
}));
|
|
150
|
+
await this.initializeServiceSecrets(builtImage.helperImageDigest, validated, resources, labels, runtimeSignal);
|
|
151
|
+
await this.initializeEgressPolicy(builtImage.helperImageDigest, runtimeDispatch, resources, labels, runtimeSignal);
|
|
152
|
+
const egressProxy = await this.startEgressProxy(runtimeDispatch, resources, labels, securityOptions, sink, runtimeSignal);
|
|
153
|
+
const serviceContainers = await this.startServices(validated, runtimeDispatch.sandbox.executionPolicy, resources, labels, securityOptions, sink, runtimeSignal);
|
|
154
|
+
const createArgs = workerCreateArgs(runtimeDispatch, validated, resources, labels, imageDigest, securityOptions);
|
|
155
|
+
const createdContainer = await this.docker(createArgs, 60_000, runtimeSignal);
|
|
156
|
+
const containerId = dockerObjectId(createdContainer.stdout, "container");
|
|
157
|
+
await this.docker(["cp", prepared.bundlePath, `${containerId}:/control/input/repo.bundle`], 60_000, runtimeSignal);
|
|
158
|
+
const runtimeContainers = [
|
|
159
|
+
{ id: containerId, name: resources.container, role: "agent" },
|
|
160
|
+
...(egressProxy ? [egressProxy] : []),
|
|
161
|
+
...serviceContainers
|
|
162
|
+
];
|
|
163
|
+
active.runtimeLease = withRuntimeIsolationResources(transitionRuntimeIsolationLease(active.runtimeLease, "active"), {
|
|
164
|
+
...active.runtimeLease.resources,
|
|
165
|
+
dockerResources: activeDockerLeaseResources(runtimeContainers, [
|
|
166
|
+
{ id: internalNetworkId, name: resources.network, role: "internal" },
|
|
167
|
+
...(egressNetworkId ? [{ id: egressNetworkId, name: resources.egressNetwork, role: "egress" }] : []),
|
|
168
|
+
], sandboxRuntimeVolumes(resources, runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist", Boolean(providerAuthAdapter))),
|
|
169
|
+
});
|
|
170
|
+
active.ledger = { ...active.ledger, runtimeLease: active.runtimeLease };
|
|
171
|
+
await this.writeLedger(active.ledger);
|
|
172
|
+
await sink.emit(event("sandbox_started", `Docker sandbox ${identity.attemptHex} started.`, {
|
|
173
|
+
agentContainer: runtimeContainers[0],
|
|
174
|
+
sidecarContainers: [...(egressProxy ? [egressProxy] : []), ...serviceContainers],
|
|
175
|
+
networks: [
|
|
176
|
+
{ id: internalNetworkId, name: resources.network, role: "internal" },
|
|
177
|
+
...(egressNetworkId ? [{ id: egressNetworkId, name: resources.egressNetwork, role: "egress" }] : [])
|
|
178
|
+
],
|
|
179
|
+
volumes: sandboxRuntimeVolumes(resources, runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist", Boolean(providerAuthAdapter)),
|
|
180
|
+
publishedPorts: [],
|
|
181
|
+
runtimeLease: active.runtimeLease,
|
|
182
|
+
}));
|
|
183
|
+
const eventBudget = new SandboxEventBudget();
|
|
184
|
+
const startPromise = runSandboxProcess({
|
|
185
|
+
command: this.config.dockerExecutable,
|
|
186
|
+
args: this.dockerArgs(["start", "--attach", "--interactive", containerId]),
|
|
187
|
+
input: JSON.stringify({
|
|
188
|
+
schemaVersion: 1,
|
|
189
|
+
dispatch: runtimeDispatch,
|
|
190
|
+
leaseControlKey: active.leaseControlKey,
|
|
191
|
+
limits: sandboxTimeLimits()
|
|
192
|
+
}),
|
|
193
|
+
timeoutMs: 8 * 60 * 60 * 1000,
|
|
194
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
195
|
+
terminateOnOutputLimit: true,
|
|
196
|
+
signal: runtimeSignal,
|
|
197
|
+
onStdoutLine: async (line) => {
|
|
198
|
+
eventBudget.accept(line);
|
|
199
|
+
const streamedEvent = parseSandboxEventLine(line);
|
|
200
|
+
if (streamedEvent) {
|
|
201
|
+
if (streamedEvent.eventType === "provider_cli_started")
|
|
202
|
+
active.phase = "active";
|
|
203
|
+
await sink.emit(streamedEvent);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
const memorySampler = this.startPeakMemorySampler(runtimeContainers);
|
|
208
|
+
const start = await startPromise.finally(async () => {
|
|
209
|
+
const telemetry = await memorySampler.stop();
|
|
210
|
+
if (!telemetry)
|
|
211
|
+
return;
|
|
212
|
+
memoryTelemetryEvent = event("sandbox_peak_memory_observed", `Observed sandbox peak memory ${formatMemoryBytes(telemetry.peakMemoryBytes)} across ${telemetry.containers.length} container${telemetry.containers.length === 1 ? "" : "s"} from ${telemetry.sampleCount} sample${telemetry.sampleCount === 1 ? "" : "s"}.`, { ...telemetry });
|
|
213
|
+
if (active.ledger) {
|
|
214
|
+
active.ledger = { ...active.ledger, peakMemory: telemetry };
|
|
215
|
+
await this.writeLedger(active.ledger).catch(() => undefined);
|
|
216
|
+
}
|
|
217
|
+
await sink.emit(memoryTelemetryEvent).catch(() => undefined);
|
|
218
|
+
});
|
|
219
|
+
if (start.truncated)
|
|
220
|
+
throw new Error("sandbox_log_limit: sandbox output exceeded the 8 MiB supervisor capture budget.");
|
|
221
|
+
const containerState = await this.inspectContainerState(containerId);
|
|
222
|
+
if (containerState.oomKilled) {
|
|
223
|
+
throw new Error("sandbox_oom_killed: Docker terminated the agent after it exceeded the memory limit.");
|
|
224
|
+
}
|
|
225
|
+
if (start.exitCode !== 0) {
|
|
226
|
+
const resourceFailure = classifySandboxResourceFailure([
|
|
227
|
+
containerState.error,
|
|
228
|
+
start.stderr,
|
|
229
|
+
start.stdout
|
|
230
|
+
].filter(Boolean).join("\n"));
|
|
231
|
+
if (resourceFailure)
|
|
232
|
+
throw new Error(`${resourceFailure.code}: ${resourceFailure.message}`);
|
|
233
|
+
}
|
|
234
|
+
active.phase = "active";
|
|
235
|
+
await sink.emit(event("sandbox_agent_exited", `Sandbox worker exited with code ${start.exitCode}.`, {
|
|
236
|
+
workerExitCode: start.exitCode
|
|
237
|
+
}));
|
|
238
|
+
await this.docker(["cp", `${containerId}:/control/output/.`, path.join(attemptRoot, "output")], 60_000);
|
|
239
|
+
let output;
|
|
240
|
+
try {
|
|
241
|
+
output = await validateOutput(attemptRoot, runtimeDispatch);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (start.exitCode !== 0 && isMissingSandboxManifest(error)) {
|
|
245
|
+
throw new Error(sandboxWorkerFailureMessage(start.exitCode, start.stderr, containerState.error), { cause: error });
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
await git.importResult(runtimeDispatch, output, () => assertSandboxImportCurrent(active, this.active.get(active.dispatchAttemptId), controller.signal));
|
|
250
|
+
if (runtimeDispatch.sandbox.publicationMode === "bridge_push") {
|
|
251
|
+
const handle = runtimeDispatch.sandbox.publishGrantHandle;
|
|
252
|
+
if (!handle || !output.manifest.resultCommitSha || !this.publicationGrantRedeemer) {
|
|
253
|
+
throw new Error("sandbox_output_publish_forbidden: bridge publication grant redemption is unavailable.");
|
|
254
|
+
}
|
|
255
|
+
const credential = await this.publicationGrantRedeemer({
|
|
256
|
+
dispatchAttemptId: runtimeDispatch.sandbox.dispatchAttemptId,
|
|
257
|
+
leaseId: runtimeDispatch.lease.id,
|
|
258
|
+
fencingToken: runtimeDispatch.lease.fencingToken,
|
|
259
|
+
runnerInstanceId: runtimeDispatch.sandbox.runnerInstanceId,
|
|
260
|
+
grantId: handle.grantId,
|
|
261
|
+
branch: handle.branch,
|
|
262
|
+
resultCommitSha: output.manifest.resultCommitSha
|
|
263
|
+
}, runtimeSignal);
|
|
264
|
+
validatePublicationCredential(credential);
|
|
265
|
+
await git.publishResult(runtimeDispatch, output, () => assertSandboxImportCurrent(active, this.active.get(active.dispatchAttemptId), controller.signal), credential, runtimeSignal);
|
|
266
|
+
await sink.emit(event("sandbox_remote_published", "Published the exact fenced result commit from the bridge host.", {
|
|
267
|
+
grantId: runtimeDispatch.sandbox.publishGrantHandle.grantId,
|
|
268
|
+
branch: runtimeDispatch.sandbox.publishGrantHandle.branch,
|
|
269
|
+
resultCommitSha: output.manifest.resultCommitSha
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
const result = JSON.parse(await fs.readFile(output.resultPath, "utf8"));
|
|
273
|
+
if (result.exitCode !== 0) {
|
|
274
|
+
const resourceFailure = classifySandboxResourceFailure([
|
|
275
|
+
result.summary,
|
|
276
|
+
...result.events.flatMap(resultEvent => [resultEvent.eventType, resultEvent.content, resultEvent.data ?? ""])
|
|
277
|
+
].join("\n"));
|
|
278
|
+
if (resourceFailure) {
|
|
279
|
+
return appendRunOnceEvent({
|
|
280
|
+
...result,
|
|
281
|
+
summary: resourceFailure.message,
|
|
282
|
+
events: [...result.events, event(resourceFailure.code, resourceFailure.message, { phase: "agent" })]
|
|
283
|
+
}, memoryTelemetryEvent);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
await sink.emit(event("sandbox_output_imported", "Validated and imported sandbox output.", {
|
|
287
|
+
resultCommitSha: output.manifest.resultCommitSha,
|
|
288
|
+
}));
|
|
289
|
+
return appendRunOnceEvent(result, memoryTelemetryEvent);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
+
const classified = classifySandboxRuntimeFailure(message);
|
|
294
|
+
return appendRunOnceEvent(failureResult(classified.message, [event(classified.code, classified.message, { phase: classified.phase })]), memoryTelemetryEvent);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
try {
|
|
298
|
+
try {
|
|
299
|
+
active.runtimeLease = transitionForCleanup(active.runtimeLease, "releasing");
|
|
300
|
+
if (active.ledger) {
|
|
301
|
+
active.ledger = { ...active.ledger, runtimeLease: active.runtimeLease };
|
|
302
|
+
await this.writeLedger(active.ledger);
|
|
303
|
+
}
|
|
304
|
+
await sink.emit(event("sandbox_teardown_started", `Removing Docker resources for attempt ${active.dispatchAttemptId}.`, {
|
|
305
|
+
runtimeLease: active.runtimeLease,
|
|
306
|
+
})).catch(() => undefined);
|
|
307
|
+
const cleanupReport = await this.teardown(resources, labels, "teardown", Boolean(providerAuthAdapter));
|
|
308
|
+
await this.persistCleanupReport(cleanupReport);
|
|
309
|
+
teardownComplete = true;
|
|
310
|
+
active.runtimeLease = transitionRuntimeIsolationLease(active.runtimeLease, "released");
|
|
311
|
+
this.degraded.delete(active.dispatchAttemptId);
|
|
312
|
+
await this.removeLedger(active.dispatchAttemptId);
|
|
313
|
+
await sink.emit(event("sandbox_teardown_completed", `Removed Docker resources for attempt ${active.dispatchAttemptId}.`, { ...cleanupReport })).catch(() => undefined);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this.degraded.add(active.dispatchAttemptId);
|
|
317
|
+
active.runtimeLease = transitionForCleanup(active.runtimeLease, "degraded");
|
|
318
|
+
if (active.ledger) {
|
|
319
|
+
active.ledger = { ...active.ledger, runtimeLease: active.runtimeLease };
|
|
320
|
+
await this.writeLedger(active.ledger).catch(() => undefined);
|
|
321
|
+
}
|
|
322
|
+
const cleanupReport = error instanceof SandboxTeardownError ? error.report : emptyCleanupReport("teardown");
|
|
323
|
+
await this.persistCleanupReport(cleanupReport);
|
|
324
|
+
await sink.emit(event("teardown_failed", `Docker resources for attempt ${active.dispatchAttemptId} require reaping.`, { ...cleanupReport })).catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
if (teardownComplete)
|
|
329
|
+
await fs.rm(attemptRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
clearInterval(watchdog);
|
|
335
|
+
signal?.removeEventListener("abort", abort);
|
|
336
|
+
this.active.delete(active.dispatchAttemptId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async updateLease(dispatchAttemptId, fencingToken, expiresAt) {
|
|
340
|
+
const active = this.active.get(dispatchAttemptId);
|
|
341
|
+
if (!active || active.fencingToken !== fencingToken)
|
|
342
|
+
return;
|
|
343
|
+
const renewed = renewRuntimeIsolationLease(active.runtimeLease, fencingToken, expiresAt);
|
|
344
|
+
if (!renewed)
|
|
345
|
+
return;
|
|
346
|
+
active.expiresAt = expiresAt;
|
|
347
|
+
active.runtimeLease = renewed;
|
|
348
|
+
if (active.ledger) {
|
|
349
|
+
active.ledger = { ...active.ledger, runtimeLease: renewed };
|
|
350
|
+
await this.writeLedger(active.ledger);
|
|
351
|
+
await this.pushLeaseUpdate(active).catch(() => undefined);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async pushLeaseUpdate(active) {
|
|
355
|
+
if (!active.ledger)
|
|
356
|
+
return;
|
|
357
|
+
const update = createLeaseUpdate(active.dispatchAttemptId, active.fencingToken, active.expiresAt, active.leaseControlKey);
|
|
358
|
+
const script = "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const f=require('fs'),p='/run/forge/lease-update.json',t=p+'.tmp';f.writeFileSync(t,s,{mode:0o600});f.renameSync(t,p)})";
|
|
359
|
+
await requireSandboxProcess({
|
|
360
|
+
command: this.config.dockerExecutable,
|
|
361
|
+
args: this.dockerArgs(["exec", "--interactive", active.ledger.resources.container, "node", "-e", script]),
|
|
362
|
+
input: `${JSON.stringify(update)}\n`,
|
|
363
|
+
timeoutMs: 10_000,
|
|
364
|
+
maxOutputBytes: 16 * 1024
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
snapshot() {
|
|
368
|
+
let starting = 0;
|
|
369
|
+
let active = 0;
|
|
370
|
+
for (const sandbox of this.active.values()) {
|
|
371
|
+
if (sandbox.phase === "starting")
|
|
372
|
+
starting += 1;
|
|
373
|
+
else
|
|
374
|
+
active += 1;
|
|
375
|
+
}
|
|
376
|
+
return { starting, active, degraded: this.degraded.size };
|
|
377
|
+
}
|
|
378
|
+
async reap(options = {}) {
|
|
379
|
+
const liveAttempts = new Set();
|
|
380
|
+
const snapshotByAttempt = new Map((options.attemptSnapshot ?? []).map(attempt => [attempt.dispatchAttemptId, attempt]));
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
for (const attempt of options.attemptSnapshot ?? []) {
|
|
383
|
+
if (attempt.runnerInstanceId === this.config.runnerInstanceId && Date.parse(attempt.expiresAt) > now) {
|
|
384
|
+
liveAttempts.add(attempt.dispatchAttemptId);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const ledger of await this.readLedgers()) {
|
|
388
|
+
if (ledger.labels["dev.forge.runner-id"] !== this.config.runnerId)
|
|
389
|
+
continue;
|
|
390
|
+
const lease = ledger.runtimeLease;
|
|
391
|
+
const current = this.active.get(lease.dispatchAttemptId);
|
|
392
|
+
if (current
|
|
393
|
+
&& current.fencingToken === lease.fencingToken
|
|
394
|
+
&& lease.runnerInstanceId === this.config.runnerInstanceId) {
|
|
395
|
+
liveAttempts.add(lease.dispatchAttemptId);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const serverAttempt = snapshotByAttempt.get(lease.dispatchAttemptId);
|
|
399
|
+
if (!shouldReapSandboxLedger(ledger, this.config.runnerInstanceId, serverAttempt, options.attemptSnapshot !== undefined, now)) {
|
|
400
|
+
liveAttempts.add(lease.dispatchAttemptId);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const releasing = transitionForCleanup(lease, "releasing");
|
|
405
|
+
await this.writeLedger({ ...ledger, runtimeLease: releasing });
|
|
406
|
+
const report = await this.teardown(ledger.resources, ledger.labels, "reaper", ledger.includeCredentials !== false);
|
|
407
|
+
await this.persistCleanupReport(report);
|
|
408
|
+
await this.removeLedger(lease.dispatchAttemptId);
|
|
409
|
+
this.degraded.delete(lease.dispatchAttemptId);
|
|
410
|
+
await fs.rm(path.join(this.config.sandboxStateRoot, "attempts", lease.dispatchAttemptId.replace(/-/g, "").toLowerCase()), { recursive: true, force: true }).catch(() => undefined);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
if (error instanceof SandboxTeardownError)
|
|
414
|
+
await this.persistCleanupReport(error.report);
|
|
415
|
+
const degraded = transitionForCleanup(lease, "degraded");
|
|
416
|
+
await this.writeLedger({ ...ledger, runtimeLease: degraded }).catch(() => undefined);
|
|
417
|
+
this.degraded.add(lease.dispatchAttemptId);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const kind of ["container", "network", "volume"]) {
|
|
421
|
+
for (const id of await this.listResources(kind, "dev.forge.managed=true")) {
|
|
422
|
+
const labels = await this.inspectResourceLabels(kind, id).catch(() => null);
|
|
423
|
+
if (!labels || !mayReapManagedResource(labels, this.config.runnerId, liveAttempts))
|
|
424
|
+
continue;
|
|
425
|
+
await this.removeResource(kind, id).catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
await this.purgeRestrictedAuditArtifacts();
|
|
429
|
+
await this.withBuildPermit(async () => {
|
|
430
|
+
if (options.diskLow) {
|
|
431
|
+
await this.collectExpiredImages();
|
|
432
|
+
}
|
|
433
|
+
if (this.active.size === 0 && this.builds.size === 0) {
|
|
434
|
+
await this.stopBuildxBuilder().catch(() => undefined);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async pendingCleanupReports() {
|
|
439
|
+
const root = path.join(this.config.sandboxStateRoot, "cleanup-reports");
|
|
440
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
441
|
+
const reports = [];
|
|
442
|
+
for (const entry of (await fs.readdir(root, { withFileTypes: true })).slice(0, 256)) {
|
|
443
|
+
if (!entry.isFile() || !/^[0-9a-f]{32}\.json$/iu.test(entry.name))
|
|
444
|
+
continue;
|
|
445
|
+
const target = path.join(root, entry.name);
|
|
446
|
+
const stat = await fs.lstat(target);
|
|
447
|
+
if (stat.isSymbolicLink() || stat.size > 256 * 1024)
|
|
448
|
+
continue;
|
|
449
|
+
try {
|
|
450
|
+
const report = parseCleanupReport(JSON.parse(await fs.readFile(target, "utf8")));
|
|
451
|
+
if (report)
|
|
452
|
+
reports.push(report);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// A corrupt report is retained for operator inspection and never sent as trusted data.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return reports;
|
|
459
|
+
}
|
|
460
|
+
async acknowledgeCleanupReport(reportId) {
|
|
461
|
+
await fs.rm(this.cleanupReportPath(reportId), { force: true });
|
|
462
|
+
}
|
|
463
|
+
async persistCleanupReport(report) {
|
|
464
|
+
if (report.actions.length === 0)
|
|
465
|
+
return;
|
|
466
|
+
const root = path.join(this.config.sandboxStateRoot, "cleanup-reports");
|
|
467
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
468
|
+
const target = this.cleanupReportPath(report.reportId);
|
|
469
|
+
try {
|
|
470
|
+
await fs.access(target);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// The report is new. Write it durably before acknowledging cleanup to the server.
|
|
475
|
+
}
|
|
476
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
477
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
478
|
+
try {
|
|
479
|
+
await handle.writeFile(`${JSON.stringify(report)}\n`);
|
|
480
|
+
await handle.sync();
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
await handle.close();
|
|
484
|
+
}
|
|
485
|
+
await fs.rename(temporary, target);
|
|
486
|
+
}
|
|
487
|
+
cleanupReportPath(reportId) {
|
|
488
|
+
const value = reportId.replace(/-/gu, "").toLowerCase();
|
|
489
|
+
if (!/^[0-9a-f]{32}$/u.test(value))
|
|
490
|
+
throw new Error("sandbox_cleanup_report_invalid: report id must be a UUID.");
|
|
491
|
+
return path.join(this.config.sandboxStateRoot, "cleanup-reports", `${value}.json`);
|
|
492
|
+
}
|
|
493
|
+
async writeLedger(ledger) {
|
|
494
|
+
const root = path.join(this.config.sandboxStateRoot, "ledger");
|
|
495
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
496
|
+
const target = this.ledgerPath(ledger.runtimeLease.dispatchAttemptId);
|
|
497
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
498
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
499
|
+
try {
|
|
500
|
+
await handle.writeFile(`${JSON.stringify(ledger)}\n`);
|
|
501
|
+
await handle.sync();
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
await handle.close();
|
|
505
|
+
}
|
|
506
|
+
await fs.rename(temporary, target);
|
|
507
|
+
}
|
|
508
|
+
async readLedgers() {
|
|
509
|
+
const root = path.join(this.config.sandboxStateRoot, "ledger");
|
|
510
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
511
|
+
const result = [];
|
|
512
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
513
|
+
if (!entry.isFile() || !/^[0-9a-f]{32}\.json$/i.test(entry.name))
|
|
514
|
+
continue;
|
|
515
|
+
const target = path.join(root, entry.name);
|
|
516
|
+
const stat = await fs.lstat(target);
|
|
517
|
+
if (stat.isSymbolicLink() || stat.size > 64 * 1024)
|
|
518
|
+
continue;
|
|
519
|
+
const ledger = normalizeSandboxLedger(JSON.parse(await fs.readFile(target, "utf8")));
|
|
520
|
+
if (ledger)
|
|
521
|
+
result.push(ledger);
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
ledgerPath(dispatchAttemptId) {
|
|
526
|
+
const attemptHex = dispatchAttemptId.replace(/-/g, "").toLowerCase();
|
|
527
|
+
if (!/^[0-9a-f]{32}$/.test(attemptHex))
|
|
528
|
+
throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
|
|
529
|
+
return path.join(this.config.sandboxStateRoot, "ledger", `${attemptHex}.json`);
|
|
530
|
+
}
|
|
531
|
+
async removeLedger(dispatchAttemptId) {
|
|
532
|
+
await fs.rm(this.ledgerPath(dispatchAttemptId), { force: true });
|
|
533
|
+
}
|
|
534
|
+
async buildImage(contextRoot, contextDigest, project, contract, upstreamImages, policy, sink, signal) {
|
|
535
|
+
const toolingImage = process.env.FORGE_AGENT_TOOLING_IMAGE ?? "node:22.17.0-bookworm-slim";
|
|
536
|
+
if (policy === "sandbox_required" && !/@sha256:[0-9a-f]{64}$/i.test(toolingImage)) {
|
|
537
|
+
throw new Error("sandbox_build_failed: FORGE_AGENT_TOOLING_IMAGE must be digest-pinned for sandbox_required.");
|
|
538
|
+
}
|
|
539
|
+
const codexVersion = process.env.FORGE_CODEX_PACKAGE_VERSION ?? "0.144.1";
|
|
540
|
+
if (!/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(codexVersion))
|
|
541
|
+
throw new Error("sandbox_build_failed: invalid pinned Codex package version.");
|
|
542
|
+
const policyConfigurationDigest = await sandboxImagePolicyConfigurationDigest();
|
|
543
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
544
|
+
const bridgeRuntimeDigest = await bridgeRuntimeContentDigest(packageRoot);
|
|
545
|
+
const key = sandboxImageCacheKey({
|
|
546
|
+
contextDigest,
|
|
547
|
+
contractDigest: contract.digest,
|
|
548
|
+
platform: process.platform,
|
|
549
|
+
architecture: process.arch,
|
|
550
|
+
bridgeVersion: this.config.version,
|
|
551
|
+
bridgeRuntimeDigest,
|
|
552
|
+
toolingImage,
|
|
553
|
+
codexVersion,
|
|
554
|
+
policy,
|
|
555
|
+
policyConfigurationDigest
|
|
556
|
+
});
|
|
557
|
+
const existing = this.builds.get(key);
|
|
558
|
+
if (existing) {
|
|
559
|
+
await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} is being built by another dispatch.`, { cacheKey: key, singleflight: true }));
|
|
560
|
+
const waitStartedAt = Date.now();
|
|
561
|
+
const built = await existing;
|
|
562
|
+
await sink.emit(event("sandbox_build_singleflight_wait", `Waited for the existing sandbox image build ${key.slice(0, 12)}.`, {
|
|
563
|
+
durationMs: Date.now() - waitStartedAt,
|
|
564
|
+
imageDigest: built.imageDigest
|
|
565
|
+
}));
|
|
566
|
+
return built;
|
|
567
|
+
}
|
|
568
|
+
const promise = this.withBuildPermit(async () => {
|
|
569
|
+
try {
|
|
570
|
+
return await this.buildImageCore(contextRoot, contextDigest, contract.digest, key, project, packageRoot, bridgeRuntimeDigest, toolingImage, upstreamImages, codexVersion, policy, sink, signal);
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
await this.stopBuildxBuilder().catch(async (error) => {
|
|
574
|
+
await sink.emit(event("sandbox_builder_release_failed", `The dedicated BuildKit builder could not be stopped: ${error instanceof Error ? error.message : String(error)}.`)).catch(() => undefined);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}, signal);
|
|
578
|
+
this.builds.set(key, promise);
|
|
579
|
+
try {
|
|
580
|
+
return await promise;
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
this.builds.delete(key);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async withBuildPermit(action, signal) {
|
|
587
|
+
let release = () => undefined;
|
|
588
|
+
const turn = new Promise(resolve => { release = resolve; });
|
|
589
|
+
const previous = this.buildTail;
|
|
590
|
+
this.buildTail = previous.catch(() => undefined).then(() => turn);
|
|
591
|
+
await previous.catch(() => undefined);
|
|
592
|
+
try {
|
|
593
|
+
if (signal?.aborted)
|
|
594
|
+
throw new Error(String(signal.reason ?? "sandbox build cancelled"));
|
|
595
|
+
return await action();
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
release();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async buildImageCore(contextRoot, contextDigest, contractDigest, key, project, packageRoot, bridgeRuntimeDigest, toolingImage, upstreamImages, codexVersion, policy, sink, signal) {
|
|
602
|
+
const projectTag = `forge/project:${key}`;
|
|
603
|
+
const helperTag = `forge/tooling:${key}`;
|
|
604
|
+
const overlayTag = `forge/agent:${key}`;
|
|
605
|
+
const [cachedHelper, cachedOverlay] = await Promise.all([
|
|
606
|
+
this.inspectImageMetadata(helperTag),
|
|
607
|
+
this.inspectImageMetadata(overlayTag)
|
|
608
|
+
]);
|
|
609
|
+
const cachedHelperDigest = cachedHelper?.id ?? "";
|
|
610
|
+
const cachedOverlayDigest = cachedOverlay?.id ?? "";
|
|
611
|
+
const cacheRecordPath = path.join(this.config.sandboxStateRoot, "image-cache", `${key}.json`);
|
|
612
|
+
const cacheRecord = await this.readImageCacheRecord(cacheRecordPath);
|
|
613
|
+
const policyRecordValid = cacheRecord?.policyDigest
|
|
614
|
+
? await hasSandboxImagePolicyRecord(this.config.sandboxStateRoot, key, cacheRecord.policyDigest, policy === "sandbox_required")
|
|
615
|
+
: false;
|
|
616
|
+
if (/^sha256:[0-9a-f]{64}$/i.test(cachedHelperDigest)
|
|
617
|
+
&& /^sha256:[0-9a-f]{64}$/i.test(cachedOverlayDigest)
|
|
618
|
+
&& cacheRecord?.helperImageDigest === cachedHelperDigest
|
|
619
|
+
&& cacheRecord.imageDigest === cachedOverlayDigest
|
|
620
|
+
&& bridgeRuntimeCacheMatches(cacheRecord, bridgeRuntimeDigest, [cachedHelper?.labels, cachedOverlay?.labels])
|
|
621
|
+
&& policyRecordValid) {
|
|
622
|
+
await this.writeImageCacheRecord({
|
|
623
|
+
...cacheRecord,
|
|
624
|
+
lastUsedAt: new Date().toISOString(),
|
|
625
|
+
});
|
|
626
|
+
await sink.emit(event("sandbox_image_cache_hit", `Using cached sandbox image ${cachedOverlayDigest}.`, { imageDigest: cachedOverlayDigest }));
|
|
627
|
+
return { imageDigest: cachedOverlayDigest, helperImageDigest: cachedHelperDigest };
|
|
628
|
+
}
|
|
629
|
+
if (cachedHelperDigest || cachedOverlayDigest) {
|
|
630
|
+
await Promise.all([overlayTag, helperTag, projectTag].map(tag => this.docker(["image", "rm", "--force", tag], 60_000).catch(() => undefined)));
|
|
631
|
+
}
|
|
632
|
+
await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} requires a build.`, { cacheKey: key }));
|
|
633
|
+
if (policy === "sandbox_required" && process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED !== "true") {
|
|
634
|
+
throw new Error("sandbox_build_policy_failed: sandbox_required builds need an operator-enforced builder egress policy.");
|
|
635
|
+
}
|
|
636
|
+
const buildStartedAt = Date.now();
|
|
637
|
+
await sink.emit(event("sandbox_build_started", `Building sandbox image ${key.slice(0, 12)}.`, { cacheKey: key }));
|
|
638
|
+
const builder = await this.ensureBuildxBuilder(signal);
|
|
639
|
+
const metadataRoot = path.join(this.config.sandboxStateRoot, "attestations", key, "buildkit");
|
|
640
|
+
const overlayRoot = path.join(this.config.sandboxStateRoot, "build", `overlay-${key}`);
|
|
641
|
+
await prepareOverlayBuildRoot(overlayRoot);
|
|
642
|
+
await fs.mkdir(metadataRoot, { recursive: true, mode: 0o700 });
|
|
643
|
+
const projectOciArchive = path.join(overlayRoot, "project.oci.tar");
|
|
644
|
+
const projectOciRoot = path.join(overlayRoot, "project-oci");
|
|
645
|
+
await this.runBuildStage("project", key, buildxBuildArgs(builder, path.join(metadataRoot, "project.json"), [
|
|
646
|
+
"--pull=false",
|
|
647
|
+
...imageLabelArgs(project, key, "project", bridgeRuntimeDigest),
|
|
648
|
+
"--tag", projectTag,
|
|
649
|
+
"--output", `type=oci,dest=${projectOciArchive}`,
|
|
650
|
+
"--file", path.join(contextRoot, ".forge", "Dockerfile"),
|
|
651
|
+
contextRoot
|
|
652
|
+
], process.env, { load: false }), sink, signal);
|
|
653
|
+
await extractOciLayoutArchive(projectOciArchive, projectOciRoot, signal);
|
|
654
|
+
const projectImage = await readOciLayoutImageDigest(projectOciRoot);
|
|
655
|
+
const projectContextName = "forge_project";
|
|
656
|
+
const stagedBridgeRoot = path.join(overlayRoot, "bridge");
|
|
657
|
+
await fs.cp(path.join(packageRoot, "dist"), path.join(stagedBridgeRoot, "dist"), { recursive: true });
|
|
658
|
+
const installMode = await copyBridgePackageManifests(packageRoot, stagedBridgeRoot);
|
|
659
|
+
const stagedBridgeRuntimeDigest = await bridgeRuntimeContentDigest(stagedBridgeRoot);
|
|
660
|
+
if (stagedBridgeRuntimeDigest !== bridgeRuntimeDigest) {
|
|
661
|
+
throw new Error("sandbox_build_failed: bridge runtime payload changed while preparing the sandbox image; retry the dispatch.");
|
|
662
|
+
}
|
|
663
|
+
const dockerfile = agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion);
|
|
664
|
+
await fs.writeFile(path.join(overlayRoot, "Dockerfile"), dockerfile);
|
|
665
|
+
await fs.writeFile(path.join(overlayRoot, ".dockerignore"), agentOverlayDockerignore());
|
|
666
|
+
await this.runBuildStage("tooling", key, buildxBuildArgs(builder, path.join(metadataRoot, "tooling.json"), ["--pull=false", "--target", "tooling", ...imageLabelArgs(project, key, "tooling", bridgeRuntimeDigest), "--tag", helperTag, overlayRoot]), sink, signal);
|
|
667
|
+
const helperImageDigest = (await this.docker(["image", "inspect", helperTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
|
|
668
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(helperImageDigest))
|
|
669
|
+
throw new Error("sandbox_build_failed: helper image did not resolve to an immutable digest.");
|
|
670
|
+
const projectContextRef = ociLayoutContextRef(path.relative(overlayRoot, projectOciRoot));
|
|
671
|
+
await this.runBuildStage("agent", key, buildxBuildArgs(builder, path.join(metadataRoot, "agent.json"), [
|
|
672
|
+
"--pull=false",
|
|
673
|
+
"--build-context", `${projectContextName}=${projectContextRef}`,
|
|
674
|
+
...imageLabelArgs(project, key, "agent", bridgeRuntimeDigest),
|
|
675
|
+
"--tag", overlayTag,
|
|
676
|
+
"."
|
|
677
|
+
]), sink, signal, { cwd: overlayRoot });
|
|
678
|
+
const digest = (await this.docker(["image", "inspect", overlayTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
|
|
679
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(digest))
|
|
680
|
+
throw new Error("sandbox_build_failed: overlay image did not resolve to an immutable digest.");
|
|
681
|
+
const imagePolicy = await evaluateSandboxImagePolicy({
|
|
682
|
+
key,
|
|
683
|
+
project: dockerLabelValue(project),
|
|
684
|
+
executionPolicy: policy,
|
|
685
|
+
stateRoot: this.config.sandboxStateRoot,
|
|
686
|
+
builderVersion: this.config.version,
|
|
687
|
+
contextDigest,
|
|
688
|
+
contractDigest,
|
|
689
|
+
toolingImage,
|
|
690
|
+
upstreamImages,
|
|
691
|
+
codexVersion,
|
|
692
|
+
images: { project: projectImage, tooling: helperImageDigest, agent: digest },
|
|
693
|
+
imageScanTargets: { project: ociLayoutScanTarget(projectOciRoot) },
|
|
694
|
+
signal
|
|
695
|
+
});
|
|
696
|
+
await sink.emit(event(imagePolicy.status === "passed" ? "sandbox_image_policy_passed" : "sandbox_image_policy_warning", imagePolicy.status === "passed" ? `Image policy passed for ${digest}.` : `Image attestations are incomplete for adoption-mode image ${digest}.`, { policyDigest: imagePolicy.policyDigest }));
|
|
697
|
+
await removeExtractedOciLayout(projectOciRoot);
|
|
698
|
+
await this.writeImageCacheRecord({
|
|
699
|
+
schemaVersion: 1,
|
|
700
|
+
key,
|
|
701
|
+
project: dockerLabelValue(project),
|
|
702
|
+
createdAt: new Date().toISOString(),
|
|
703
|
+
lastUsedAt: new Date().toISOString(),
|
|
704
|
+
projectTag,
|
|
705
|
+
helperTag,
|
|
706
|
+
overlayTag,
|
|
707
|
+
imageDigest: digest,
|
|
708
|
+
helperImageDigest,
|
|
709
|
+
bridgeRuntimeDigest,
|
|
710
|
+
policyDigest: imagePolicy.policyDigest
|
|
711
|
+
});
|
|
712
|
+
await sink.emit(event("sandbox_build_completed", `Sandbox image ready at ${digest}.`, {
|
|
713
|
+
durationMs: Date.now() - buildStartedAt,
|
|
714
|
+
imageDigest: digest
|
|
715
|
+
}));
|
|
716
|
+
return { imageDigest: digest, helperImageDigest };
|
|
717
|
+
}
|
|
718
|
+
async runBuildStage(stage, key, args, sink, signal, options = {}) {
|
|
719
|
+
const result = await runSandboxProcess({
|
|
720
|
+
command: this.config.dockerExecutable,
|
|
721
|
+
args: this.dockerArgs(args),
|
|
722
|
+
cwd: options.cwd,
|
|
723
|
+
timeoutMs: 30 * 60_000,
|
|
724
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
725
|
+
terminateOnOutputLimit: true,
|
|
726
|
+
signal
|
|
727
|
+
});
|
|
728
|
+
if (result.exitCode === 0 && !result.truncated)
|
|
729
|
+
return;
|
|
730
|
+
const exactSecrets = [this.config.deviceToken, ...Object.entries(process.env)
|
|
731
|
+
.filter(([name, value]) => /(?:api[_-]?key|authorization|bearer|client[_-]?secret|password|secret|token)/iu.test(name)
|
|
732
|
+
&& typeof value === "string"
|
|
733
|
+
&& value.length >= 8)
|
|
734
|
+
.map(([, value]) => value)].filter(Boolean);
|
|
735
|
+
const artifact = restrictedBuildLog(`stage=${stage}\nexitCode=${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, exactSecrets);
|
|
736
|
+
const root = path.join(this.config.sandboxStateRoot, "build-logs", key);
|
|
737
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
738
|
+
const target = path.join(root, `${stage}.log`);
|
|
739
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
740
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
741
|
+
try {
|
|
742
|
+
await handle.writeFile(artifact.content);
|
|
743
|
+
await handle.sync();
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
await handle.close();
|
|
747
|
+
}
|
|
748
|
+
await fs.rename(temporary, target);
|
|
749
|
+
await sink.emit(event("sandbox_build_log_preserved", `Restricted build log retained for failed ${stage} image stage.`, {
|
|
750
|
+
stage,
|
|
751
|
+
digest: artifact.digest,
|
|
752
|
+
byteCount: artifact.byteCount,
|
|
753
|
+
truncated: artifact.truncated || result.truncated
|
|
754
|
+
})).catch(() => undefined);
|
|
755
|
+
throw new Error(`sandbox_build_failed: ${stage} image build failed; restricted log digest ${artifact.digest}.`);
|
|
756
|
+
}
|
|
757
|
+
async ensureBuildxBuilder(signal) {
|
|
758
|
+
const name = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
|
|
759
|
+
const existing = await this.docker(["buildx", "inspect", name], 30_000, signal).catch(() => null);
|
|
760
|
+
if (!existing) {
|
|
761
|
+
await this.docker(["buildx", "create", "--name", name, "--driver", "docker-container"], 60_000, signal);
|
|
762
|
+
}
|
|
763
|
+
await this.docker(["buildx", "inspect", "--bootstrap", name], 2 * 60_000, signal, 1024 * 1024);
|
|
764
|
+
const policy = buildResourcePolicy();
|
|
765
|
+
const container = dedicatedBuildxBuilderContainerName(name);
|
|
766
|
+
await this.docker([
|
|
767
|
+
"container", "update",
|
|
768
|
+
"--pids-limit", String(policy.builderPidsLimit),
|
|
769
|
+
"--memory", String(policy.builderMemoryBytes),
|
|
770
|
+
"--memory-swap", String(policy.builderMemoryBytes),
|
|
771
|
+
container
|
|
772
|
+
], 30_000, signal);
|
|
773
|
+
const applied = await this.docker(["container", "inspect", container, "--format", "{{json .HostConfig}}"], 30_000, signal);
|
|
774
|
+
const hostConfig = JSON.parse(applied.stdout);
|
|
775
|
+
if (Number(hostConfig.PidsLimit) !== policy.builderPidsLimit
|
|
776
|
+
|| Number(hostConfig.Memory) !== policy.builderMemoryBytes
|
|
777
|
+
|| Number(hostConfig.MemorySwap) !== policy.builderMemoryBytes) {
|
|
778
|
+
throw new Error("sandbox_build_policy_failed: dedicated BuildKit builder resource limits were not applied.");
|
|
779
|
+
}
|
|
780
|
+
return name;
|
|
781
|
+
}
|
|
782
|
+
async stopBuildxBuilder() {
|
|
783
|
+
const name = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
|
|
784
|
+
const container = dedicatedBuildxBuilderContainerName(name);
|
|
785
|
+
await this.docker(["container", "stop", "--time", "10", container], 30_000);
|
|
786
|
+
}
|
|
787
|
+
async initializeVolumes(helperImage, resources, labels, includeCredentials, signal) {
|
|
788
|
+
const cacheMounts = Object.entries(resources.cacheVolumes).flatMap(([name, volume]) => ["--volume", `${volume}:/cache/${name}`]);
|
|
789
|
+
const credentialSetup = includeCredentials ? "f.chmodSync('/credentials',0o700);" : "";
|
|
790
|
+
const script = `const f=require('fs'),owned=['/control','/control/input','/control/output','/workspace'];f.mkdirSync('/control/input',{recursive:true});f.mkdirSync('/control/output',{recursive:true});for(const p of owned)f.chmodSync(p,0o700);for(const p of owned.toReversed())f.chownSync(p,10001,10001);${credentialSetup}for(const p of f.readdirSync('/cache').map(n=>'/cache/'+n)){f.chmodSync(p,0o700);f.chownSync(p,10001,10001)}`;
|
|
791
|
+
const credentialMount = includeCredentials ? ["--volume", `${resources.credentialsVolume}:/credentials`] : [];
|
|
792
|
+
await this.docker(["run", "--rm", ...labelArgs(labels), "--network", "none", "--read-only", "--cap-drop", "ALL", "--cap-add", "CHOWN", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--memory", "128m", "--memory-swap", "128m", "--cpus", "0.25", "--log-driver", "none", "--user", "0", "--entrypoint", "node", "--volume", `${resources.workspaceVolume}:/workspace`, "--volume", `${resources.controlVolume}:/control`, ...credentialMount, ...cacheMounts, "--tmpfs", "/cache:rw,nosuid,nodev,noexec,size=1m", helperImage, "-e", script], 60_000, signal);
|
|
793
|
+
}
|
|
794
|
+
async inspectContainerState(containerId) {
|
|
795
|
+
const result = await this.docker(["container", "inspect", containerId, "--format", "{{json .State}}"], 15_000, undefined, 256 * 1024);
|
|
796
|
+
const value = JSON.parse(result.stdout);
|
|
797
|
+
return {
|
|
798
|
+
oomKilled: value.OOMKilled === true,
|
|
799
|
+
exitCode: typeof value.ExitCode === "number" ? value.ExitCode : null,
|
|
800
|
+
error: typeof value.Error === "string" && value.Error.trim() ? value.Error.trim().slice(0, 1000) : null
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async initializeServiceSecrets(helperImage, contract, resources, labels, signal) {
|
|
804
|
+
const script = "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>require('fs').writeFileSync('/secrets/postgres-password',s,{mode:0o444}))";
|
|
805
|
+
for (const service of contract.contract.services) {
|
|
806
|
+
const secretName = service.type === "postgres" ? "postgres-password" : service.type === "redis" ? "redis-password" : "browser-token";
|
|
807
|
+
const writeScript = script.replace("postgres-password", secretName);
|
|
808
|
+
await requireSandboxProcess({
|
|
809
|
+
command: this.config.dockerExecutable,
|
|
810
|
+
args: this.dockerArgs(["run", "--rm", "--interactive", ...labelArgs(labels), "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--memory", "128m", "--memory-swap", "128m", "--cpus", "0.25", "--log-driver", "none", "--user", "0", "--entrypoint", "node", "--volume", `${resources.secretsVolume}:/secrets`, helperImage, "-e", writeScript]),
|
|
811
|
+
input: randomBytes(32).toString("base64url"),
|
|
812
|
+
timeoutMs: 60_000,
|
|
813
|
+
maxOutputBytes: 64 * 1024,
|
|
814
|
+
signal
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async initializeProviderCredentials(helperImage, dispatch, resources, labels, signal) {
|
|
819
|
+
const providerAuth = await sandboxProviderAuthForDispatch(dispatch);
|
|
820
|
+
if (!providerAuth) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const script = "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const f=require('fs'),p=require('path'),root='/credentials';for(const e of f.readdirSync(root))f.rmSync(p.join(root,e),{recursive:true,force:true});const packet=JSON.parse(s);for(const file of packet.files){if(!/^[a-z0-9_.-]+$/i.test(file.name))throw new Error('invalid credential file');const target=p.resolve(root,file.name);if(!target.startsWith(root+'/'))throw new Error('invalid credential file');const body=Buffer.from(file.contentBase64,'base64');f.writeFileSync(target,body,{mode:0o600});f.chmodSync(target,0o600);f.chownSync(target,10001,10001)}f.chmodSync(root,0o700);f.chownSync(root,10001,10001)})";
|
|
824
|
+
await requireSandboxProcess({
|
|
825
|
+
command: this.config.dockerExecutable,
|
|
826
|
+
args: this.dockerArgs(["run", "--rm", "--interactive", ...labelArgs(labels), "--network", "none", "--read-only", "--cap-drop", "ALL", "--cap-add", "CHOWN", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--memory", "128m", "--memory-swap", "128m", "--cpus", "0.25", "--log-driver", "none", "--user", "0", "--entrypoint", "node", "--volume", `${resources.credentialsVolume}:/credentials`, helperImage, "-e", script]),
|
|
827
|
+
input: JSON.stringify({ files: providerAuth.files }),
|
|
828
|
+
timeoutMs: 60_000,
|
|
829
|
+
maxOutputBytes: 64 * 1024,
|
|
830
|
+
signal
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
async initializeEgressPolicy(helperImage, dispatch, resources, labels, signal) {
|
|
834
|
+
if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
|
|
835
|
+
return;
|
|
836
|
+
const script = "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>require('fs').writeFileSync('/policy/forge.conf',s,{mode:0o444}))";
|
|
837
|
+
await requireSandboxProcess({
|
|
838
|
+
command: this.config.dockerExecutable,
|
|
839
|
+
args: this.dockerArgs(["run", "--rm", "--interactive", ...labelArgs(labels), "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--memory", "128m", "--memory-swap", "128m", "--cpus", "0.25", "--log-driver", "none", "--user", "0", "--entrypoint", "node", "--volume", `${resources.egressConfigVolume}:/policy`, helperImage, "-e", script]),
|
|
840
|
+
input: egressProxyConfig(dispatch.sandbox.networkPolicy.allowedHosts),
|
|
841
|
+
timeoutMs: 60_000,
|
|
842
|
+
maxOutputBytes: 64 * 1024,
|
|
843
|
+
signal
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
async startEgressProxy(dispatch, resources, labels, securityOptions, sink, signal) {
|
|
847
|
+
if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
|
|
848
|
+
return null;
|
|
849
|
+
const image = process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim();
|
|
850
|
+
if (!image || !/@sha256:[0-9a-f]{64}$/iu.test(image)) {
|
|
851
|
+
throw new Error("sandbox_egress_proxy_unavailable: public_allowlist needs a digest-pinned Squid-compatible proxy image.");
|
|
852
|
+
}
|
|
853
|
+
if (process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
|
|
854
|
+
throw new Error("sandbox_egress_proxy_unavailable: runner egress enforcement is not enabled.");
|
|
855
|
+
}
|
|
856
|
+
await this.verifyCatalogImage(image, dispatch.sandbox.executionPolicy, sink, signal);
|
|
857
|
+
const name = `${resources.prefix}-egress`;
|
|
858
|
+
const containerId = dockerObjectId((await this.docker([
|
|
859
|
+
"run", "--detach", "--name", name, ...labelArgs(labels), "--network", resources.egressNetwork,
|
|
860
|
+
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", ...securityOptions,
|
|
861
|
+
"--pids-limit", "128", "--memory", "256m", "--memory-swap", "256m", "--cpus", "0.5",
|
|
862
|
+
"--tmpfs", "/var/spool/squid:rw,nosuid,nodev,noexec,size=128m", "--tmpfs", "/run:rw,nosuid,nodev,noexec,size=16m",
|
|
863
|
+
"--log-driver", "none", "--volume", `${resources.egressConfigVolume}:/etc/squid/forge:ro`,
|
|
864
|
+
"--entrypoint", "squid", image, "-N", "-f", "/etc/squid/forge/forge.conf"
|
|
865
|
+
], 60_000, signal)).stdout, "container");
|
|
866
|
+
await this.docker(["network", "connect", "--alias", "forge-egress", resources.network, name], 30_000, signal);
|
|
867
|
+
return { id: containerId, name, role: "egress_proxy" };
|
|
868
|
+
}
|
|
869
|
+
async startServices(contract, executionPolicy, resources, labels, securityOptions, sink, signal) {
|
|
870
|
+
const started = [];
|
|
871
|
+
for (const service of contract.contract.services) {
|
|
872
|
+
const image = serviceImage(service.type);
|
|
873
|
+
if (!image)
|
|
874
|
+
throw new Error(`service_unavailable: no digest-pinned catalogue image configured for ${service.type}.`);
|
|
875
|
+
await this.verifyCatalogImage(image, executionPolicy, sink, signal);
|
|
876
|
+
const name = `${resources.prefix}-${service.type}`;
|
|
877
|
+
const args = serviceContainerArgs(service, image, resources, labels, securityOptions);
|
|
878
|
+
const containerId = dockerObjectId((await this.docker(args, 60_000, signal)).stdout, "container");
|
|
879
|
+
await this.waitForServiceHealthy(name, signal);
|
|
880
|
+
started.push({ id: containerId, name, role: `service:${service.type}` });
|
|
881
|
+
}
|
|
882
|
+
return started;
|
|
883
|
+
}
|
|
884
|
+
async verifyCatalogImage(image, executionPolicy, sink, signal) {
|
|
885
|
+
if (this.verifiedCatalogImages.has(image))
|
|
886
|
+
return;
|
|
887
|
+
const status = await verifySandboxImageSignature({ image, executionPolicy, signal });
|
|
888
|
+
if (status === "passed") {
|
|
889
|
+
this.verifiedCatalogImages.add(image);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
await sink.emit(event("sandbox_image_signature_warning", `Image signature enforcement is not configured for adoption-mode image ${image}.`));
|
|
893
|
+
}
|
|
894
|
+
async waitForServiceHealthy(name, signal) {
|
|
895
|
+
const deadline = Date.now() + 60_000;
|
|
896
|
+
while (Date.now() < deadline) {
|
|
897
|
+
if (signal?.aborted)
|
|
898
|
+
throw new Error(String(signal.reason ?? "sandbox service startup cancelled"));
|
|
899
|
+
const status = (await this.docker(["container", "inspect", name, "--format", "{{.State.Health.Status}}"], 10_000, signal)).stdout.trim();
|
|
900
|
+
if (status === "healthy")
|
|
901
|
+
return;
|
|
902
|
+
if (status === "unhealthy")
|
|
903
|
+
throw new Error(`sandbox_service_health_timeout: ${name} reported unhealthy.`);
|
|
904
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
905
|
+
}
|
|
906
|
+
throw new Error(`sandbox_service_health_timeout: ${name} did not become healthy before the startup deadline.`);
|
|
907
|
+
}
|
|
908
|
+
async teardown(resources, expectedLabels, source, includeCredentials = true) {
|
|
909
|
+
const attemptId = expectedLabels["dev.forge.dispatch-attempt-id"];
|
|
910
|
+
const reportId = randomUUID();
|
|
911
|
+
const actions = [];
|
|
912
|
+
const record = async (kind, id) => {
|
|
913
|
+
try {
|
|
914
|
+
await this.removeResource(kind, id);
|
|
915
|
+
actions.push(cleanupAction(expectedLabels, kind, id, "removed"));
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
actions.push(cleanupAction(expectedLabels, kind, id, "failed", cleanupErrorMessage(error)));
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
for (const id of await this.listResources("container", `dev.forge.dispatch-attempt-id=${attemptId}`, false)) {
|
|
922
|
+
try {
|
|
923
|
+
const labels = await this.inspectResourceLabels("container", id);
|
|
924
|
+
if (!labelsMatch(labels, expectedLabels))
|
|
925
|
+
continue;
|
|
926
|
+
await record("container", id);
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
actions.push(cleanupAction(expectedLabels, "container", id, "failed", cleanupErrorMessage(error)));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const [kind, names] of [["network", [resources.network, resources.egressNetwork]], ["volume", sandboxRuntimeCleanupVolumeNames(resources, includeCredentials)]]) {
|
|
933
|
+
for (const name of names) {
|
|
934
|
+
if (!name)
|
|
935
|
+
continue;
|
|
936
|
+
const labels = await this.inspectResourceLabels(kind, name).catch(() => null);
|
|
937
|
+
if (labels && labelsMatch(labels, expectedLabels))
|
|
938
|
+
await record(kind, name);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const remaining = (await Promise.all(["container", "network", "volume"].map(async (kind) => ({
|
|
942
|
+
kind,
|
|
943
|
+
ids: await this.listResources(kind, `dev.forge.dispatch-attempt-id=${attemptId}`, false)
|
|
944
|
+
})))).flatMap(entry => entry.ids.map(id => ({ kind: entry.kind, id })));
|
|
945
|
+
const reportedFailures = new Set(actions.filter(action => action.outcome === "failed").map(action => `${action.resourceType}\0${action.resourceId}`));
|
|
946
|
+
for (const resource of remaining) {
|
|
947
|
+
if (!reportedFailures.has(`${resource.kind}\0${resource.id}`)) {
|
|
948
|
+
actions.push(cleanupAction(expectedLabels, resource.kind, resource.id, "failed", "Exact-labeled resource remained after teardown."));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const report = { reportId, source, actions };
|
|
952
|
+
if (remaining.length > 0 || actions.some(action => action.outcome === "failed")) {
|
|
953
|
+
throw new SandboxTeardownError(report, `teardown_failed: ${remaining.length} exact-labeled Docker resources remain for attempt ${attemptId}.`);
|
|
954
|
+
}
|
|
955
|
+
return report;
|
|
956
|
+
}
|
|
957
|
+
async listResources(kind, label, suppressErrors = true) {
|
|
958
|
+
const args = kind === "container"
|
|
959
|
+
? ["ps", "-aq", "--filter", `label=${label}`]
|
|
960
|
+
: [kind, "ls", "-q", "--filter", `label=${label}`];
|
|
961
|
+
const result = suppressErrors ? await this.docker(args, 30_000).catch(() => null) : await this.docker(args, 30_000);
|
|
962
|
+
return result?.stdout.split(/\s+/).filter(Boolean) ?? [];
|
|
963
|
+
}
|
|
964
|
+
async inspectResourceLabels(kind, id) {
|
|
965
|
+
const args = kind === "container" ? ["container", "inspect", id] : [kind, "inspect", id];
|
|
966
|
+
const result = await this.docker(args, 15_000, undefined, 256 * 1024);
|
|
967
|
+
const document = JSON.parse(result.stdout);
|
|
968
|
+
return document[0]?.Config?.Labels ?? document[0]?.Labels ?? {};
|
|
969
|
+
}
|
|
970
|
+
async removeResource(kind, id) {
|
|
971
|
+
if (kind === "container") {
|
|
972
|
+
await this.docker(["container", "stop", "--time", "10", id], 20_000).catch(() => undefined);
|
|
973
|
+
}
|
|
974
|
+
const args = kind === "container" ? ["container", "rm", "--force", id] : kind === "volume" ? ["volume", "rm", "--force", id] : ["network", "rm", id];
|
|
975
|
+
await this.docker(args, 30_000);
|
|
976
|
+
}
|
|
977
|
+
async writeImageCacheRecord(record) {
|
|
978
|
+
const root = path.join(this.config.sandboxStateRoot, "image-cache");
|
|
979
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
980
|
+
const target = path.join(root, `${record.key}.json`);
|
|
981
|
+
const previous = await this.readImageCacheRecord(target);
|
|
982
|
+
const value = previous ? { ...record, createdAt: previous.createdAt } : record;
|
|
983
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
984
|
+
await fs.writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: "wx" });
|
|
985
|
+
await fs.rename(temporary, target);
|
|
986
|
+
}
|
|
987
|
+
async readImageCacheRecords() {
|
|
988
|
+
const root = path.join(this.config.sandboxStateRoot, "image-cache");
|
|
989
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
990
|
+
const records = [];
|
|
991
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
992
|
+
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/i.test(entry.name))
|
|
993
|
+
continue;
|
|
994
|
+
const record = await this.readImageCacheRecord(path.join(root, entry.name));
|
|
995
|
+
if (record)
|
|
996
|
+
records.push(record);
|
|
997
|
+
}
|
|
998
|
+
return records;
|
|
999
|
+
}
|
|
1000
|
+
async readImageCacheRecord(target) {
|
|
1001
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
1002
|
+
if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
|
|
1003
|
+
return null;
|
|
1004
|
+
const value = await fs.readFile(target, "utf8").then(text => JSON.parse(text)).catch(() => null);
|
|
1005
|
+
return value && validImageCacheRecord(value) ? value : null;
|
|
1006
|
+
}
|
|
1007
|
+
async collectExpiredImages() {
|
|
1008
|
+
const records = await this.readImageCacheRecords();
|
|
1009
|
+
const candidates = selectImageGcCandidates(records, Math.max(1, Math.floor(this.config.imageRetentionCount)), Math.max(60_000, this.config.imageRetentionTtlMs), Date.now());
|
|
1010
|
+
const protectedDigests = new Set([...this.active.values()]
|
|
1011
|
+
.map(value => value.ledger?.labels["dev.forge.image-digest"])
|
|
1012
|
+
.filter((value) => Boolean(value)));
|
|
1013
|
+
for (const record of candidates) {
|
|
1014
|
+
if (protectedDigests.has(record.imageDigest) || protectedDigests.has(record.helperImageDigest))
|
|
1015
|
+
continue;
|
|
1016
|
+
for (const tag of [record.overlayTag, record.helperTag, record.projectTag]) {
|
|
1017
|
+
await this.docker(["image", "rm", tag], 60_000).catch(() => undefined);
|
|
1018
|
+
}
|
|
1019
|
+
const remains = await Promise.all([record.overlayTag, record.helperTag, record.projectTag]
|
|
1020
|
+
.map(tag => this.docker(["image", "inspect", tag, "--format", "{{.Id}}"], 15_000).then(() => true).catch(() => false)));
|
|
1021
|
+
if (!remains.some(Boolean)) {
|
|
1022
|
+
await fs.rm(path.join(this.config.sandboxStateRoot, "image-cache", `${record.key}.json`), { force: true });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const builder = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
|
|
1026
|
+
const builderExists = await this.docker(["buildx", "inspect", builder], 30_000).then(() => true).catch(() => false);
|
|
1027
|
+
if (builderExists) {
|
|
1028
|
+
await this.docker([
|
|
1029
|
+
"buildx", "prune", "--builder", builder, "--force",
|
|
1030
|
+
"--max-used-space", boundedBuildCacheSize(process.env.FORGE_SANDBOX_BUILDKIT_CACHE_MAX, "20gb"),
|
|
1031
|
+
"--min-free-space", String(Math.max(1, Math.floor(this.config.minimumWorkspaceBytes))),
|
|
1032
|
+
"--reserved-space", boundedBuildCacheSize(process.env.FORGE_SANDBOX_BUILDKIT_CACHE_RESERVED, "2gb")
|
|
1033
|
+
], 5 * 60_000, undefined, 4 * 1024 * 1024).catch(() => undefined);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
async purgeRestrictedAuditArtifacts() {
|
|
1037
|
+
const activeDigests = new Set([...this.active.values()]
|
|
1038
|
+
.map(value => value.ledger?.labels["dev.forge.image-digest"])
|
|
1039
|
+
.filter((value) => Boolean(value)));
|
|
1040
|
+
const protectedKeys = new Set((await this.readImageCacheRecords())
|
|
1041
|
+
.filter(record => activeDigests.has(record.imageDigest) || activeDigests.has(record.helperImageDigest))
|
|
1042
|
+
.map(record => record.key));
|
|
1043
|
+
for (const name of ["build-logs", "attestations"]) {
|
|
1044
|
+
const root = path.join(this.config.sandboxStateRoot, name);
|
|
1045
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
1046
|
+
const records = [];
|
|
1047
|
+
for (const entry of entries) {
|
|
1048
|
+
if (!entry.isDirectory() || !/^[0-9a-f]{64}$/iu.test(entry.name))
|
|
1049
|
+
continue;
|
|
1050
|
+
const target = path.join(root, entry.name);
|
|
1051
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
1052
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
1053
|
+
continue;
|
|
1054
|
+
records.push({ key: entry.name.toLowerCase(), lastModifiedMs: stat.mtimeMs });
|
|
1055
|
+
}
|
|
1056
|
+
const candidates = selectAuditArtifactGcCandidates(records, Math.max(1, Math.floor(this.config.auditRetentionCount)), Math.max(60 * 60_000, this.config.auditRetentionTtlMs), protectedKeys);
|
|
1057
|
+
for (const key of candidates) {
|
|
1058
|
+
await fs.rm(path.join(root, key), { recursive: true, force: true }).catch(() => undefined);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async inspectImageMetadata(tag) {
|
|
1063
|
+
const result = await this.docker(["image", "inspect", tag], 15_000, undefined, 1024 * 1024).catch(() => null);
|
|
1064
|
+
if (!result)
|
|
1065
|
+
return null;
|
|
1066
|
+
const document = JSON.parse(result.stdout);
|
|
1067
|
+
const image = document[0];
|
|
1068
|
+
if (!image || typeof image.Id !== "string" || !/^sha256:[0-9a-f]{64}$/i.test(image.Id))
|
|
1069
|
+
return null;
|
|
1070
|
+
const labels = image.Config?.Labels;
|
|
1071
|
+
return {
|
|
1072
|
+
id: image.Id,
|
|
1073
|
+
labels: labels && typeof labels === "object" && !Array.isArray(labels)
|
|
1074
|
+
? Object.fromEntries(Object.entries(labels).filter((entry) => typeof entry[1] === "string"))
|
|
1075
|
+
: {}
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
startPeakMemorySampler(containers) {
|
|
1079
|
+
const intervalMs = this.config.sandboxMemorySampleIntervalMs;
|
|
1080
|
+
const controller = new AbortController();
|
|
1081
|
+
const accumulator = createSandboxMemoryAccumulator(intervalMs);
|
|
1082
|
+
const done = (async () => {
|
|
1083
|
+
while (!controller.signal.aborted) {
|
|
1084
|
+
try {
|
|
1085
|
+
const result = await this.docker(["stats", "--no-stream", "--format", "{{json .}}", ...containers.map(container => container.id)], Math.max(5_000, intervalMs * 3), controller.signal, 128 * 1024);
|
|
1086
|
+
const sample = parseDockerStatsMemorySample(result.stdout);
|
|
1087
|
+
if (sample.length === 0)
|
|
1088
|
+
accumulator.failedSampleCount += 1;
|
|
1089
|
+
else
|
|
1090
|
+
recordSandboxMemorySample(accumulator, containers, sample, new Date().toISOString());
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
if (!controller.signal.aborted)
|
|
1094
|
+
accumulator.failedSampleCount += 1;
|
|
1095
|
+
}
|
|
1096
|
+
await waitForMemorySampleInterval(intervalMs, controller.signal);
|
|
1097
|
+
}
|
|
1098
|
+
})();
|
|
1099
|
+
return {
|
|
1100
|
+
stop: async () => {
|
|
1101
|
+
controller.abort("sandbox memory sampling complete");
|
|
1102
|
+
await done.catch(() => undefined);
|
|
1103
|
+
return finalizeSandboxMemoryTelemetry(accumulator);
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
docker(args, timeoutMs, signal, maxOutputBytes = 1024 * 1024) {
|
|
1108
|
+
return requireSandboxProcess({ command: this.config.dockerExecutable, args: this.dockerArgs(args), timeoutMs, maxOutputBytes, signal });
|
|
1109
|
+
}
|
|
1110
|
+
dockerArgs(args) { return this.config.dockerContext ? ["--context", this.config.dockerContext, ...args] : [...args]; }
|
|
1111
|
+
}
|
|
1112
|
+
class HostGit {
|
|
1113
|
+
config;
|
|
1114
|
+
repoPath;
|
|
1115
|
+
coordinator;
|
|
1116
|
+
constructor(config, repoPath) {
|
|
1117
|
+
this.config = config;
|
|
1118
|
+
this.repoPath = repoPath;
|
|
1119
|
+
this.coordinator = new RepositoryCoordinatorLock(config.sandboxStateRoot);
|
|
1120
|
+
}
|
|
1121
|
+
async prepare(dispatch, attemptRoot, signal) {
|
|
1122
|
+
return await this.coordinator.withLock(this.repoPath, () => this.prepareLocked(dispatch, attemptRoot), signal);
|
|
1123
|
+
}
|
|
1124
|
+
async prepareLocked(dispatch, attemptRoot) {
|
|
1125
|
+
const baseBranch = dispatch.cycleRun.baseBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch;
|
|
1126
|
+
await this.git(["fetch", "--prune", "origin"], 5 * 60_000).catch(() => null);
|
|
1127
|
+
const inputCommitSha = await this.resolveFirst([...selectSandboxInputRefs(dispatch.lease.worktreeKey, baseBranch)]);
|
|
1128
|
+
const configSelection = selectSandboxConfigRefs(dispatch.sandbox.executionPolicy, dispatch.sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
|
|
1129
|
+
const configCommitSha = await this.resolveFirst([...configSelection.refs]);
|
|
1130
|
+
const approvedConfigSha = configSelection.approvedSha;
|
|
1131
|
+
if (approvedConfigSha && configCommitSha !== approvedConfigSha) {
|
|
1132
|
+
throw new Error("sandbox_config_commit_mismatch: approved sandbox config commit resolved to a different object.");
|
|
1133
|
+
}
|
|
1134
|
+
const contractSource = (await this.git(["show", `${configCommitSha}:.forge/forge.sandbox.yml`], 30_000)).stdout;
|
|
1135
|
+
const dockerfile = (await this.git(["show", `${configCommitSha}:.forge/Dockerfile`], 30_000)).stdout;
|
|
1136
|
+
const inputRef = `refs/forge/inputs/${dispatch.sandbox.dispatchAttemptId.replace(/-/g, "")}`;
|
|
1137
|
+
const bundlePath = path.join(attemptRoot, "input", "repo.bundle");
|
|
1138
|
+
await this.git(["update-ref", inputRef, inputCommitSha], 30_000);
|
|
1139
|
+
try {
|
|
1140
|
+
await this.git(["bundle", "create", bundlePath, inputRef], 5 * 60_000);
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
await this.git(["update-ref", "-d", inputRef], 30_000).catch(() => null);
|
|
1144
|
+
}
|
|
1145
|
+
const contextArchive = path.join(attemptRoot, "input", "context.tar");
|
|
1146
|
+
await this.git(["archive", "--format=tar", "--output", contextArchive, configCommitSha], 5 * 60_000);
|
|
1147
|
+
const contextRoot = path.join(attemptRoot, "context");
|
|
1148
|
+
await fs.mkdir(contextRoot, { recursive: true });
|
|
1149
|
+
await requireSandboxProcess({ command: "tar", args: ["-xf", contextArchive, "-C", contextRoot], timeoutMs: 5 * 60_000, maxOutputBytes: 1024 * 1024 });
|
|
1150
|
+
const contextDigest = createHash("sha256").update(await fs.readFile(contextArchive)).digest("hex");
|
|
1151
|
+
return { inputCommitSha, configCommitSha, contractSource, dockerfile, bundlePath, contextRoot, contextDigest };
|
|
1152
|
+
}
|
|
1153
|
+
async importResult(dispatch, output, assertCurrent) {
|
|
1154
|
+
await this.coordinator.withLock(this.repoPath, () => this.importResultLocked(dispatch, output, assertCurrent));
|
|
1155
|
+
}
|
|
1156
|
+
async publishResult(dispatch, output, assertCurrent, credential, signal) {
|
|
1157
|
+
await this.coordinator.withLock(this.repoPath, async () => {
|
|
1158
|
+
const handle = dispatch.sandbox?.publishGrantHandle;
|
|
1159
|
+
const resultCommitSha = output.manifest.resultCommitSha;
|
|
1160
|
+
if (dispatch.sandbox?.publicationMode !== "bridge_push"
|
|
1161
|
+
|| !handle
|
|
1162
|
+
|| !resultCommitSha
|
|
1163
|
+
|| handle.branch !== dispatch.lease.worktreeKey
|
|
1164
|
+
|| !/^[0-9a-f-]{32,36}$/iu.test(handle.grantId)) {
|
|
1165
|
+
throw new Error("sandbox_output_publish_forbidden: remote publication grant is missing or outside its attempt scope.");
|
|
1166
|
+
}
|
|
1167
|
+
assertCurrent();
|
|
1168
|
+
const credentialEnvironment = publicationCredentialEnvironment(credential);
|
|
1169
|
+
await this.git(["check-ref-format", "--branch", handle.branch], 30_000, signal);
|
|
1170
|
+
const remoteRef = `refs/heads/${handle.branch}`;
|
|
1171
|
+
const remote = await this.git(["ls-remote", "--heads", credential.remoteUrl, remoteRef], 60_000, signal, credentialEnvironment);
|
|
1172
|
+
const remoteSha = remote.stdout.trim().split(/\s+/u)[0] ?? "";
|
|
1173
|
+
const pushArgs = publicationPushArgs(handle.branch, resultCommitSha, output.manifest.inputCommitSha, remoteSha, credential.remoteUrl);
|
|
1174
|
+
if (!pushArgs) {
|
|
1175
|
+
assertCurrent();
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
assertCurrent();
|
|
1179
|
+
await this.git(pushArgs, 5 * 60_000, signal, credentialEnvironment);
|
|
1180
|
+
assertCurrent();
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
async importResultLocked(dispatch, output, assertCurrent) {
|
|
1184
|
+
if (!output.bundlePath || !output.manifest.resultCommitSha)
|
|
1185
|
+
return;
|
|
1186
|
+
await this.git(["bundle", "verify", output.bundlePath], 2 * 60_000);
|
|
1187
|
+
const attemptHex = dispatch.sandbox.dispatchAttemptId.replace(/-/g, "");
|
|
1188
|
+
const sourceRef = `refs/forge/results/${attemptHex}`;
|
|
1189
|
+
const importRef = `refs/forge/imports/${attemptHex}`;
|
|
1190
|
+
const quarantine = path.join(this.config.sandboxStateRoot, "quarantine", attemptHex);
|
|
1191
|
+
await fs.rm(quarantine, { recursive: true, force: true });
|
|
1192
|
+
await fs.mkdir(path.dirname(quarantine), { recursive: true, mode: 0o700 });
|
|
1193
|
+
await requireSandboxProcess({ command: "git", args: ["init", "--bare", quarantine], timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 });
|
|
1194
|
+
try {
|
|
1195
|
+
await quarantineGit(quarantine, ["fetch", "--no-write-fetch-head", output.bundlePath, `${sourceRef}:${importRef}`], 5 * 60_000);
|
|
1196
|
+
await quarantineGit(quarantine, ["fsck", "--strict", "--full", "--no-reflogs"], 5 * 60_000);
|
|
1197
|
+
const objectInventory = await runSandboxProcess({
|
|
1198
|
+
command: "git",
|
|
1199
|
+
args: ["-C", quarantine, "cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
|
|
1200
|
+
timeoutMs: 2 * 60_000,
|
|
1201
|
+
maxOutputBytes: 64 * 1024 * 1024,
|
|
1202
|
+
terminateOnOutputLimit: true
|
|
1203
|
+
});
|
|
1204
|
+
if (objectInventory.exitCode !== 0 || objectInventory.truncated) {
|
|
1205
|
+
throw new Error("sandbox_output_invalid: Git bundle exceeds object validation limits.");
|
|
1206
|
+
}
|
|
1207
|
+
validateGitObjectBudget(objectInventory.stdout);
|
|
1208
|
+
const imported = (await quarantineGit(quarantine, ["rev-parse", `${importRef}^{commit}`], 30_000)).stdout.trim();
|
|
1209
|
+
if (imported !== output.manifest.resultCommitSha)
|
|
1210
|
+
throw new Error("sandbox_output_invalid: imported result SHA differs from manifest.");
|
|
1211
|
+
const ancestry = await runSandboxProcess({ command: "git", args: ["-C", quarantine, "merge-base", "--is-ancestor", output.manifest.inputCommitSha, imported], timeoutMs: 30_000 });
|
|
1212
|
+
if (ancestry.exitCode !== 0)
|
|
1213
|
+
throw new Error("sandbox_output_invalid: result commit is not descended from input commit.");
|
|
1214
|
+
const commitDepth = await quarantineGit(quarantine, ["rev-list", "--count", `${output.manifest.inputCommitSha}..${imported}`], 2 * 60_000);
|
|
1215
|
+
validateGitCommitDepth(commitDepth.stdout);
|
|
1216
|
+
await this.git(["fetch", "--no-write-fetch-head", quarantine, `${importRef}:${importRef}`], 5 * 60_000);
|
|
1217
|
+
try {
|
|
1218
|
+
assertCurrent();
|
|
1219
|
+
await this.updateManagedBranch(dispatch.lease.worktreeKey, imported, output.manifest.inputCommitSha);
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
await this.git(["update-ref", "-d", importRef], 30_000).catch(() => null);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
finally {
|
|
1226
|
+
await fs.rm(quarantine, { recursive: true, force: true }).catch(() => undefined);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
async updateManagedBranch(worktreeKey, imported, expected) {
|
|
1230
|
+
const branchRef = `refs/heads/${worktreeKey}`;
|
|
1231
|
+
const listing = (await this.git(["worktree", "list", "--porcelain"], 30_000)).stdout;
|
|
1232
|
+
const checkedOut = parseWorktreeListing(listing).find(entry => entry.branch === branchRef);
|
|
1233
|
+
if (!checkedOut) {
|
|
1234
|
+
const current = (await this.git(["for-each-ref", "--format=%(objectname)", branchRef], 30_000)).stdout.trim();
|
|
1235
|
+
await this.git(["update-ref", branchRef, imported, managedBranchUpdateExpectedValue(current, expected, imported)], 30_000);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const head = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "rev-parse", "HEAD"], timeoutMs: 30_000 })).stdout.trim();
|
|
1239
|
+
const status = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "status", "--porcelain=v1", "--untracked-files=no"], timeoutMs: 30_000, maxOutputBytes: 4 * 1024 * 1024 })).stdout;
|
|
1240
|
+
if (head !== expected || status.trim())
|
|
1241
|
+
throw new Error("sandbox_output_conflict: managed worktree changed after sandbox input was captured.");
|
|
1242
|
+
await this.git(["update-ref", branchRef, imported, expected], 30_000);
|
|
1243
|
+
await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "reset", "--hard"], timeoutMs: 2 * 60_000, maxOutputBytes: 4 * 1024 * 1024 });
|
|
1244
|
+
}
|
|
1245
|
+
async resolveFirst(refs) {
|
|
1246
|
+
for (const ref of refs) {
|
|
1247
|
+
const result = await runSandboxProcess({ command: "git", args: ["-C", this.repoPath, "rev-parse", "--verify", `${ref}^{commit}`], timeoutMs: 30_000 });
|
|
1248
|
+
const sha = result.stdout.trim();
|
|
1249
|
+
if (result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(sha))
|
|
1250
|
+
return sha;
|
|
1251
|
+
}
|
|
1252
|
+
throw new Error("sandbox_input_failed: no approved input Git ref could be resolved.");
|
|
1253
|
+
}
|
|
1254
|
+
git(args, timeoutMs, signal, env) { return requireSandboxProcess({ command: "git", args: ["-C", this.repoPath, ...args], timeoutMs, maxOutputBytes: 4 * 1024 * 1024, signal, env }); }
|
|
1255
|
+
}
|
|
1256
|
+
export function managedBranchUpdateExpectedValue(current, expectedInput, imported) {
|
|
1257
|
+
const normalized = current.trim();
|
|
1258
|
+
if (!normalized) {
|
|
1259
|
+
return "0".repeat(imported.length);
|
|
1260
|
+
}
|
|
1261
|
+
if (normalized !== expectedInput) {
|
|
1262
|
+
throw new Error("sandbox_output_conflict: managed branch changed after sandbox input was captured.");
|
|
1263
|
+
}
|
|
1264
|
+
return normalized;
|
|
1265
|
+
}
|
|
1266
|
+
export function publicationPushArgs(branch, resultCommitSha, inputCommitSha, remoteCommitSha, remote = "origin") {
|
|
1267
|
+
const isCommit = (value) => /^[0-9a-f]{40,64}$/iu.test(value);
|
|
1268
|
+
if (!branch || branch.length > 240 || !isCommit(resultCommitSha) || !isCommit(inputCommitSha) || (remoteCommitSha && !isCommit(remoteCommitSha))) {
|
|
1269
|
+
throw new Error("sandbox_output_publish_rejected: publication inputs contain an invalid branch or object id.");
|
|
1270
|
+
}
|
|
1271
|
+
if (remoteCommitSha === resultCommitSha)
|
|
1272
|
+
return null;
|
|
1273
|
+
if (remoteCommitSha && remoteCommitSha !== inputCommitSha) {
|
|
1274
|
+
throw new Error("sandbox_output_publish_stale: remote branch changed after the sandbox input was captured.");
|
|
1275
|
+
}
|
|
1276
|
+
const remoteRef = `refs/heads/${branch}`;
|
|
1277
|
+
return [
|
|
1278
|
+
"push",
|
|
1279
|
+
"--porcelain",
|
|
1280
|
+
remote,
|
|
1281
|
+
`${resultCommitSha}:${remoteRef}`,
|
|
1282
|
+
`--force-with-lease=${remoteRef}:${remoteCommitSha}`
|
|
1283
|
+
];
|
|
1284
|
+
}
|
|
1285
|
+
export function validatePublicationCredential(credential, nowMs = Date.now()) {
|
|
1286
|
+
const remote = (() => { try {
|
|
1287
|
+
return new URL(credential.remoteUrl);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
return null;
|
|
1291
|
+
} })();
|
|
1292
|
+
if (!remote
|
|
1293
|
+
|| remote.protocol !== "https:"
|
|
1294
|
+
|| remote.hostname.toLowerCase() !== "github.com"
|
|
1295
|
+
|| remote.port
|
|
1296
|
+
|| remote.username
|
|
1297
|
+
|| remote.password
|
|
1298
|
+
|| remote.search
|
|
1299
|
+
|| remote.hash
|
|
1300
|
+
|| !/^\/[a-z0-9_.-]+\/[a-z0-9_.-]+\.git$/iu.test(remote.pathname)
|
|
1301
|
+
|| !/^[a-z0-9_-]{1,64}$/iu.test(credential.username)
|
|
1302
|
+
|| !credential.secret
|
|
1303
|
+
|| credential.secret.length > 16 * 1024
|
|
1304
|
+
|| /[\r\n\0]/u.test(credential.secret)
|
|
1305
|
+
|| Date.parse(credential.expiresAt) <= nowMs) {
|
|
1306
|
+
throw new Error("sandbox_output_publish_forbidden: redeemed publication credential is invalid or expired.");
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
export function publicationCredentialEnvironment(credential, environment = process.env) {
|
|
1310
|
+
validatePublicationCredential(credential);
|
|
1311
|
+
const authorization = Buffer.from(`${credential.username}:${credential.secret}`, "utf8").toString("base64");
|
|
1312
|
+
return {
|
|
1313
|
+
...environment,
|
|
1314
|
+
GIT_CONFIG_COUNT: "1",
|
|
1315
|
+
GIT_CONFIG_KEY_0: "http.https://github.com/.extraHeader",
|
|
1316
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${authorization}`,
|
|
1317
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1318
|
+
GCM_INTERACTIVE: "Never"
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
export function restrictedBuildLog(source, exactSecrets = [], maximumBytes = 4 * 1024 * 1024) {
|
|
1322
|
+
const limit = Math.max(1024, Math.min(maximumBytes, 4 * 1024 * 1024));
|
|
1323
|
+
const redacted = Buffer.from(redactText(source, exactSecrets), "utf8");
|
|
1324
|
+
const truncated = redacted.length > limit;
|
|
1325
|
+
const retained = truncated ? redacted.subarray(0, limit) : redacted;
|
|
1326
|
+
return {
|
|
1327
|
+
content: retained.toString("utf8"),
|
|
1328
|
+
digest: `sha256:${createHash("sha256").update(retained).digest("hex")}`,
|
|
1329
|
+
byteCount: retained.length,
|
|
1330
|
+
truncated
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000, maximumExpandedBytes = 2 * 1024 * 1024 * 1024) {
|
|
1334
|
+
const lines = inventory.split(/\r?\n/u).filter(Boolean);
|
|
1335
|
+
if (lines.length === 0 || lines.length > maximumObjectCount) {
|
|
1336
|
+
throw new Error("sandbox_output_invalid: Git bundle exceeds the object-count budget.");
|
|
1337
|
+
}
|
|
1338
|
+
const maximum = BigInt(maximumExpandedBytes);
|
|
1339
|
+
let expanded = 0n;
|
|
1340
|
+
const objects = new Set();
|
|
1341
|
+
for (const line of lines) {
|
|
1342
|
+
const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (blob|tree|commit|tag) ([0-9]+)$/iu.exec(line);
|
|
1343
|
+
if (!match || objects.has(match[1].toLowerCase())) {
|
|
1344
|
+
throw new Error("sandbox_output_invalid: Git object inventory is malformed or duplicated.");
|
|
1345
|
+
}
|
|
1346
|
+
objects.add(match[1].toLowerCase());
|
|
1347
|
+
const size = BigInt(match[3]);
|
|
1348
|
+
expanded += size;
|
|
1349
|
+
if (size > maximum || expanded > maximum) {
|
|
1350
|
+
throw new Error("sandbox_output_invalid: Git bundle exceeds the expanded-object byte budget.");
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return { objectCount: lines.length, expandedBytes: Number(expanded) };
|
|
1354
|
+
}
|
|
1355
|
+
export function validateGitCommitDepth(value, maximumCommitDepth = 50_000) {
|
|
1356
|
+
const normalized = value.trim();
|
|
1357
|
+
if (!/^[0-9]+$/u.test(normalized)) {
|
|
1358
|
+
throw new Error("sandbox_output_invalid: Git commit-depth result is malformed.");
|
|
1359
|
+
}
|
|
1360
|
+
const depth = BigInt(normalized);
|
|
1361
|
+
if (depth > BigInt(maximumCommitDepth)) {
|
|
1362
|
+
throw new Error("sandbox_output_invalid: Git result exceeds the commit-depth budget.");
|
|
1363
|
+
}
|
|
1364
|
+
return Number(depth);
|
|
1365
|
+
}
|
|
1366
|
+
export function selectAuditArtifactGcCandidates(records, retentionCount, retentionTtlMs, protectedKeys = new Set(), nowMs = Date.now()) {
|
|
1367
|
+
const count = Math.max(1, Math.floor(retentionCount));
|
|
1368
|
+
const ttl = Math.max(60 * 60_000, retentionTtlMs);
|
|
1369
|
+
return records
|
|
1370
|
+
.filter(record => !protectedKeys.has(record.key))
|
|
1371
|
+
.sort((left, right) => right.lastModifiedMs - left.lastModifiedMs)
|
|
1372
|
+
.filter((record, index) => index >= count || nowMs - record.lastModifiedMs > ttl)
|
|
1373
|
+
.map(record => record.key);
|
|
1374
|
+
}
|
|
1375
|
+
export function selectSandboxConfigRefs(executionPolicy, configuredSha, defaultBranch) {
|
|
1376
|
+
const approvedSha = configuredSha?.trim().toLowerCase() || null;
|
|
1377
|
+
if (executionPolicy === "sandbox_required" && !approvedSha) {
|
|
1378
|
+
throw new Error("sandbox_config_commit_unapproved: sandbox_required dispatches need a server-approved config commit SHA.");
|
|
1379
|
+
}
|
|
1380
|
+
if (approvedSha && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(approvedSha)) {
|
|
1381
|
+
throw new Error("sandbox_config_commit_invalid: approved sandbox config commit is not a full Git object id.");
|
|
1382
|
+
}
|
|
1383
|
+
return approvedSha
|
|
1384
|
+
? { refs: [approvedSha], approvedSha }
|
|
1385
|
+
: { refs: [`refs/heads/${defaultBranch}`, `refs/remotes/origin/${defaultBranch}`], approvedSha: null };
|
|
1386
|
+
}
|
|
1387
|
+
export function selectSandboxInputRefs(worktreeKey, baseBranch) {
|
|
1388
|
+
return [
|
|
1389
|
+
`refs/heads/${worktreeKey}`,
|
|
1390
|
+
`refs/heads/${baseBranch}`,
|
|
1391
|
+
`refs/remotes/origin/${baseBranch}`
|
|
1392
|
+
];
|
|
1393
|
+
}
|
|
1394
|
+
export function sandboxTimeLimits(environment = process.env) {
|
|
1395
|
+
const hardWallSeconds = boundedInteger(environment.FORGE_SANDBOX_HARD_WALL_SECONDS, 8 * 60 * 60, 60, 8 * 60 * 60);
|
|
1396
|
+
const idleSeconds = boundedInteger(environment.FORGE_SANDBOX_IDLE_SECONDS, 10 * 60, 60, hardWallSeconds);
|
|
1397
|
+
return { hardWallMs: hardWallSeconds * 1000, idleMs: idleSeconds * 1000 };
|
|
1398
|
+
}
|
|
1399
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
1400
|
+
const parsed = Number(value);
|
|
1401
|
+
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
|
|
1402
|
+
}
|
|
1403
|
+
function quarantineGit(repoPath, args, timeoutMs) {
|
|
1404
|
+
return requireSandboxProcess({
|
|
1405
|
+
command: "git",
|
|
1406
|
+
args: ["-C", repoPath, "-c", "transfer.fsckObjects=true", "-c", "fetch.fsckObjects=true", ...args],
|
|
1407
|
+
timeoutMs,
|
|
1408
|
+
maxOutputBytes: 16 * 1024 * 1024,
|
|
1409
|
+
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" }
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
function parseWorktreeListing(value) {
|
|
1413
|
+
return value.trim().split(/\r?\n\r?\n/u).map(block => {
|
|
1414
|
+
const lines = block.split(/\r?\n/u);
|
|
1415
|
+
return {
|
|
1416
|
+
path: lines.find(line => line.startsWith("worktree "))?.slice(9) ?? "",
|
|
1417
|
+
branch: lines.find(line => line.startsWith("branch "))?.slice(7) ?? null
|
|
1418
|
+
};
|
|
1419
|
+
}).filter(entry => path.isAbsolute(entry.path));
|
|
1420
|
+
}
|
|
1421
|
+
async function validateOutput(attemptRoot, dispatch) {
|
|
1422
|
+
const outputRoot = path.join(attemptRoot, "output");
|
|
1423
|
+
const manifestPath = path.join(outputRoot, "manifest.json");
|
|
1424
|
+
const stat = await fs.lstat(manifestPath).catch(error => {
|
|
1425
|
+
if (isNodeErrorWithCode(error, "ENOENT")) {
|
|
1426
|
+
throw new Error("sandbox_output_missing: sandbox worker did not produce manifest.json.");
|
|
1427
|
+
}
|
|
1428
|
+
throw error;
|
|
1429
|
+
});
|
|
1430
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
|
|
1431
|
+
throw new Error("sandbox_output_invalid: manifest must be a bounded regular file.");
|
|
1432
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
1433
|
+
if (!dispatch.sandbox
|
|
1434
|
+
|| manifest.dispatchAttemptId !== dispatch.sandbox.dispatchAttemptId
|
|
1435
|
+
|| manifest.executionLeaseId !== dispatch.sandbox.executionLeaseId
|
|
1436
|
+
|| manifest.fencingToken !== dispatch.lease.fencingToken
|
|
1437
|
+
|| manifest.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
|
|
1438
|
+
|| manifest.inputCommitSha !== dispatch.sandbox.inputCommitSha
|
|
1439
|
+
|| manifest.branch !== dispatch.lease.worktreeKey
|
|
1440
|
+
|| manifest.contractDigest !== dispatch.sandbox.contractDigest
|
|
1441
|
+
|| manifest.imageDigest !== dispatch.sandbox.imageDigest)
|
|
1442
|
+
throw new Error("sandbox_output_invalid: manifest identity or fence mismatch.");
|
|
1443
|
+
if ((manifest.resultCommitSha === null) !== (manifest.bundle === null)
|
|
1444
|
+
|| (manifest.resultCommitSha !== null && !/^[0-9a-f]{40,64}$/i.test(manifest.resultCommitSha))) {
|
|
1445
|
+
throw new Error("sandbox_output_invalid: result commit and bundle must be present together.");
|
|
1446
|
+
}
|
|
1447
|
+
const resultPath = await validateManifestFile(outputRoot, manifest.result, 16 * 1024 * 1024);
|
|
1448
|
+
const bundlePath = manifest.bundle ? await validateManifestFile(outputRoot, manifest.bundle, 512 * 1024 * 1024) : null;
|
|
1449
|
+
return { manifest, resultPath, bundlePath };
|
|
1450
|
+
}
|
|
1451
|
+
async function validateManifestFile(root, file, max) {
|
|
1452
|
+
if (!/^[a-z0-9_.-]+$/i.test(file.path))
|
|
1453
|
+
throw new Error("sandbox_output_invalid: output path is invalid.");
|
|
1454
|
+
const target = path.join(root, file.path);
|
|
1455
|
+
const stat = await fs.lstat(target);
|
|
1456
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.bytes || stat.size > max)
|
|
1457
|
+
throw new Error("sandbox_output_invalid: output file bounds mismatch.");
|
|
1458
|
+
const digest = createHash("sha256").update(await fs.readFile(target)).digest("hex");
|
|
1459
|
+
if (digest !== file.sha256)
|
|
1460
|
+
throw new Error("sandbox_output_invalid: output hash mismatch.");
|
|
1461
|
+
return target;
|
|
1462
|
+
}
|
|
1463
|
+
function isMissingSandboxManifest(error) {
|
|
1464
|
+
return error instanceof Error && error.message.startsWith("sandbox_output_missing:");
|
|
1465
|
+
}
|
|
1466
|
+
function isNodeErrorWithCode(error, code) {
|
|
1467
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
1468
|
+
}
|
|
1469
|
+
export function sandboxWorkerFailureMessage(exitCode, stderr, containerError) {
|
|
1470
|
+
const diagnostic = [containerError, stderr]
|
|
1471
|
+
.filter((value) => Boolean(value?.trim()))
|
|
1472
|
+
.join(" ")
|
|
1473
|
+
.replace(/[\r\n\t]+/gu, " ")
|
|
1474
|
+
.trim()
|
|
1475
|
+
.slice(0, 1000);
|
|
1476
|
+
const suffix = diagnostic ? ` Worker diagnostic: ${diagnostic}` : " No worker diagnostic was captured.";
|
|
1477
|
+
return `sandbox_worker_failed: Sandbox worker exited with code ${exitCode} before producing its output manifest.${suffix}`;
|
|
1478
|
+
}
|
|
1479
|
+
function identityFor(dispatch) {
|
|
1480
|
+
const value = dispatch.sandbox?.dispatchAttemptId.replace(/-/g, "").toLowerCase() ?? "";
|
|
1481
|
+
if (!/^[0-9a-f]{32}$/.test(value))
|
|
1482
|
+
throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
|
|
1483
|
+
return { attemptHex: value };
|
|
1484
|
+
}
|
|
1485
|
+
class SandboxTeardownError extends Error {
|
|
1486
|
+
report;
|
|
1487
|
+
constructor(report, message) {
|
|
1488
|
+
super(message);
|
|
1489
|
+
this.report = report;
|
|
1490
|
+
this.name = "SandboxTeardownError";
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
function emptyCleanupReport(source) {
|
|
1494
|
+
return { reportId: randomUUID(), source, actions: [] };
|
|
1495
|
+
}
|
|
1496
|
+
function cleanupAction(labels, resourceType, resourceId, outcome, message = null) {
|
|
1497
|
+
return {
|
|
1498
|
+
dispatchAttemptId: labels["dev.forge.dispatch-attempt-id"] ?? "",
|
|
1499
|
+
fencingToken: labels["dev.forge.fencing-token"] ?? "",
|
|
1500
|
+
runnerInstanceId: labels["dev.forge.runner-instance-id"] ?? "",
|
|
1501
|
+
resourceType,
|
|
1502
|
+
resourceId: resourceId.slice(0, 255),
|
|
1503
|
+
outcome,
|
|
1504
|
+
message,
|
|
1505
|
+
occurredAt: new Date().toISOString()
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function cleanupErrorMessage(error) {
|
|
1509
|
+
return redactText(error instanceof Error ? error.message : String(error)).replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000)
|
|
1510
|
+
|| "Docker resource removal failed.";
|
|
1511
|
+
}
|
|
1512
|
+
function parseCleanupReport(value) {
|
|
1513
|
+
if (!value || typeof value !== "object")
|
|
1514
|
+
return null;
|
|
1515
|
+
const report = value;
|
|
1516
|
+
if (typeof report.reportId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(report.reportId))
|
|
1517
|
+
return null;
|
|
1518
|
+
if (report.source !== "teardown" && report.source !== "reaper")
|
|
1519
|
+
return null;
|
|
1520
|
+
if (!Array.isArray(report.actions) || report.actions.length > 128)
|
|
1521
|
+
return null;
|
|
1522
|
+
const actions = [];
|
|
1523
|
+
for (const candidate of report.actions) {
|
|
1524
|
+
if (!candidate || typeof candidate !== "object")
|
|
1525
|
+
return null;
|
|
1526
|
+
const action = candidate;
|
|
1527
|
+
if (typeof action.dispatchAttemptId !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.dispatchAttemptId)
|
|
1528
|
+
|| typeof action.fencingToken !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.fencingToken)
|
|
1529
|
+
|| typeof action.runnerInstanceId !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.runnerInstanceId)
|
|
1530
|
+
|| (action.resourceType !== "container" && action.resourceType !== "network" && action.resourceType !== "volume")
|
|
1531
|
+
|| typeof action.resourceId !== "string" || action.resourceId.length === 0 || action.resourceId.length > 255 || hasControlCharacters(action.resourceId)
|
|
1532
|
+
|| (action.outcome !== "removed" && action.outcome !== "failed")
|
|
1533
|
+
|| (action.message != null && (typeof action.message !== "string" || action.message.length > 1000))
|
|
1534
|
+
|| typeof action.occurredAt !== "string" || !Number.isFinite(Date.parse(action.occurredAt)))
|
|
1535
|
+
return null;
|
|
1536
|
+
actions.push(action);
|
|
1537
|
+
}
|
|
1538
|
+
return { reportId: report.reportId, source: report.source, actions };
|
|
1539
|
+
}
|
|
1540
|
+
function hasControlCharacters(value) {
|
|
1541
|
+
return [...value].some(character => {
|
|
1542
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1543
|
+
return code < 32 || code === 127;
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
function createSandboxMemoryAccumulator(sampleIntervalMs) {
|
|
1547
|
+
return {
|
|
1548
|
+
sampleIntervalMs,
|
|
1549
|
+
sampleCount: 0,
|
|
1550
|
+
failedSampleCount: 0,
|
|
1551
|
+
peakMemoryBytes: 0,
|
|
1552
|
+
firstSampledAt: null,
|
|
1553
|
+
lastSampledAt: null,
|
|
1554
|
+
containerPeaks: new Map()
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
export function parseDockerMemoryBytes(value) {
|
|
1558
|
+
const match = /^([0-9]+(?:\.[0-9]+)?)\s*([kmgt]?i?b)$/iu.exec(value.trim());
|
|
1559
|
+
if (!match)
|
|
1560
|
+
return null;
|
|
1561
|
+
const amount = Number(match[1]);
|
|
1562
|
+
const multipliers = {
|
|
1563
|
+
b: 1,
|
|
1564
|
+
kb: 1000,
|
|
1565
|
+
mb: 1000 ** 2,
|
|
1566
|
+
gb: 1000 ** 3,
|
|
1567
|
+
tb: 1000 ** 4,
|
|
1568
|
+
kib: 1024,
|
|
1569
|
+
mib: 1024 ** 2,
|
|
1570
|
+
gib: 1024 ** 3,
|
|
1571
|
+
tib: 1024 ** 4
|
|
1572
|
+
};
|
|
1573
|
+
const multiplier = multipliers[match[2].toLowerCase()];
|
|
1574
|
+
const bytes = amount * multiplier;
|
|
1575
|
+
return Number.isFinite(bytes) && bytes >= 0 && bytes <= Number.MAX_SAFE_INTEGER
|
|
1576
|
+
? Math.round(bytes)
|
|
1577
|
+
: null;
|
|
1578
|
+
}
|
|
1579
|
+
export function parseDockerStatsMemorySample(output) {
|
|
1580
|
+
const samples = [];
|
|
1581
|
+
for (const line of output.split(/\r?\n/u).map(value => value.trim()).filter(Boolean)) {
|
|
1582
|
+
try {
|
|
1583
|
+
const value = JSON.parse(line);
|
|
1584
|
+
const containerId = typeof value.ID === "string" ? value.ID.trim().toLowerCase() : "";
|
|
1585
|
+
const name = typeof value.Name === "string" ? value.Name.trim()
|
|
1586
|
+
: typeof value.Container === "string" ? value.Container.trim()
|
|
1587
|
+
: "";
|
|
1588
|
+
const usage = typeof value.MemUsage === "string" ? value.MemUsage.split("/", 1)[0]?.trim() ?? "" : "";
|
|
1589
|
+
const memoryBytes = parseDockerMemoryBytes(usage);
|
|
1590
|
+
if ((!containerId && !name) || memoryBytes == null)
|
|
1591
|
+
continue;
|
|
1592
|
+
samples.push({ containerId, name, memoryBytes });
|
|
1593
|
+
}
|
|
1594
|
+
catch {
|
|
1595
|
+
// A malformed line is ignored; the sampler records the round as failed only if no valid rows remain.
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return samples;
|
|
1599
|
+
}
|
|
1600
|
+
function recordSandboxMemorySample(accumulator, containers, sample, sampledAt) {
|
|
1601
|
+
let aggregateMemoryBytes = 0;
|
|
1602
|
+
let matched = 0;
|
|
1603
|
+
for (const container of containers) {
|
|
1604
|
+
const row = sample.find(candidate => candidate.name === container.name
|
|
1605
|
+
|| (candidate.containerId.length > 0 && (container.id.startsWith(candidate.containerId) || candidate.containerId.startsWith(container.id))));
|
|
1606
|
+
if (!row)
|
|
1607
|
+
continue;
|
|
1608
|
+
matched += 1;
|
|
1609
|
+
aggregateMemoryBytes += row.memoryBytes;
|
|
1610
|
+
const previous = accumulator.containerPeaks.get(container.id);
|
|
1611
|
+
if (!previous || row.memoryBytes > previous.peakMemoryBytes) {
|
|
1612
|
+
accumulator.containerPeaks.set(container.id, {
|
|
1613
|
+
containerId: container.id,
|
|
1614
|
+
name: container.name,
|
|
1615
|
+
role: container.role,
|
|
1616
|
+
peakMemoryBytes: row.memoryBytes
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
if (matched === 0) {
|
|
1621
|
+
accumulator.failedSampleCount += 1;
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
accumulator.sampleCount += 1;
|
|
1625
|
+
accumulator.peakMemoryBytes = Math.max(accumulator.peakMemoryBytes, aggregateMemoryBytes);
|
|
1626
|
+
accumulator.firstSampledAt ??= sampledAt;
|
|
1627
|
+
accumulator.lastSampledAt = sampledAt;
|
|
1628
|
+
}
|
|
1629
|
+
function finalizeSandboxMemoryTelemetry(accumulator) {
|
|
1630
|
+
if (accumulator.sampleCount === 0 || !accumulator.firstSampledAt || !accumulator.lastSampledAt)
|
|
1631
|
+
return null;
|
|
1632
|
+
return {
|
|
1633
|
+
schemaVersion: 1,
|
|
1634
|
+
source: "docker_stats_sampled",
|
|
1635
|
+
sampleIntervalMs: accumulator.sampleIntervalMs,
|
|
1636
|
+
sampleCount: accumulator.sampleCount,
|
|
1637
|
+
failedSampleCount: accumulator.failedSampleCount,
|
|
1638
|
+
peakMemoryBytes: accumulator.peakMemoryBytes,
|
|
1639
|
+
firstSampledAt: accumulator.firstSampledAt,
|
|
1640
|
+
lastSampledAt: accumulator.lastSampledAt,
|
|
1641
|
+
containers: [...accumulator.containerPeaks.values()].sort((left, right) => left.role.localeCompare(right.role) || left.name.localeCompare(right.name))
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
function validSandboxPeakMemoryTelemetry(value) {
|
|
1645
|
+
return value?.schemaVersion === 1
|
|
1646
|
+
&& value.source === "docker_stats_sampled"
|
|
1647
|
+
&& Number.isInteger(value.sampleIntervalMs) && value.sampleIntervalMs >= 250 && value.sampleIntervalMs <= 30_000
|
|
1648
|
+
&& Number.isInteger(value.sampleCount) && value.sampleCount > 0
|
|
1649
|
+
&& Number.isInteger(value.failedSampleCount) && value.failedSampleCount >= 0
|
|
1650
|
+
&& Number.isSafeInteger(value.peakMemoryBytes) && value.peakMemoryBytes >= 0
|
|
1651
|
+
&& typeof value.firstSampledAt === "string" && Number.isFinite(Date.parse(value.firstSampledAt))
|
|
1652
|
+
&& typeof value.lastSampledAt === "string" && Number.isFinite(Date.parse(value.lastSampledAt))
|
|
1653
|
+
&& Array.isArray(value.containers) && value.containers.length <= 32
|
|
1654
|
+
&& value.containers.every(container => typeof container.containerId === "string" && /^[0-9a-f]{12,64}$/iu.test(container.containerId)
|
|
1655
|
+
&& typeof container.name === "string" && container.name.length > 0 && container.name.length <= 255 && !hasControlCharacters(container.name)
|
|
1656
|
+
&& typeof container.role === "string" && container.role.length > 0 && container.role.length <= 255 && !hasControlCharacters(container.role)
|
|
1657
|
+
&& Number.isSafeInteger(container.peakMemoryBytes) && container.peakMemoryBytes >= 0);
|
|
1658
|
+
}
|
|
1659
|
+
function waitForMemorySampleInterval(intervalMs, signal) {
|
|
1660
|
+
if (signal.aborted)
|
|
1661
|
+
return Promise.resolve();
|
|
1662
|
+
return new Promise(resolve => {
|
|
1663
|
+
const complete = () => {
|
|
1664
|
+
clearTimeout(timeout);
|
|
1665
|
+
signal.removeEventListener("abort", complete);
|
|
1666
|
+
resolve();
|
|
1667
|
+
};
|
|
1668
|
+
const timeout = setTimeout(complete, intervalMs);
|
|
1669
|
+
timeout.unref?.();
|
|
1670
|
+
signal.addEventListener("abort", complete, { once: true });
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
function formatMemoryBytes(value) {
|
|
1674
|
+
if (value >= 1024 ** 3)
|
|
1675
|
+
return `${(value / 1024 ** 3).toFixed(2)} GiB`;
|
|
1676
|
+
if (value >= 1024 ** 2)
|
|
1677
|
+
return `${(value / 1024 ** 2).toFixed(1)} MiB`;
|
|
1678
|
+
if (value >= 1024)
|
|
1679
|
+
return `${(value / 1024).toFixed(1)} KiB`;
|
|
1680
|
+
return `${value} B`;
|
|
1681
|
+
}
|
|
1682
|
+
function appendRunOnceEvent(result, dispatchEvent) {
|
|
1683
|
+
return dispatchEvent ? { ...result, events: [...result.events, dispatchEvent] } : result;
|
|
1684
|
+
}
|
|
1685
|
+
export function assertSandboxImportCurrent(expected, current, signal, nowMs = Date.now()) {
|
|
1686
|
+
if (signal.aborted
|
|
1687
|
+
|| !current
|
|
1688
|
+
|| current.dispatchAttemptId !== expected.dispatchAttemptId
|
|
1689
|
+
|| current.fencingToken !== expected.fencingToken
|
|
1690
|
+
|| Date.parse(expected.expiresAt) <= nowMs) {
|
|
1691
|
+
throw new Error("sandbox_output_stale: output cannot move the managed ref because its attempt, fence, or lease is no longer current.");
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
export function validSandboxLedger(value) {
|
|
1695
|
+
return normalizeSandboxLedger(value) !== null;
|
|
1696
|
+
}
|
|
1697
|
+
export function normalizeSandboxLedger(value) {
|
|
1698
|
+
if (validCurrentSandboxLedger(value))
|
|
1699
|
+
return value;
|
|
1700
|
+
if (!validLegacySandboxLedger(value))
|
|
1701
|
+
return null;
|
|
1702
|
+
// Schema v2 did not retain the reservation timestamp. Use the earlier of
|
|
1703
|
+
// its expiry and migration time so an old-instance ledger can transition
|
|
1704
|
+
// immediately during startup reaping, even when its lease has not expired.
|
|
1705
|
+
const createdAt = new Date(Math.min(Date.parse(value.expiresAt), Date.now())).toISOString();
|
|
1706
|
+
let runtimeLease = createRuntimeIsolationLease({
|
|
1707
|
+
leaseId: value.resourceLeaseId,
|
|
1708
|
+
dispatchAttemptId: value.dispatchAttemptId,
|
|
1709
|
+
executionLeaseId: value.resourceLeaseId,
|
|
1710
|
+
fencingToken: value.fencingToken,
|
|
1711
|
+
runnerId: value.labels["dev.forge.runner-id"],
|
|
1712
|
+
runnerInstanceId: value.runnerInstanceId,
|
|
1713
|
+
worktreeKey: `legacy/${value.dispatchAttemptId}`,
|
|
1714
|
+
worktreePath: "/workspace",
|
|
1715
|
+
mode: "docker",
|
|
1716
|
+
createdAt,
|
|
1717
|
+
expiresAt: value.expiresAt,
|
|
1718
|
+
resources: {
|
|
1719
|
+
memoryReservationBytes: 0,
|
|
1720
|
+
cpuMillis: 0,
|
|
1721
|
+
pids: 0,
|
|
1722
|
+
ports: [],
|
|
1723
|
+
paths: [{ path: "/workspace", role: "worktree" }],
|
|
1724
|
+
processSupervisorId: null,
|
|
1725
|
+
dockerResources: plannedDockerLeaseResources(value.resources, true, value.includeCredentials !== false),
|
|
1726
|
+
singletonKeys: [],
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
runtimeLease = transitionRuntimeIsolationLease(runtimeLease, "starting", createdAt);
|
|
1730
|
+
runtimeLease = transitionRuntimeIsolationLease(runtimeLease, "active", createdAt);
|
|
1731
|
+
return {
|
|
1732
|
+
schemaVersion: 3,
|
|
1733
|
+
runtimeLease,
|
|
1734
|
+
resources: value.resources,
|
|
1735
|
+
labels: value.labels,
|
|
1736
|
+
includeCredentials: value.includeCredentials,
|
|
1737
|
+
peakMemory: value.peakMemory,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
function validCurrentSandboxLedger(value) {
|
|
1741
|
+
if (typeof value !== "object" || value === null)
|
|
1742
|
+
return false;
|
|
1743
|
+
const ledger = value;
|
|
1744
|
+
const lease = ledger.runtimeLease;
|
|
1745
|
+
return ledger.schemaVersion === 3
|
|
1746
|
+
&& validRuntimeIsolationLease(lease)
|
|
1747
|
+
&& lease.mode === "docker"
|
|
1748
|
+
&& typeof ledger.resources?.network === "string"
|
|
1749
|
+
&& ledger.labels?.["dev.forge.managed"] === "true"
|
|
1750
|
+
&& ledger.labels?.["dev.forge.runner-id"] === lease.runnerId
|
|
1751
|
+
&& ledger.labels?.["dev.forge.resource-lease-id"] === lease.leaseId
|
|
1752
|
+
&& ledger.labels?.["dev.forge.execution-lease-id"] === lease.executionLeaseId
|
|
1753
|
+
&& ledger.labels?.["dev.forge.dispatch-attempt-id"] === lease.dispatchAttemptId
|
|
1754
|
+
&& ledger.labels?.["dev.forge.fencing-token"] === lease.fencingToken
|
|
1755
|
+
&& ledger.labels?.["dev.forge.runner-instance-id"] === lease.runnerInstanceId
|
|
1756
|
+
&& (ledger.peakMemory == null || validSandboxPeakMemoryTelemetry(ledger.peakMemory));
|
|
1757
|
+
}
|
|
1758
|
+
function validLegacySandboxLedger(value) {
|
|
1759
|
+
if (typeof value !== "object" || value === null)
|
|
1760
|
+
return false;
|
|
1761
|
+
const ledger = value;
|
|
1762
|
+
return ledger.schemaVersion === 2
|
|
1763
|
+
&& isFullUuid(ledger.resourceLeaseId)
|
|
1764
|
+
&& isFullUuid(ledger.dispatchAttemptId)
|
|
1765
|
+
&& isFullUuid(ledger.fencingToken)
|
|
1766
|
+
&& isFullUuid(ledger.runnerInstanceId)
|
|
1767
|
+
&& Number.isFinite(Date.parse(ledger.expiresAt))
|
|
1768
|
+
&& typeof ledger.resources?.network === "string"
|
|
1769
|
+
&& ledger.labels?.["dev.forge.managed"] === "true"
|
|
1770
|
+
&& isFullUuid(ledger.labels?.["dev.forge.runner-id"])
|
|
1771
|
+
&& ledger.labels?.["dev.forge.resource-lease-id"] === ledger.resourceLeaseId
|
|
1772
|
+
&& ledger.labels?.["dev.forge.execution-lease-id"] === ledger.resourceLeaseId
|
|
1773
|
+
&& ledger.labels?.["dev.forge.dispatch-attempt-id"] === ledger.dispatchAttemptId
|
|
1774
|
+
&& ledger.labels?.["dev.forge.fencing-token"] === ledger.fencingToken
|
|
1775
|
+
&& ledger.labels?.["dev.forge.runner-instance-id"] === ledger.runnerInstanceId
|
|
1776
|
+
&& (ledger.peakMemory == null || validSandboxPeakMemoryTelemetry(ledger.peakMemory));
|
|
1777
|
+
}
|
|
1778
|
+
export function resourceNames(attemptHex, perAttemptCaches = []) { const prefix = `forge-${attemptHex}`; return { prefix, container: `forge-agent-${attemptHex}`, network: prefix, egressNetwork: `${prefix}-egress`, workspaceVolume: `${prefix}-workspace`, controlVolume: `${prefix}-control`, secretsVolume: `${prefix}-secrets`, credentialsVolume: `${prefix}-credentials`, egressConfigVolume: `${prefix}-egress-policy`, cacheVolumes: Object.fromEntries(perAttemptCaches.map(name => [name, `${prefix}-cache-${name}`])) }; }
|
|
1779
|
+
export function dockerObjectId(output, kind) {
|
|
1780
|
+
const id = output.trim();
|
|
1781
|
+
if (!/^[0-9a-f]{12,64}$/iu.test(id)) {
|
|
1782
|
+
throw new Error(`sandbox_create_failed: Docker returned an invalid ${kind} id.`);
|
|
1783
|
+
}
|
|
1784
|
+
return id.toLowerCase();
|
|
1785
|
+
}
|
|
1786
|
+
export function sandboxRuntimeVolumes(resources, includeEgressPolicy = true, includeCredentials = true) {
|
|
1787
|
+
return [
|
|
1788
|
+
{ name: resources.workspaceVolume, role: "workspace" },
|
|
1789
|
+
{ name: resources.controlVolume, role: "control" },
|
|
1790
|
+
{ name: resources.secretsVolume, role: "secrets" },
|
|
1791
|
+
...(includeCredentials ? [{ name: resources.credentialsVolume, role: "credentials" }] : []),
|
|
1792
|
+
...(includeEgressPolicy ? [{ name: resources.egressConfigVolume, role: "egress_policy" }] : []),
|
|
1793
|
+
...Object.entries(resources.cacheVolumes).map(([cache, name]) => ({ name, role: `cache:${cache}` }))
|
|
1794
|
+
];
|
|
1795
|
+
}
|
|
1796
|
+
export function sandboxRuntimeCleanupVolumeNames(resources, includeCredentials = true) {
|
|
1797
|
+
return sandboxRuntimeVolumes(resources, true, includeCredentials)
|
|
1798
|
+
.map(volume => volume.name)
|
|
1799
|
+
.filter((name) => typeof name === "string" && name.length > 0);
|
|
1800
|
+
}
|
|
1801
|
+
function plannedDockerLeaseResources(resources, includeEgressPolicy, includeCredentials) {
|
|
1802
|
+
return [
|
|
1803
|
+
{ type: "container", name: resources.container, role: "agent" },
|
|
1804
|
+
{ type: "network", name: resources.network, role: "internal" },
|
|
1805
|
+
...(includeEgressPolicy ? [{ type: "network", name: resources.egressNetwork, role: "egress" }] : []),
|
|
1806
|
+
...sandboxRuntimeVolumes(resources, includeEgressPolicy, includeCredentials)
|
|
1807
|
+
.map(volume => ({ type: "volume", name: volume.name, role: volume.role })),
|
|
1808
|
+
];
|
|
1809
|
+
}
|
|
1810
|
+
function activeDockerLeaseResources(containers, networks, volumes) {
|
|
1811
|
+
return [
|
|
1812
|
+
...containers.map(resource => ({ type: "container", ...resource })),
|
|
1813
|
+
...networks.map(resource => ({ type: "network", ...resource })),
|
|
1814
|
+
...volumes.map(resource => ({ type: "volume", ...resource })),
|
|
1815
|
+
];
|
|
1816
|
+
}
|
|
1817
|
+
function transitionForCleanup(lease, state) {
|
|
1818
|
+
if (lease.state === state)
|
|
1819
|
+
return lease;
|
|
1820
|
+
return transitionRuntimeIsolationLease(lease, state);
|
|
1821
|
+
}
|
|
1822
|
+
export function sandboxResourceLabels(dispatch, image, contract) {
|
|
1823
|
+
const resourceLeaseId = dispatch.sandbox.executionLeaseId;
|
|
1824
|
+
const ownershipIds = [
|
|
1825
|
+
["resource lease", resourceLeaseId],
|
|
1826
|
+
["dispatch attempt", dispatch.sandbox.dispatchAttemptId],
|
|
1827
|
+
["runner", dispatch.execution.runnerId],
|
|
1828
|
+
["runner instance", dispatch.sandbox.runnerInstanceId],
|
|
1829
|
+
["fencing token", dispatch.lease.fencingToken]
|
|
1830
|
+
];
|
|
1831
|
+
const invalid = ownershipIds.find(([, value]) => !isFullUuid(value));
|
|
1832
|
+
if (invalid)
|
|
1833
|
+
throw new Error(`sandbox_identity_invalid: ${invalid[0]} id must be a full UUID.`);
|
|
1834
|
+
return {
|
|
1835
|
+
"dev.forge.managed": "true", "dev.forge.session-id": dispatch.session.id, "dev.forge.resource-lease-id": resourceLeaseId, "dev.forge.execution-lease-id": resourceLeaseId,
|
|
1836
|
+
"dev.forge.dispatch-attempt-id": dispatch.sandbox.dispatchAttemptId, "dev.forge.fencing-token": dispatch.lease.fencingToken,
|
|
1837
|
+
"dev.forge.runner-id": dispatch.execution.runnerId ?? "", "dev.forge.runner-instance-id": dispatch.sandbox.runnerInstanceId,
|
|
1838
|
+
"dev.forge.project": dispatch.repo.name.slice(0, 120), "dev.forge.image-digest": image, "dev.forge.contract-digest": contract,
|
|
1839
|
+
"dev.forge.initial-expires-at": dispatch.lease.expiresAt
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
function labelArgs(labels) { return Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]); }
|
|
1843
|
+
function labelsMatch(actual, expected) { return Object.entries(expected).every(([key, value]) => actual[key] === value); }
|
|
1844
|
+
export function mayReapManagedResource(labels, runnerId, liveAttempts) {
|
|
1845
|
+
const attemptId = labels["dev.forge.dispatch-attempt-id"];
|
|
1846
|
+
const resourceLeaseId = labels["dev.forge.resource-lease-id"];
|
|
1847
|
+
return labels["dev.forge.managed"] === "true"
|
|
1848
|
+
&& labels["dev.forge.runner-id"] === runnerId
|
|
1849
|
+
&& isFullUuid(resourceLeaseId)
|
|
1850
|
+
&& labels["dev.forge.execution-lease-id"] === resourceLeaseId
|
|
1851
|
+
&& isFullUuid(attemptId)
|
|
1852
|
+
&& isFullUuid(labels["dev.forge.runner-instance-id"])
|
|
1853
|
+
&& isFullUuid(labels["dev.forge.fencing-token"])
|
|
1854
|
+
&& !liveAttempts.has(attemptId);
|
|
1855
|
+
}
|
|
1856
|
+
function isFullUuid(value) {
|
|
1857
|
+
return typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu.test(value);
|
|
1858
|
+
}
|
|
1859
|
+
export function shouldReapSandboxLedger(ledger, currentRunnerInstanceId, serverAttempt, snapshotAvailable, nowMs = Date.now()) {
|
|
1860
|
+
const lease = "runtimeLease" in ledger ? ledger.runtimeLease : ledger;
|
|
1861
|
+
if (lease.runnerInstanceId !== currentRunnerInstanceId)
|
|
1862
|
+
return true;
|
|
1863
|
+
if (!snapshotAvailable)
|
|
1864
|
+
return Date.parse(lease.expiresAt) <= nowMs;
|
|
1865
|
+
return !serverAttempt
|
|
1866
|
+
|| serverAttempt.dispatchAttemptId !== lease.dispatchAttemptId
|
|
1867
|
+
|| serverAttempt.fencingToken !== lease.fencingToken
|
|
1868
|
+
|| serverAttempt.runnerInstanceId !== lease.runnerInstanceId
|
|
1869
|
+
|| Date.parse(serverAttempt.expiresAt) <= nowMs;
|
|
1870
|
+
}
|
|
1871
|
+
function dockerLabelValue(value) { return value.replace(/[\r\n\0]/gu, " ").trim().slice(0, 120) || "unknown"; }
|
|
1872
|
+
function imageLabelArgs(project, key, kind, bridgeRuntimeDigest) {
|
|
1873
|
+
return [
|
|
1874
|
+
"--label", managedLabel,
|
|
1875
|
+
"--label", `dev.forge.project=${dockerLabelValue(project)}`,
|
|
1876
|
+
"--label", `dev.forge.image-key=${key}`,
|
|
1877
|
+
"--label", `dev.forge.image-kind=${kind}`,
|
|
1878
|
+
"--label", `${bridgeRuntimeDigestLabel}=${bridgeRuntimeDigest}`
|
|
1879
|
+
];
|
|
1880
|
+
}
|
|
1881
|
+
export function bridgeRuntimeCacheMatches(record, expectedDigest, imageLabels) {
|
|
1882
|
+
return /^[0-9a-f]{64}$/i.test(expectedDigest)
|
|
1883
|
+
&& record?.bridgeRuntimeDigest === expectedDigest
|
|
1884
|
+
&& imageLabels.length > 0
|
|
1885
|
+
&& imageLabels.every(labels => labels?.[bridgeRuntimeDigestLabel] === expectedDigest);
|
|
1886
|
+
}
|
|
1887
|
+
function validImageCacheRecord(value) {
|
|
1888
|
+
return value?.schemaVersion === 1
|
|
1889
|
+
&& /^[0-9a-f]{64}$/i.test(value.key)
|
|
1890
|
+
&& typeof value.project === "string"
|
|
1891
|
+
&& Number.isFinite(Date.parse(value.createdAt))
|
|
1892
|
+
&& Number.isFinite(Date.parse(value.lastUsedAt))
|
|
1893
|
+
&& [value.projectTag, value.helperTag, value.overlayTag].every(tag => /^forge\/(?:project|tooling|agent):[0-9a-f]{64}$/i.test(tag))
|
|
1894
|
+
&& /^sha256:[0-9a-f]{64}$/i.test(value.imageDigest)
|
|
1895
|
+
&& /^sha256:[0-9a-f]{64}$/i.test(value.helperImageDigest)
|
|
1896
|
+
&& (value.bridgeRuntimeDigest === undefined || /^[0-9a-f]{64}$/i.test(value.bridgeRuntimeDigest))
|
|
1897
|
+
&& /^[0-9a-f]{64}$/i.test(value.policyDigest);
|
|
1898
|
+
}
|
|
1899
|
+
export async function prepareOverlayBuildRoot(overlayRoot) {
|
|
1900
|
+
const buildRoot = path.dirname(overlayRoot);
|
|
1901
|
+
await fs.mkdir(buildRoot, { recursive: true, mode: 0o700 });
|
|
1902
|
+
await fs.chmod(buildRoot, 0o700);
|
|
1903
|
+
await fs.rm(overlayRoot, { recursive: true, force: true });
|
|
1904
|
+
await fs.mkdir(overlayRoot, { recursive: true, mode: 0o700 });
|
|
1905
|
+
await fs.chmod(overlayRoot, 0o700);
|
|
1906
|
+
const bridgeRoot = path.join(overlayRoot, "bridge");
|
|
1907
|
+
await fs.mkdir(bridgeRoot, { recursive: true, mode: 0o700 });
|
|
1908
|
+
await fs.chmod(bridgeRoot, 0o700);
|
|
1909
|
+
}
|
|
1910
|
+
export async function extractOciLayoutArchive(archivePath, destination, signal) {
|
|
1911
|
+
await fs.rm(destination, { recursive: true, force: true });
|
|
1912
|
+
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
|
1913
|
+
await fs.chmod(destination, 0o700);
|
|
1914
|
+
const result = await runSandboxProcess({
|
|
1915
|
+
command: process.platform === "win32" ? "tar.exe" : "tar",
|
|
1916
|
+
args: ["-xf", archivePath, "-C", destination],
|
|
1917
|
+
timeoutMs: 5 * 60_000,
|
|
1918
|
+
maxOutputBytes: 1024 * 1024,
|
|
1919
|
+
signal
|
|
1920
|
+
});
|
|
1921
|
+
if (result.exitCode !== 0 || result.truncated) {
|
|
1922
|
+
throw new Error(`sandbox_build_failed: project OCI layout extraction failed with exit code ${result.exitCode}; ${formatOciExtractionFailure(result)}. Archive retained at ${archivePath}.`);
|
|
1923
|
+
}
|
|
1924
|
+
await fs.rm(archivePath, { force: true }).catch(() => undefined);
|
|
1925
|
+
}
|
|
1926
|
+
export function ociLayoutContextRef(layoutPath) {
|
|
1927
|
+
if (/^[A-Za-z]:[\\/]/u.test(layoutPath)) {
|
|
1928
|
+
throw new Error("sandbox_build_failed: Windows absolute OCI layout paths are not BuildKit-safe; use a relative path from the build working directory.");
|
|
1929
|
+
}
|
|
1930
|
+
const normalized = layoutPath.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
1931
|
+
// BuildKit's oci-layout resolver expects a raw filesystem path here; it does not percent-decode
|
|
1932
|
+
// URI path escapes such as %20 before opening index.json. On Windows, absolute drive paths
|
|
1933
|
+
// such as D:/state/project-oci are parsed as OCI layout ref/tag syntax, so callers should
|
|
1934
|
+
// run buildx from the layout parent and pass a relative path (for example project-oci).
|
|
1935
|
+
return `oci-layout://${normalized}`;
|
|
1936
|
+
}
|
|
1937
|
+
export function formatOciExtractionFailure(result) {
|
|
1938
|
+
const output = result.stderr.trim() || result.stdout.trim() || "no tar output";
|
|
1939
|
+
return `tar output: ${output.slice(0, 2048)}${result.truncated ? " (output truncated)" : ""}`;
|
|
1940
|
+
}
|
|
1941
|
+
export async function readOciLayoutImageDigest(layoutRoot) {
|
|
1942
|
+
const indexPath = path.join(layoutRoot, "index.json");
|
|
1943
|
+
const index = JSON.parse(await fs.readFile(indexPath, "utf8"));
|
|
1944
|
+
const manifest = index.manifests?.find(entry => typeof entry.digest === "string"
|
|
1945
|
+
&& /^sha256:[0-9a-f]{64}$/iu.test(entry.digest)
|
|
1946
|
+
&& entry.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest");
|
|
1947
|
+
if (!manifest || typeof manifest.digest !== "string") {
|
|
1948
|
+
throw new Error("sandbox_build_failed: project OCI layout did not expose an immutable image digest.");
|
|
1949
|
+
}
|
|
1950
|
+
return manifest.digest;
|
|
1951
|
+
}
|
|
1952
|
+
export async function removeExtractedOciLayout(layoutRoot) {
|
|
1953
|
+
await fs.rm(layoutRoot, { recursive: true, force: true });
|
|
1954
|
+
}
|
|
1955
|
+
export function ociLayoutScanTarget(layoutPath) {
|
|
1956
|
+
const absolute = /^[A-Za-z]:[\\/]/u.test(layoutPath) ? layoutPath : path.resolve(layoutPath);
|
|
1957
|
+
return `oci-dir:${absolute.replace(/\\/gu, "/")}`;
|
|
1958
|
+
}
|
|
1959
|
+
export function agentOverlayDockerignore() {
|
|
1960
|
+
return "project.oci.tar\nproject-oci\nproject-oci/**\n";
|
|
1961
|
+
}
|
|
1962
|
+
export function sandboxImageCacheKey(input) {
|
|
1963
|
+
return createHash("sha256").update([
|
|
1964
|
+
input.contextDigest,
|
|
1965
|
+
input.contractDigest,
|
|
1966
|
+
input.platform,
|
|
1967
|
+
input.architecture,
|
|
1968
|
+
input.bridgeVersion,
|
|
1969
|
+
input.bridgeRuntimeDigest,
|
|
1970
|
+
input.toolingImage,
|
|
1971
|
+
input.codexVersion,
|
|
1972
|
+
input.policy,
|
|
1973
|
+
input.policyConfigurationDigest
|
|
1974
|
+
].join("\0")).digest("hex");
|
|
1975
|
+
}
|
|
1976
|
+
export async function bridgeRuntimeContentDigest(packageRoot) {
|
|
1977
|
+
const root = path.resolve(packageRoot);
|
|
1978
|
+
const packageManifest = await requireBridgeRuntimeFile(root, "package.json");
|
|
1979
|
+
const lockManifest = await optionalBridgeRuntimeFile(root, "package-lock.json");
|
|
1980
|
+
const distRoot = path.join(root, "dist");
|
|
1981
|
+
const distStat = await fs.lstat(distRoot).catch(() => null);
|
|
1982
|
+
if (!distStat?.isDirectory() || distStat.isSymbolicLink()) {
|
|
1983
|
+
throw new Error("sandbox_build_failed: bridge runtime dist must be a real directory.");
|
|
1984
|
+
}
|
|
1985
|
+
const distFiles = await collectBridgeRuntimeFiles(root, "dist");
|
|
1986
|
+
if (distFiles.length === 0) {
|
|
1987
|
+
throw new Error("sandbox_build_failed: bridge runtime dist is empty.");
|
|
1988
|
+
}
|
|
1989
|
+
const files = [packageManifest, ...(lockManifest ? [lockManifest] : []), ...distFiles]
|
|
1990
|
+
.sort((left, right) => compareBridgeRuntimePaths(left.relativePath, right.relativePath));
|
|
1991
|
+
const digest = createHash("sha256");
|
|
1992
|
+
digest.update("forge-bridge-runtime-v1\0");
|
|
1993
|
+
digest.update(`${files.length}\0`);
|
|
1994
|
+
for (const file of files) {
|
|
1995
|
+
const contents = await fs.readFile(file.absolutePath);
|
|
1996
|
+
digest.update(`${Buffer.byteLength(file.relativePath, "utf8")}:${file.relativePath}\0`);
|
|
1997
|
+
digest.update(`${contents.length}:`);
|
|
1998
|
+
digest.update(contents);
|
|
1999
|
+
digest.update("\0");
|
|
2000
|
+
}
|
|
2001
|
+
return digest.digest("hex");
|
|
2002
|
+
}
|
|
2003
|
+
async function requireBridgeRuntimeFile(root, relativePath) {
|
|
2004
|
+
const file = await optionalBridgeRuntimeFile(root, relativePath);
|
|
2005
|
+
if (!file)
|
|
2006
|
+
throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} is missing.`);
|
|
2007
|
+
return file;
|
|
2008
|
+
}
|
|
2009
|
+
async function optionalBridgeRuntimeFile(root, relativePath) {
|
|
2010
|
+
const absolutePath = path.join(root, relativePath);
|
|
2011
|
+
const stat = await fs.lstat(absolutePath).catch(() => null);
|
|
2012
|
+
if (!stat)
|
|
2013
|
+
return null;
|
|
2014
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
2015
|
+
throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} must be a regular file.`);
|
|
2016
|
+
}
|
|
2017
|
+
return { absolutePath, relativePath: relativePath.replace(/\\/gu, "/") };
|
|
2018
|
+
}
|
|
2019
|
+
async function collectBridgeRuntimeFiles(root, relativeDirectory) {
|
|
2020
|
+
const absoluteDirectory = path.join(root, relativeDirectory);
|
|
2021
|
+
const entries = (await fs.readdir(absoluteDirectory, { withFileTypes: true }))
|
|
2022
|
+
.sort((left, right) => compareBridgeRuntimePaths(left.name, right.name));
|
|
2023
|
+
const files = [];
|
|
2024
|
+
for (const entry of entries) {
|
|
2025
|
+
const relativePath = path.join(relativeDirectory, entry.name);
|
|
2026
|
+
if (entry.isSymbolicLink()) {
|
|
2027
|
+
throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} cannot be a symbolic link.`);
|
|
2028
|
+
}
|
|
2029
|
+
if (entry.isDirectory()) {
|
|
2030
|
+
files.push(...await collectBridgeRuntimeFiles(root, relativePath));
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if (!entry.isFile()) {
|
|
2034
|
+
throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} must be a regular file.`);
|
|
2035
|
+
}
|
|
2036
|
+
files.push({
|
|
2037
|
+
absolutePath: path.join(root, relativePath),
|
|
2038
|
+
relativePath: relativePath.replace(/\\/gu, "/")
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
return files;
|
|
2042
|
+
}
|
|
2043
|
+
function compareBridgeRuntimePaths(left, right) {
|
|
2044
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2045
|
+
}
|
|
2046
|
+
export function agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion) {
|
|
2047
|
+
return `FROM ${toolingImage} AS tooling
|
|
2048
|
+
WORKDIR /opt/forge-bridge
|
|
2049
|
+
COPY bridge/package*.json ./
|
|
2050
|
+
RUN ${bridgePackageInstallCommand(installMode, codexVersion)}
|
|
2051
|
+
COPY bridge/dist ./dist
|
|
2052
|
+
FROM ${projectContextName}
|
|
2053
|
+
USER 0
|
|
2054
|
+
COPY --from=tooling /usr/local/bin/node /usr/local/bin/node
|
|
2055
|
+
COPY --from=tooling /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
|
|
2056
|
+
COPY --from=tooling /opt/forge-bridge /opt/forge-bridge
|
|
2057
|
+
RUN ln -sfn ../lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \
|
|
2058
|
+
&& (getent group 10001 || groupadd -g 10001 forge) \
|
|
2059
|
+
&& (getent passwd 10001 || useradd -u 10001 -g 10001 -d /home/forge -m forge)
|
|
2060
|
+
ENV HOME=/home/forge TMPDIR=/tmp
|
|
2061
|
+
ENTRYPOINT []
|
|
2062
|
+
CMD []
|
|
2063
|
+
USER 10001:10001
|
|
2064
|
+
`;
|
|
2065
|
+
}
|
|
2066
|
+
export async function copyBridgePackageManifests(packageRoot, bridgeRoot) {
|
|
2067
|
+
await fs.copyFile(path.join(packageRoot, "package.json"), path.join(bridgeRoot, "package.json"));
|
|
2068
|
+
return await copyOptionalBridgeManifest(packageRoot, bridgeRoot, "package-lock.json") ? "ci" : "install";
|
|
2069
|
+
}
|
|
2070
|
+
export function bridgePackageInstallCommand(mode, codexVersion) {
|
|
2071
|
+
const bridgeInstall = mode === "ci"
|
|
2072
|
+
? "npm ci --omit=dev --ignore-scripts"
|
|
2073
|
+
: "npm install --omit=dev --ignore-scripts";
|
|
2074
|
+
return `${bridgeInstall} && npm install --global @openai/codex@${codexVersion} && npm cache clean --force`;
|
|
2075
|
+
}
|
|
2076
|
+
async function copyOptionalBridgeManifest(packageRoot, bridgeRoot, fileName) {
|
|
2077
|
+
try {
|
|
2078
|
+
await fs.copyFile(path.join(packageRoot, fileName), path.join(bridgeRoot, fileName));
|
|
2079
|
+
return true;
|
|
2080
|
+
}
|
|
2081
|
+
catch (error) {
|
|
2082
|
+
if (error.code === "ENOENT")
|
|
2083
|
+
return false;
|
|
2084
|
+
throw error;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
export function buildxBuildArgs(builder, metadataFile, tail, environment = process.env, options = {}) {
|
|
2088
|
+
if (!/^forge-[0-9a-f]{20}$/u.test(builder))
|
|
2089
|
+
throw new Error("sandbox_build_failed: invalid dedicated builder name.");
|
|
2090
|
+
const resources = buildResourcePolicy(environment);
|
|
2091
|
+
const args = ["buildx", "build", "--builder", builder];
|
|
2092
|
+
if (options.load !== false)
|
|
2093
|
+
args.push("--load");
|
|
2094
|
+
args.push("--ulimit", `nofile=${resources.nofile}:${resources.nofile}`, "--shm-size", resources.sharedMemory, "--provenance=mode=max", "--sbom=true", "--metadata-file", metadataFile, ...tail);
|
|
2095
|
+
return args;
|
|
2096
|
+
}
|
|
2097
|
+
export function buildResourcePolicy(environment = process.env) {
|
|
2098
|
+
return {
|
|
2099
|
+
nofile: boundedInteger(environment.FORGE_SANDBOX_BUILD_NOFILE, 4_096, 256, 65_536),
|
|
2100
|
+
sharedMemory: "256m",
|
|
2101
|
+
builderPidsLimit: boundedInteger(environment.FORGE_SANDBOX_BUILDER_PIDS_LIMIT, 512, 128, 4_096),
|
|
2102
|
+
builderMemoryBytes: boundedInteger(environment.FORGE_SANDBOX_BUILDER_MEMORY_BYTES, 3 * 1024 ** 3, 512 * 1024 ** 2, 16 * 1024 ** 3)
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
export function dedicatedBuildxBuilderName(runnerId, dockerContext) {
|
|
2106
|
+
const identity = `${runnerId}\0${dockerContext || "default"}`;
|
|
2107
|
+
return `forge-${createHash("sha256").update(identity).digest("hex").slice(0, 20)}`;
|
|
2108
|
+
}
|
|
2109
|
+
export function dedicatedBuildxBuilderContainerName(builder) {
|
|
2110
|
+
if (!/^forge-[0-9a-f]{20}$/u.test(builder))
|
|
2111
|
+
throw new Error("sandbox_build_failed: invalid dedicated builder name.");
|
|
2112
|
+
return `buildx_buildkit_${builder}0`;
|
|
2113
|
+
}
|
|
2114
|
+
function boundedBuildCacheSize(value, fallback) {
|
|
2115
|
+
const normalized = value?.trim().toLowerCase();
|
|
2116
|
+
const match = /^(\d+)(gb|mb)$/u.exec(normalized ?? "");
|
|
2117
|
+
if (!match)
|
|
2118
|
+
return fallback;
|
|
2119
|
+
const bytes = Number(match[1]) * (match[2] === "gb" ? 1024 ** 3 : 1024 ** 2);
|
|
2120
|
+
return bytes >= 512 * 1024 ** 2 && bytes <= 200 * 1024 ** 3 ? normalized : fallback;
|
|
2121
|
+
}
|
|
2122
|
+
export function selectImageGcCandidates(records, keepPerProject, ttlMs, nowMs) {
|
|
2123
|
+
const groups = new Map();
|
|
2124
|
+
for (const record of records)
|
|
2125
|
+
groups.set(record.project, [...(groups.get(record.project) ?? []), record]);
|
|
2126
|
+
const cutoff = nowMs - ttlMs;
|
|
2127
|
+
return [...groups.values()].flatMap(group => group
|
|
2128
|
+
.sort((left, right) => Date.parse(right.lastUsedAt) - Date.parse(left.lastUsedAt))
|
|
2129
|
+
.slice(Math.max(1, keepPerProject))
|
|
2130
|
+
.filter(record => Date.parse(record.lastUsedAt) <= cutoff));
|
|
2131
|
+
}
|
|
2132
|
+
async function sandboxProviderAuthForDispatch(dispatch) {
|
|
2133
|
+
const unavailable = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
|
|
2134
|
+
if (unavailable) {
|
|
2135
|
+
throw new Error(`sandbox_auth_unavailable: ${unavailable}`);
|
|
2136
|
+
}
|
|
2137
|
+
const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
|
|
2138
|
+
return adapter ? adapter.prepareAttemptAuth() : null;
|
|
2139
|
+
}
|
|
2140
|
+
export function workerCreateArgs(dispatch, contract, resources, labels, image, securityOptions = resolveSandboxSecurityOptions(dispatch.sandbox.executionPolicy)) {
|
|
2141
|
+
const r = dispatch.sandbox.requestedResources;
|
|
2142
|
+
return ["create", "--interactive", "--name", resources.container, ...labelArgs(labels), "--network", resources.network, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", ...securityOptions, "--pids-limit", String(r.pids), "--memory", String(r.workerMemoryLimitBytes), "--memory-swap", String(r.workerMemoryLimitBytes), "--cpus", String(r.cpuMillis / 1000), "--ulimit", "nofile=4096:4096", "--ulimit", "core=0", "--tmpfs", "/tmp:rw,nosuid,nodev,size=1g,uid=10001,gid=10001", "--tmpfs", "/run/forge:rw,nosuid,nodev,noexec,size=32m,uid=10001,gid=10001", "--user", "10001:10001", "--workdir", contract.contract.workingDirectory, "--init", "--stop-timeout", "10", "--entrypoint", "node", "--log-driver", "none", "--mount", `type=volume,src=${resources.workspaceVolume},dst=/workspace,volume-nocopy`, "--volume", `${resources.controlVolume}:/control`, "--volume", `${resources.secretsVolume}:/run/forge-secrets:ro`, ...providerAuthVolumeArgs(dispatch, resources), ...cacheVolumeArgs(contract, resources), "--env", `FORGE_SESSION_ID=${dispatch.session.id}`, "--env", `FORGE_DISPATCH_ATTEMPT_ID=${dispatch.sandbox.dispatchAttemptId}`, "--env", `FORGE_RESOURCE_LEASE_ID=${dispatch.sandbox.executionLeaseId}`, "--env", `FORGE_EXECUTION_LEASE_ID=${dispatch.sandbox.executionLeaseId}`, "--env", `FORGE_FENCING_TOKEN=${dispatch.lease.fencingToken}`, "--env", "FORGE_RUNTIME_MODE=docker", "--env", `FORGE_SANDBOX_ID=${dispatch.sandbox.dispatchAttemptId}`, "--env", "FORGE_WORKSPACE=/workspace", "--env", "FORGE_WORKTREE_PATH=/workspace", "--env", "FORGE_TMPDIR=/tmp", "--env", "FORGE_LOG_DIR=/control/output/logs", "--env", "FORGE_BROWSER_PROFILE_DIR=/tmp/forge-browser-profile", "--env", `FORGE_INPUT_COMMIT_SHA=${dispatch.sandbox.inputCommitSha}`, "--env", "FORGE_INPUT_BUNDLE=/control/input/repo.bundle", ...proxyEnvironmentArgs(dispatch, contract, resources), "--env", "HOME=/home/forge", "--env", "TMPDIR=/tmp", image, "/opt/forge-bridge/dist/sandbox/worker.js"];
|
|
2143
|
+
}
|
|
2144
|
+
export function validateSandboxMemoryReservation(dispatch, contract) {
|
|
2145
|
+
const requested = dispatch.sandbox.requestedResources;
|
|
2146
|
+
const requiredServiceMemoryBytes = contract.contract.services.length * 1024 ** 3;
|
|
2147
|
+
const requiredRuntimeOverheadBytes = 128 * 1024 ** 2
|
|
2148
|
+
+ (dispatch.sandbox.networkPolicy.mode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
|
|
2149
|
+
if (requested.serviceMemoryLimitBytes < requiredServiceMemoryBytes
|
|
2150
|
+
|| requested.runtimeOverheadBytes < requiredRuntimeOverheadBytes) {
|
|
2151
|
+
throw new Error(`sandbox_capacity_profile_mismatch: reserved ${requested.serviceMemoryLimitBytes} service bytes and ${requested.runtimeOverheadBytes} runtime overhead bytes; the contract requires at least ${requiredServiceMemoryBytes} service bytes and ${requiredRuntimeOverheadBytes} runtime overhead bytes.`);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
function providerAuthVolumeArgs(dispatch, resources) {
|
|
2155
|
+
const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
|
|
2156
|
+
if (!adapter) {
|
|
2157
|
+
return [];
|
|
2158
|
+
}
|
|
2159
|
+
return ["--volume", `${resources.credentialsVolume}:${adapter.containerMountPath}:${adapter.writable ? "rw" : "ro"}`];
|
|
2160
|
+
}
|
|
2161
|
+
export function cacheVolumeArgs(contract, resources) {
|
|
2162
|
+
const targets = {
|
|
2163
|
+
npm: "/home/forge/.npm", pnpm: "/home/forge/.local/share/pnpm/store", yarn: "/home/forge/.cache/yarn",
|
|
2164
|
+
nuget: "/home/forge/.nuget/packages", cargo: "/home/forge/.cargo/registry", pip: "/home/forge/.cache/pip"
|
|
2165
|
+
};
|
|
2166
|
+
return contract.contract.caches
|
|
2167
|
+
.filter(cache => cache.mode === "per_attempt")
|
|
2168
|
+
.flatMap(cache => ["--mount", `type=volume,src=${resources.cacheVolumes[cache.name]},dst=${targets[cache.name]},volume-nocopy`]);
|
|
2169
|
+
}
|
|
2170
|
+
export function proxyEnvironmentArgs(dispatch, contract, resources) {
|
|
2171
|
+
if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
|
|
2172
|
+
return [];
|
|
2173
|
+
const bypass = ["localhost", "127.0.0.1", "::1", ...contract.contract.services.map(service => `${resources.prefix}-${service.type}`)].join(",");
|
|
2174
|
+
return ["--env", "HTTP_PROXY=http://forge-egress:3128", "--env", "HTTPS_PROXY=http://forge-egress:3128", "--env", "ALL_PROXY=http://forge-egress:3128", "--env", `NO_PROXY=${bypass}`];
|
|
2175
|
+
}
|
|
2176
|
+
export function egressProxyConfig(allowedHosts) {
|
|
2177
|
+
const domains = allowedHosts.map(host => host.startsWith("*.") ? `.${host.slice(2)}` : host).join(" ");
|
|
2178
|
+
if (!domains)
|
|
2179
|
+
throw new Error("sandbox_egress_policy_invalid: public_allowlist requires at least one approved host.");
|
|
2180
|
+
return [
|
|
2181
|
+
"http_port 3128",
|
|
2182
|
+
"access_log none",
|
|
2183
|
+
"cache deny all",
|
|
2184
|
+
"acl SSL_ports port 443",
|
|
2185
|
+
"acl Safe_ports port 80 443",
|
|
2186
|
+
"acl CONNECT method CONNECT",
|
|
2187
|
+
"acl private_dst dst 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 ::1/128 fc00::/7 fe80::/10",
|
|
2188
|
+
`acl forge_allowed dstdomain ${domains}`,
|
|
2189
|
+
"http_access deny private_dst",
|
|
2190
|
+
"http_access deny !Safe_ports",
|
|
2191
|
+
"http_access deny CONNECT !SSL_ports",
|
|
2192
|
+
"http_access allow forge_allowed",
|
|
2193
|
+
"http_access deny all",
|
|
2194
|
+
"shutdown_lifetime 1 seconds",
|
|
2195
|
+
"visible_hostname forge-egress"
|
|
2196
|
+
].join("\n") + "\n";
|
|
2197
|
+
}
|
|
2198
|
+
export function serviceContainerArgs(service, image, resources, labels, securityOptions, environment = process.env) {
|
|
2199
|
+
const name = `${resources.prefix}-${service.type}`;
|
|
2200
|
+
const serviceUser = service.type === "browser" ? "10001:10001" : "999:999";
|
|
2201
|
+
const args = ["run", "--detach", "--name", name, ...labelArgs(labels), "--network", resources.network, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", ...securityOptions, "--user", serviceUser, "--init", "--stop-timeout", "10", "--pids-limit", "256", "--memory", "1g", "--memory-swap", "1g", "--cpus", "1", "--tmpfs", "/tmp:rw,nosuid,nodev,size=256m", "--log-driver", "none"];
|
|
2202
|
+
if (service.type === "postgres") {
|
|
2203
|
+
args.push("--env", "POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password", "--env", "POSTGRES_USER=forge", "--env", "POSTGRES_DB=forge_test", "--volume", `${resources.secretsVolume}:/run/secrets:ro`, "--tmpfs", "/var/lib/postgresql/data:rw,nosuid,nodev,size=1g", "--health-cmd", "pg_isready -U forge -d forge_test", "--health-interval", "1s", "--health-timeout", "5s", "--health-retries", "60");
|
|
2204
|
+
}
|
|
2205
|
+
else if (service.type === "redis") {
|
|
2206
|
+
args.push("--volume", `${resources.secretsVolume}:/run/secrets:ro`, "--health-cmd", "redis-cli -a \"$(cat /run/secrets/redis-password)\" ping", "--health-interval", "1s", "--health-timeout", "5s", "--health-retries", "60", "--entrypoint", "/bin/sh");
|
|
2207
|
+
}
|
|
2208
|
+
else {
|
|
2209
|
+
const healthCommand = environment.FORGE_SERVICE_BROWSER_HEALTH_CMD?.trim();
|
|
2210
|
+
if (!healthCommand || /[\r\n\0]/u.test(healthCommand) || healthCommand.length > 1000) {
|
|
2211
|
+
throw new Error("sandbox_service_health_policy_invalid: browser service needs an operator-configured bounded health command.");
|
|
2212
|
+
}
|
|
2213
|
+
args.push("--env", "FORGE_BROWSER_TOKEN_FILE=/run/secrets/browser-token", "--volume", `${resources.secretsVolume}:/run/secrets:ro`, "--health-cmd", healthCommand, "--health-interval", "1s", "--health-timeout", "5s", "--health-retries", "60");
|
|
2214
|
+
}
|
|
2215
|
+
args.push(image);
|
|
2216
|
+
if (service.type === "redis")
|
|
2217
|
+
args.push("-c", "exec redis-server --requirepass \"$(cat /run/secrets/redis-password)\"");
|
|
2218
|
+
return args;
|
|
2219
|
+
}
|
|
2220
|
+
export function resolveSandboxSecurityOptions(executionPolicy, environment = process.env) {
|
|
2221
|
+
const seccomp = environment.FORGE_SANDBOX_SECCOMP_PROFILE?.trim();
|
|
2222
|
+
const lsm = environment.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim();
|
|
2223
|
+
if (seccomp && (!path.isAbsolute(seccomp) || /[\r\n\0]/u.test(seccomp))) {
|
|
2224
|
+
throw new Error("sandbox_security_profile_invalid: seccomp profile must be an absolute host path.");
|
|
2225
|
+
}
|
|
2226
|
+
if (lsm && !/^(?:apparmor=[a-zA-Z0-9_.-]{1,128}|label=[a-zA-Z0-9_:,.-]{1,256})$/u.test(lsm)) {
|
|
2227
|
+
throw new Error("sandbox_security_profile_invalid: LSM security option is invalid.");
|
|
2228
|
+
}
|
|
2229
|
+
if (executionPolicy === "sandbox_required" && (!seccomp || !lsm)) {
|
|
2230
|
+
throw new Error("sandbox_security_profile_unavailable: sandbox_required needs explicit seccomp and LSM profiles.");
|
|
2231
|
+
}
|
|
2232
|
+
return [
|
|
2233
|
+
...(seccomp ? ["--security-opt", `seccomp=${seccomp}`] : []),
|
|
2234
|
+
...(lsm ? ["--security-opt", lsm] : [])
|
|
2235
|
+
];
|
|
2236
|
+
}
|
|
2237
|
+
export function networkCreateArgs(dispatch, environment = process.env, allowOpenPublicSandbox = environment.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true") {
|
|
2238
|
+
const policy = dispatch.sandbox.networkPolicy;
|
|
2239
|
+
if (policy.mode === "open_public") {
|
|
2240
|
+
if (dispatch.sandbox.executionPolicy === "sandbox_required" || !allowOpenPublicSandbox) {
|
|
2241
|
+
throw new Error("policy_mismatch: open_public sandbox networking is not permitted by this runner policy.");
|
|
2242
|
+
}
|
|
2243
|
+
return [];
|
|
2244
|
+
}
|
|
2245
|
+
if (policy.mode === "public_allowlist" && policy.allowedHosts.length > 0) {
|
|
2246
|
+
if (environment.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
|
|
2247
|
+
throw new Error("policy_mismatch: public_allowlist requires an operator-enforced Forge egress proxy or firewall.");
|
|
2248
|
+
}
|
|
2249
|
+
return ["--internal"];
|
|
2250
|
+
}
|
|
2251
|
+
return ["--internal"];
|
|
2252
|
+
}
|
|
2253
|
+
function withSandboxMetadata(dispatch, input, config, contract, image) { return { ...dispatch, repo: { ...dispatch.repo, serverLocalPath: null, remoteUrl: "/control/input/repo.bundle", cloneOnDemand: true }, serverLocal: { repoRoot: "/workspace", cleanupWorktrees: false }, sandbox: { ...dispatch.sandbox, inputCommitSha: input, sandboxConfigCommitSha: config, contractDigest: contract, imageDigest: image } }; }
|
|
2254
|
+
function serviceImage(type) { const key = `FORGE_SERVICE_${type.toUpperCase()}_IMAGE`; const image = process.env[key]; return image?.includes("@sha256:") ? image : null; }
|
|
2255
|
+
function event(eventType, content, data) {
|
|
2256
|
+
return {
|
|
2257
|
+
role: "runner",
|
|
2258
|
+
kind: /(?:failed|timeout|expired|exhausted|killed|rejected|invalid|unavailable|limit)/iu.test(eventType) ? "error" : "checkpoint",
|
|
2259
|
+
content,
|
|
2260
|
+
eventType,
|
|
2261
|
+
occurredAt: new Date().toISOString(),
|
|
2262
|
+
data: data ? JSON.stringify(data) : null
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
export function classifySandboxRuntimeFailure(value) {
|
|
2266
|
+
const message = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000) || "Sandbox execution failed.";
|
|
2267
|
+
const explicit = /^([a-z][a-z0-9_.-]{2,79}):/u.exec(message)?.[1] ?? null;
|
|
2268
|
+
let code = explicit ?? "sandbox_runtime_failed";
|
|
2269
|
+
if (/no space left|disk quota exceeded|enospc/iu.test(message))
|
|
2270
|
+
code = "sandbox_storage_exhausted";
|
|
2271
|
+
const phase = code.startsWith("sandbox_build") || code.startsWith("image_") ? "build"
|
|
2272
|
+
: code.startsWith("sandbox_output") || code.startsWith("sandbox_import") ? "import"
|
|
2273
|
+
: code.startsWith("teardown") ? "teardown"
|
|
2274
|
+
: code.startsWith("sandbox_config") || code.startsWith("sandbox_auth") || code.startsWith("contract_") || code.startsWith("sandbox_input") || code.startsWith("sandbox_create") ? "prepare"
|
|
2275
|
+
: "agent";
|
|
2276
|
+
return { code, phase, message };
|
|
2277
|
+
}
|
|
2278
|
+
export function classifySandboxResourceFailure(value) {
|
|
2279
|
+
const diagnostic = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000);
|
|
2280
|
+
if (!diagnostic)
|
|
2281
|
+
return null;
|
|
2282
|
+
if (/(?:oomkilled|out of memory|memory cgroup out of memory|cannot allocate memory)/iu.test(diagnostic)) {
|
|
2283
|
+
return { code: "sandbox_oom_killed", message: "Sandbox exceeded its memory limit." };
|
|
2284
|
+
}
|
|
2285
|
+
if (/(?:resource temporarily unavailable|cannot fork|fork:\s*(?:retry|resource)|pthread_create[^.]*resource|process limit|pids? limit)/iu.test(diagnostic)) {
|
|
2286
|
+
return { code: "sandbox_pid_exhausted", message: "Sandbox exceeded its process/PID limit." };
|
|
2287
|
+
}
|
|
2288
|
+
if (/(?:no space left on device|disk quota exceeded|\benospc\b|workspace quota|storage limit)/iu.test(diagnostic)) {
|
|
2289
|
+
return { code: "sandbox_storage_exhausted", message: "Sandbox exceeded its workspace/storage limit." };
|
|
2290
|
+
}
|
|
2291
|
+
if (/(?:too many open files|\bemfile\b|\benfile\b)/iu.test(diagnostic)) {
|
|
2292
|
+
return { code: "sandbox_file_descriptor_exhausted", message: "Sandbox exceeded its file-descriptor limit." };
|
|
2293
|
+
}
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
function parseSandboxEventLine(line) {
|
|
2297
|
+
if (!line.trim().startsWith("{"))
|
|
2298
|
+
return null;
|
|
2299
|
+
let value;
|
|
2300
|
+
try {
|
|
2301
|
+
value = JSON.parse(line);
|
|
2302
|
+
}
|
|
2303
|
+
catch {
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
if (!value || typeof value !== "object" || value.type !== "runner_event")
|
|
2307
|
+
return null;
|
|
2308
|
+
const candidate = value.event;
|
|
2309
|
+
if (!candidate || typeof candidate !== "object")
|
|
2310
|
+
throw new Error("sandbox_event_invalid: runner event envelope is missing event data.");
|
|
2311
|
+
const eventValue = candidate;
|
|
2312
|
+
for (const field of ["role", "kind", "content", "eventType"]) {
|
|
2313
|
+
if (typeof eventValue[field] !== "string")
|
|
2314
|
+
throw new Error(`sandbox_event_invalid: ${field} must be a string.`);
|
|
2315
|
+
}
|
|
2316
|
+
if (eventValue.role.length > 64
|
|
2317
|
+
|| eventValue.kind.length > 128
|
|
2318
|
+
|| eventValue.eventType.length > 128
|
|
2319
|
+
|| Buffer.byteLength(eventValue.content, "utf8") > 256 * 1024) {
|
|
2320
|
+
throw new Error("sandbox_event_invalid: runner event exceeds field bounds.");
|
|
2321
|
+
}
|
|
2322
|
+
if (eventValue.data !== undefined && eventValue.data !== null
|
|
2323
|
+
&& (typeof eventValue.data !== "string" || Buffer.byteLength(eventValue.data, "utf8") > 256 * 1024)) {
|
|
2324
|
+
throw new Error("sandbox_event_invalid: runner event data exceeds field bounds.");
|
|
2325
|
+
}
|
|
2326
|
+
if (eventValue.occurredAt !== undefined && eventValue.occurredAt !== null
|
|
2327
|
+
&& (typeof eventValue.occurredAt !== "string" || !Number.isFinite(Date.parse(eventValue.occurredAt)))) {
|
|
2328
|
+
throw new Error("sandbox_event_invalid: runner event occurredAt is invalid.");
|
|
2329
|
+
}
|
|
2330
|
+
return {
|
|
2331
|
+
role: eventValue.role,
|
|
2332
|
+
kind: eventValue.kind,
|
|
2333
|
+
content: eventValue.content,
|
|
2334
|
+
eventType: eventValue.eventType,
|
|
2335
|
+
occurredAt: eventValue.occurredAt,
|
|
2336
|
+
data: eventValue.data
|
|
2337
|
+
};
|
|
2338
|
+
}
|