@loucompanion/forge-bridge 0.1.3-dev.bd0c67bbf352 → 0.1.3-dev.c24058d57ab3

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.
Files changed (88) hide show
  1. package/README.md +44 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +36 -2
  4. package/dist/bridge/bridge.service.js +588 -18
  5. package/dist/bridge/bridge.types.d.ts +114 -1
  6. package/dist/cli/cli.help.d.ts +2 -0
  7. package/dist/cli/cli.help.js +183 -0
  8. package/dist/cli/cli.service.d.ts +12 -0
  9. package/dist/cli/cli.service.js +176 -184
  10. package/dist/cli/local-config.d.ts +2 -0
  11. package/dist/cli/local-config.js +6 -2
  12. package/dist/sandbox/command-runner.d.ts +4 -0
  13. package/dist/sandbox/command-runner.js +47 -0
  14. package/dist/sandbox/contract.d.ts +5 -0
  15. package/dist/sandbox/contract.js +276 -0
  16. package/dist/sandbox/event-budget.d.ts +11 -0
  17. package/dist/sandbox/event-budget.js +27 -0
  18. package/dist/sandbox/image-policy.d.ts +35 -0
  19. package/dist/sandbox/image-policy.js +247 -0
  20. package/dist/sandbox/index.d.ts +5 -0
  21. package/dist/sandbox/index.js +5 -0
  22. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  23. package/dist/sandbox/lease-watchdog.js +67 -0
  24. package/dist/sandbox/preflight.d.ts +36 -0
  25. package/dist/sandbox/preflight.js +239 -0
  26. package/dist/sandbox/process.d.ts +20 -0
  27. package/dist/sandbox/process.js +124 -0
  28. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  29. package/dist/sandbox/provider-auth/codex.js +212 -0
  30. package/dist/sandbox/repository-lock.d.ts +5 -0
  31. package/dist/sandbox/repository-lock.js +83 -0
  32. package/dist/sandbox/runtime.d.ts +234 -0
  33. package/dist/sandbox/runtime.js +1767 -0
  34. package/dist/sandbox/sandbox.types.d.ts +83 -0
  35. package/dist/sandbox/sandbox.types.js +1 -0
  36. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  37. package/dist/sandbox/worker-supervisor.js +36 -0
  38. package/dist/sandbox/worker.d.ts +1 -0
  39. package/dist/sandbox/worker.js +228 -0
  40. package/dist/sandbox/workspace.service.d.ts +12 -0
  41. package/dist/sandbox/workspace.service.js +49 -0
  42. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  43. package/dist/session/phase-completion.artifacts.js +620 -0
  44. package/dist/session/phase-completion.d.ts +32 -44
  45. package/dist/session/phase-completion.helpers.d.ts +112 -0
  46. package/dist/session/phase-completion.helpers.js +660 -0
  47. package/dist/session/phase-completion.js +28 -3501
  48. package/dist/session/phase-completion.prompts.d.ts +14 -0
  49. package/dist/session/phase-completion.prompts.js +220 -0
  50. package/dist/session/phase-completion.runtime.d.ts +36 -0
  51. package/dist/session/phase-completion.runtime.js +609 -0
  52. package/dist/session/phase-completion.schemas.d.ts +3 -0
  53. package/dist/session/phase-completion.schemas.js +707 -0
  54. package/dist/session/phase-completion.validation.d.ts +34 -0
  55. package/dist/session/phase-completion.validation.js +724 -0
  56. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  57. package/dist/session/provider-cli/provider-cli.artifacts.js +754 -0
  58. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  59. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  60. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  61. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  62. package/dist/session/provider-cli/provider-cli.deterministic.js +329 -0
  63. package/dist/session/provider-cli/provider-cli.process.d.ts +32 -0
  64. package/dist/session/provider-cli/provider-cli.process.js +287 -0
  65. package/dist/session/provider-cli/provider-cli.prompts.d.ts +44 -0
  66. package/dist/session/provider-cli/provider-cli.prompts.js +528 -0
  67. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  68. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +65 -0
  69. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  70. package/dist/session/provider-cli/provider-cli.runtime.js +40 -0
  71. package/dist/session/provider-cli/provider-cli.service.d.ts +9 -53
  72. package/dist/session/provider-cli/provider-cli.service.js +161 -2561
  73. package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
  74. package/dist/session/provider-cli/provider-output-state.d.ts +20 -0
  75. package/dist/session/provider-cli/provider-output-state.js +445 -0
  76. package/dist/session/session.service.js +34 -24
  77. package/dist/session/session.types.d.ts +89 -1
  78. package/dist/session/session.types.js +209 -3
  79. package/dist/shared/process/windows-launch.d.ts +8 -0
  80. package/dist/shared/process/windows-launch.js +41 -0
  81. package/dist/shared/provider-capabilities.d.ts +1 -0
  82. package/dist/shared/provider-capabilities.js +3 -0
  83. package/dist/shared/redaction/redaction.js +4 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/dist/worktree/dependency-warmer.d.ts +11 -0
  87. package/dist/worktree/dependency-warmer.js +45 -1
  88. package/package.json +4 -2
@@ -0,0 +1,1767 @@
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 { sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "./provider-auth/codex.js";
14
+ const managedLabel = "dev.forge.managed=true";
15
+ export class DockerSandboxRuntime {
16
+ config;
17
+ publicationGrantRedeemer;
18
+ active = new Map();
19
+ degraded = new Set();
20
+ builds = new Map();
21
+ verifiedCatalogImages = new Set();
22
+ buildTail = Promise.resolve();
23
+ constructor(config, publicationGrantRedeemer) {
24
+ this.config = config;
25
+ this.publicationGrantRedeemer = publicationGrantRedeemer;
26
+ }
27
+ async runOnce(dispatch, sink, signal) {
28
+ if (!dispatch.sandbox || dispatch.sandbox.executionMode !== "docker" || dispatch.sandbox.runtime !== "docker") {
29
+ throw new Error("Docker runtime requires a protocol-v2 Docker sandbox dispatch.");
30
+ }
31
+ const controller = new AbortController();
32
+ const abort = () => controller.abort(signal?.reason ?? "sandbox cancelled");
33
+ signal?.addEventListener("abort", abort, { once: true });
34
+ const active = {
35
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
36
+ fencingToken: dispatch.lease.fencingToken,
37
+ expiresAt: dispatch.lease.expiresAt,
38
+ controller,
39
+ phase: "starting",
40
+ ledger: null,
41
+ leaseControlKey: randomBytes(32).toString("base64url")
42
+ };
43
+ this.active.set(active.dispatchAttemptId, active);
44
+ const watchdog = setInterval(() => {
45
+ if (Date.parse(active.expiresAt) <= Date.now() && !controller.signal.aborted) {
46
+ controller.abort("dispatch lease expired");
47
+ }
48
+ }, 500);
49
+ watchdog.unref?.();
50
+ const runtimeSignal = controller.signal;
51
+ try {
52
+ const identity = identityFor(dispatch);
53
+ const attemptRoot = path.join(this.config.sandboxStateRoot, "attempts", identity.attemptHex);
54
+ await fs.mkdir(path.join(attemptRoot, "input"), { recursive: true, mode: 0o700 });
55
+ await fs.mkdir(path.join(attemptRoot, "output"), { recursive: true, mode: 0o700 });
56
+ const repoPath = dispatch.repo.serverLocalPath ? path.resolve(dispatch.repo.serverLocalPath) : null;
57
+ if (!repoPath)
58
+ throw new Error("sandbox_input_failed: bridge repository mapping is required for bundle handoff.");
59
+ await sink.emit(event("sandbox_preparing", `Preparing Docker sandbox attempt ${dispatch.sandbox.dispatchAttemptId}.`));
60
+ const git = new HostGit(this.config, repoPath);
61
+ const prepared = await git.prepare(dispatch, attemptRoot, runtimeSignal);
62
+ const validated = validateSandboxContract(prepared.contractSource);
63
+ validateDockerfilePolicy(prepared.dockerfile, dispatch.sandbox.executionPolicy === "sandbox_required");
64
+ const upstreamImages = dockerfileUpstreamImages(prepared.dockerfile);
65
+ const securityOptions = resolveSandboxSecurityOptions(dispatch.sandbox.executionPolicy);
66
+ const builtImage = await this.buildImage(prepared.contextRoot, prepared.contextDigest, dispatch.repo.name, validated, upstreamImages, dispatch.sandbox.executionPolicy, sink, runtimeSignal);
67
+ const imageDigest = builtImage.imageDigest;
68
+ const runtimeDispatch = withSandboxMetadata(dispatch, prepared.inputCommitSha, prepared.configCommitSha, validated.digest, imageDigest);
69
+ const providerAuthAdapter = sandboxProviderAuthAdapterFor(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
70
+ const providerAuthUnavailable = sandboxProviderAuthUnavailableMessage(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
71
+ if (providerAuthUnavailable) {
72
+ throw new Error(`sandbox_auth_unavailable: ${providerAuthUnavailable}`);
73
+ }
74
+ await sink.emit(event("sandbox_metadata", "Resolved immutable sandbox inputs.", {
75
+ inputCommitSha: prepared.inputCommitSha,
76
+ sandboxConfigCommitSha: prepared.configCommitSha,
77
+ contractDigest: validated.digest,
78
+ imageDigest
79
+ }));
80
+ const resources = resourceNames(identity.attemptHex, validated.contract.caches.filter(cache => cache.mode === "per_attempt").map(cache => cache.name));
81
+ const labels = labelsFor(runtimeDispatch, imageDigest, validated.digest);
82
+ const networkArgs = networkCreateArgs(runtimeDispatch);
83
+ active.ledger = {
84
+ schemaVersion: 1,
85
+ dispatchAttemptId: active.dispatchAttemptId,
86
+ fencingToken: active.fencingToken,
87
+ runnerInstanceId: this.config.runnerInstanceId,
88
+ expiresAt: active.expiresAt,
89
+ resources,
90
+ labels,
91
+ includeCredentials: Boolean(providerAuthAdapter)
92
+ };
93
+ await this.writeLedger(active.ledger);
94
+ let teardownComplete = false;
95
+ try {
96
+ const internalNetworkId = dockerObjectId((await this.docker(["network", "create", ...networkArgs, ...labelArgs(labels), resources.network], 30_000, runtimeSignal)).stdout, "network");
97
+ await this.docker(["volume", "create", ...labelArgs(labels), resources.workspaceVolume], 30_000, runtimeSignal);
98
+ await this.docker(["volume", "create", ...labelArgs(labels), resources.controlVolume], 30_000, runtimeSignal);
99
+ await this.docker(["volume", "create", ...labelArgs(labels), resources.secretsVolume], 30_000, runtimeSignal);
100
+ if (providerAuthAdapter) {
101
+ await this.docker(["volume", "create", ...labelArgs(labels), resources.credentialsVolume], 30_000, runtimeSignal);
102
+ }
103
+ for (const cacheVolume of Object.values(resources.cacheVolumes)) {
104
+ await this.docker(["volume", "create", ...labelArgs(labels), cacheVolume], 30_000, runtimeSignal);
105
+ }
106
+ let egressNetworkId = null;
107
+ if (runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist") {
108
+ egressNetworkId = dockerObjectId((await this.docker(["network", "create", ...labelArgs(labels), resources.egressNetwork], 30_000, runtimeSignal)).stdout, "network");
109
+ await this.docker(["volume", "create", ...labelArgs(labels), resources.egressConfigVolume], 30_000, runtimeSignal);
110
+ }
111
+ await this.initializeVolumes(builtImage.helperImageDigest, resources, Boolean(providerAuthAdapter), runtimeSignal);
112
+ await this.initializeProviderCredentials(builtImage.helperImageDigest, runtimeDispatch, resources, runtimeSignal);
113
+ await sink.emit(event("sandbox_cache_policy", "Runtime caches are builder-managed or isolated to this attempt.", {
114
+ builderManaged: validated.contract.caches.filter(cache => cache.mode === "readonly").map(cache => cache.name),
115
+ perAttempt: Object.keys(resources.cacheVolumes)
116
+ }));
117
+ await this.initializeServiceSecrets(builtImage.helperImageDigest, validated, resources, runtimeSignal);
118
+ await this.initializeEgressPolicy(builtImage.helperImageDigest, runtimeDispatch, resources, runtimeSignal);
119
+ const egressProxy = await this.startEgressProxy(runtimeDispatch, resources, labels, securityOptions, sink, runtimeSignal);
120
+ const serviceContainers = await this.startServices(validated, runtimeDispatch.sandbox.executionPolicy, resources, labels, securityOptions, sink, runtimeSignal);
121
+ const createArgs = workerCreateArgs(runtimeDispatch, validated, resources, labels, imageDigest, securityOptions);
122
+ const createdContainer = await this.docker(createArgs, 60_000, runtimeSignal);
123
+ const containerId = dockerObjectId(createdContainer.stdout, "container");
124
+ await this.docker(["cp", prepared.bundlePath, `${containerId}:/control/input/repo.bundle`], 60_000, runtimeSignal);
125
+ await sink.emit(event("sandbox_started", `Docker sandbox ${identity.attemptHex} started.`, {
126
+ agentContainer: { id: containerId, name: resources.container, role: "agent" },
127
+ sidecarContainers: [...(egressProxy ? [egressProxy] : []), ...serviceContainers],
128
+ networks: [
129
+ { id: internalNetworkId, name: resources.network, role: "internal" },
130
+ ...(egressNetworkId ? [{ id: egressNetworkId, name: resources.egressNetwork, role: "egress" }] : [])
131
+ ],
132
+ volumes: sandboxRuntimeVolumes(resources, runtimeDispatch.sandbox.networkPolicy.mode === "public_allowlist", Boolean(providerAuthAdapter)),
133
+ publishedPorts: []
134
+ }));
135
+ const eventBudget = new SandboxEventBudget();
136
+ const start = await runSandboxProcess({
137
+ command: this.config.dockerExecutable,
138
+ args: this.dockerArgs(["start", "--attach", "--interactive", containerId]),
139
+ input: JSON.stringify({
140
+ schemaVersion: 1,
141
+ dispatch: runtimeDispatch,
142
+ leaseControlKey: active.leaseControlKey,
143
+ limits: sandboxTimeLimits()
144
+ }),
145
+ timeoutMs: 8 * 60 * 60 * 1000,
146
+ maxOutputBytes: 8 * 1024 * 1024,
147
+ terminateOnOutputLimit: true,
148
+ signal: runtimeSignal,
149
+ onStdoutLine: async (line) => {
150
+ eventBudget.accept(line);
151
+ const streamedEvent = parseSandboxEventLine(line);
152
+ if (streamedEvent) {
153
+ if (streamedEvent.eventType === "provider_cli_started")
154
+ active.phase = "active";
155
+ await sink.emit(streamedEvent);
156
+ }
157
+ }
158
+ });
159
+ if (start.truncated)
160
+ throw new Error("sandbox_log_limit: sandbox output exceeded the 8 MiB supervisor capture budget.");
161
+ const containerState = await this.inspectContainerState(containerId);
162
+ if (containerState.oomKilled) {
163
+ throw new Error("sandbox_oom_killed: Docker terminated the agent after it exceeded the memory limit.");
164
+ }
165
+ if (start.exitCode !== 0) {
166
+ const resourceFailure = classifySandboxResourceFailure([
167
+ containerState.error,
168
+ start.stderr,
169
+ start.stdout
170
+ ].filter(Boolean).join("\n"));
171
+ if (resourceFailure)
172
+ throw new Error(`${resourceFailure.code}: ${resourceFailure.message}`);
173
+ }
174
+ active.phase = "active";
175
+ await sink.emit(event("sandbox_agent_exited", `Sandbox worker exited with code ${start.exitCode}.`, {
176
+ workerExitCode: start.exitCode
177
+ }));
178
+ await this.docker(["cp", `${containerId}:/control/output/.`, path.join(attemptRoot, "output")], 60_000);
179
+ const output = await validateOutput(attemptRoot, runtimeDispatch);
180
+ await git.importResult(runtimeDispatch, output, () => assertSandboxImportCurrent(active, this.active.get(active.dispatchAttemptId), controller.signal));
181
+ if (runtimeDispatch.sandbox.publicationMode === "bridge_push") {
182
+ const handle = runtimeDispatch.sandbox.publishGrantHandle;
183
+ if (!handle || !output.manifest.resultCommitSha || !this.publicationGrantRedeemer) {
184
+ throw new Error("sandbox_output_publish_forbidden: bridge publication grant redemption is unavailable.");
185
+ }
186
+ const credential = await this.publicationGrantRedeemer({
187
+ dispatchAttemptId: runtimeDispatch.sandbox.dispatchAttemptId,
188
+ leaseId: runtimeDispatch.lease.id,
189
+ fencingToken: runtimeDispatch.lease.fencingToken,
190
+ runnerInstanceId: runtimeDispatch.sandbox.runnerInstanceId,
191
+ grantId: handle.grantId,
192
+ branch: handle.branch,
193
+ resultCommitSha: output.manifest.resultCommitSha
194
+ }, runtimeSignal);
195
+ validatePublicationCredential(credential);
196
+ await git.publishResult(runtimeDispatch, output, () => assertSandboxImportCurrent(active, this.active.get(active.dispatchAttemptId), controller.signal), credential, runtimeSignal);
197
+ await sink.emit(event("sandbox_remote_published", "Published the exact fenced result commit from the bridge host.", {
198
+ grantId: runtimeDispatch.sandbox.publishGrantHandle.grantId,
199
+ branch: runtimeDispatch.sandbox.publishGrantHandle.branch,
200
+ resultCommitSha: output.manifest.resultCommitSha
201
+ }));
202
+ }
203
+ const result = JSON.parse(await fs.readFile(output.resultPath, "utf8"));
204
+ if (result.exitCode !== 0) {
205
+ const resourceFailure = classifySandboxResourceFailure([
206
+ result.summary,
207
+ ...result.events.flatMap(resultEvent => [resultEvent.eventType, resultEvent.content, resultEvent.data ?? ""])
208
+ ].join("\n"));
209
+ if (resourceFailure) {
210
+ return {
211
+ ...result,
212
+ summary: resourceFailure.message,
213
+ events: [...result.events, event(resourceFailure.code, resourceFailure.message, { phase: "agent" })]
214
+ };
215
+ }
216
+ }
217
+ await sink.emit(event("sandbox_output_imported", "Validated and imported sandbox output.", {
218
+ resultCommitSha: output.manifest.resultCommitSha,
219
+ }));
220
+ return result;
221
+ }
222
+ catch (error) {
223
+ const message = error instanceof Error ? error.message : String(error);
224
+ const classified = classifySandboxRuntimeFailure(message);
225
+ return failureResult(classified.message, [event(classified.code, classified.message, { phase: classified.phase })]);
226
+ }
227
+ finally {
228
+ try {
229
+ try {
230
+ await sink.emit(event("sandbox_teardown_started", `Removing Docker resources for attempt ${active.dispatchAttemptId}.`)).catch(() => undefined);
231
+ const cleanupReport = await this.teardown(resources, labels, "teardown", Boolean(providerAuthAdapter));
232
+ await this.persistCleanupReport(cleanupReport);
233
+ teardownComplete = true;
234
+ this.degraded.delete(active.dispatchAttemptId);
235
+ await this.removeLedger(active.dispatchAttemptId);
236
+ await sink.emit(event("sandbox_teardown_completed", `Removed Docker resources for attempt ${active.dispatchAttemptId}.`, { ...cleanupReport })).catch(() => undefined);
237
+ }
238
+ catch (error) {
239
+ this.degraded.add(active.dispatchAttemptId);
240
+ const cleanupReport = error instanceof SandboxTeardownError ? error.report : emptyCleanupReport("teardown");
241
+ await this.persistCleanupReport(cleanupReport);
242
+ await sink.emit(event("teardown_failed", `Docker resources for attempt ${active.dispatchAttemptId} require reaping.`, { ...cleanupReport })).catch(() => undefined);
243
+ }
244
+ }
245
+ finally {
246
+ if (teardownComplete)
247
+ await fs.rm(attemptRoot, { recursive: true, force: true }).catch(() => undefined);
248
+ }
249
+ }
250
+ }
251
+ finally {
252
+ clearInterval(watchdog);
253
+ signal?.removeEventListener("abort", abort);
254
+ this.active.delete(active.dispatchAttemptId);
255
+ }
256
+ }
257
+ async updateLease(dispatchAttemptId, fencingToken, expiresAt) {
258
+ if (!Number.isFinite(Date.parse(expiresAt)))
259
+ return;
260
+ const active = this.active.get(dispatchAttemptId);
261
+ if (!active || active.fencingToken !== fencingToken)
262
+ return;
263
+ active.expiresAt = expiresAt;
264
+ if (active.ledger) {
265
+ active.ledger = { ...active.ledger, expiresAt };
266
+ await this.writeLedger(active.ledger);
267
+ await this.pushLeaseUpdate(active).catch(() => undefined);
268
+ }
269
+ }
270
+ async pushLeaseUpdate(active) {
271
+ if (!active.ledger)
272
+ return;
273
+ const update = createLeaseUpdate(active.dispatchAttemptId, active.fencingToken, active.expiresAt, active.leaseControlKey);
274
+ 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)})";
275
+ await requireSandboxProcess({
276
+ command: this.config.dockerExecutable,
277
+ args: this.dockerArgs(["exec", "--interactive", active.ledger.resources.container, "node", "-e", script]),
278
+ input: `${JSON.stringify(update)}\n`,
279
+ timeoutMs: 10_000,
280
+ maxOutputBytes: 16 * 1024
281
+ });
282
+ }
283
+ snapshot() {
284
+ let starting = 0;
285
+ let active = 0;
286
+ for (const sandbox of this.active.values()) {
287
+ if (sandbox.phase === "starting")
288
+ starting += 1;
289
+ else
290
+ active += 1;
291
+ }
292
+ return { starting, active, degraded: this.degraded.size };
293
+ }
294
+ async reap(options = {}) {
295
+ const liveAttempts = new Set();
296
+ const snapshotByAttempt = new Map((options.attemptSnapshot ?? []).map(attempt => [attempt.dispatchAttemptId, attempt]));
297
+ const now = Date.now();
298
+ for (const attempt of options.attemptSnapshot ?? []) {
299
+ if (attempt.runnerInstanceId === this.config.runnerInstanceId && Date.parse(attempt.expiresAt) > now) {
300
+ liveAttempts.add(attempt.dispatchAttemptId);
301
+ }
302
+ }
303
+ for (const ledger of await this.readLedgers()) {
304
+ if (ledger.labels["dev.forge.runner-id"] !== this.config.runnerId)
305
+ continue;
306
+ const current = this.active.get(ledger.dispatchAttemptId);
307
+ if (current
308
+ && current.fencingToken === ledger.fencingToken
309
+ && ledger.runnerInstanceId === this.config.runnerInstanceId) {
310
+ liveAttempts.add(ledger.dispatchAttemptId);
311
+ continue;
312
+ }
313
+ const serverAttempt = snapshotByAttempt.get(ledger.dispatchAttemptId);
314
+ if (!shouldReapSandboxLedger(ledger, this.config.runnerInstanceId, serverAttempt, options.attemptSnapshot !== undefined, now)) {
315
+ liveAttempts.add(ledger.dispatchAttemptId);
316
+ continue;
317
+ }
318
+ try {
319
+ const report = await this.teardown(ledger.resources, ledger.labels, "reaper", ledger.includeCredentials !== false);
320
+ await this.persistCleanupReport(report);
321
+ await this.removeLedger(ledger.dispatchAttemptId);
322
+ this.degraded.delete(ledger.dispatchAttemptId);
323
+ await fs.rm(path.join(this.config.sandboxStateRoot, "attempts", ledger.dispatchAttemptId.replace(/-/g, "").toLowerCase()), { recursive: true, force: true }).catch(() => undefined);
324
+ }
325
+ catch (error) {
326
+ if (error instanceof SandboxTeardownError)
327
+ await this.persistCleanupReport(error.report);
328
+ this.degraded.add(ledger.dispatchAttemptId);
329
+ }
330
+ }
331
+ for (const kind of ["container", "network", "volume"]) {
332
+ for (const id of await this.listResources(kind, "dev.forge.managed=true")) {
333
+ const labels = await this.inspectResourceLabels(kind, id).catch(() => null);
334
+ if (!labels || !mayReapManagedResource(labels, this.config.runnerId, liveAttempts))
335
+ continue;
336
+ await this.removeResource(kind, id).catch(() => undefined);
337
+ }
338
+ }
339
+ await this.purgeRestrictedAuditArtifacts();
340
+ if (options.diskLow) {
341
+ await this.withBuildPermit(() => this.collectExpiredImages());
342
+ }
343
+ }
344
+ async pendingCleanupReports() {
345
+ const root = path.join(this.config.sandboxStateRoot, "cleanup-reports");
346
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
347
+ const reports = [];
348
+ for (const entry of (await fs.readdir(root, { withFileTypes: true })).slice(0, 256)) {
349
+ if (!entry.isFile() || !/^[0-9a-f]{32}\.json$/iu.test(entry.name))
350
+ continue;
351
+ const target = path.join(root, entry.name);
352
+ const stat = await fs.lstat(target);
353
+ if (stat.isSymbolicLink() || stat.size > 256 * 1024)
354
+ continue;
355
+ try {
356
+ const report = parseCleanupReport(JSON.parse(await fs.readFile(target, "utf8")));
357
+ if (report)
358
+ reports.push(report);
359
+ }
360
+ catch {
361
+ // A corrupt report is retained for operator inspection and never sent as trusted data.
362
+ }
363
+ }
364
+ return reports;
365
+ }
366
+ async acknowledgeCleanupReport(reportId) {
367
+ await fs.rm(this.cleanupReportPath(reportId), { force: true });
368
+ }
369
+ async persistCleanupReport(report) {
370
+ if (report.actions.length === 0)
371
+ return;
372
+ const root = path.join(this.config.sandboxStateRoot, "cleanup-reports");
373
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
374
+ const target = this.cleanupReportPath(report.reportId);
375
+ try {
376
+ await fs.access(target);
377
+ return;
378
+ }
379
+ catch {
380
+ // The report is new. Write it durably before acknowledging cleanup to the server.
381
+ }
382
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
383
+ const handle = await fs.open(temporary, "wx", 0o600);
384
+ try {
385
+ await handle.writeFile(`${JSON.stringify(report)}\n`);
386
+ await handle.sync();
387
+ }
388
+ finally {
389
+ await handle.close();
390
+ }
391
+ await fs.rename(temporary, target);
392
+ }
393
+ cleanupReportPath(reportId) {
394
+ const value = reportId.replace(/-/gu, "").toLowerCase();
395
+ if (!/^[0-9a-f]{32}$/u.test(value))
396
+ throw new Error("sandbox_cleanup_report_invalid: report id must be a UUID.");
397
+ return path.join(this.config.sandboxStateRoot, "cleanup-reports", `${value}.json`);
398
+ }
399
+ async writeLedger(ledger) {
400
+ const root = path.join(this.config.sandboxStateRoot, "ledger");
401
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
402
+ const target = this.ledgerPath(ledger.dispatchAttemptId);
403
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
404
+ const handle = await fs.open(temporary, "wx", 0o600);
405
+ try {
406
+ await handle.writeFile(`${JSON.stringify(ledger)}\n`);
407
+ await handle.sync();
408
+ }
409
+ finally {
410
+ await handle.close();
411
+ }
412
+ await fs.rename(temporary, target);
413
+ }
414
+ async readLedgers() {
415
+ const root = path.join(this.config.sandboxStateRoot, "ledger");
416
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
417
+ const result = [];
418
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
419
+ if (!entry.isFile() || !/^[0-9a-f]{32}\.json$/i.test(entry.name))
420
+ continue;
421
+ const target = path.join(root, entry.name);
422
+ const stat = await fs.lstat(target);
423
+ if (stat.isSymbolicLink() || stat.size > 64 * 1024)
424
+ continue;
425
+ const ledger = JSON.parse(await fs.readFile(target, "utf8"));
426
+ if (validLedger(ledger))
427
+ result.push(ledger);
428
+ }
429
+ return result;
430
+ }
431
+ ledgerPath(dispatchAttemptId) {
432
+ const attemptHex = dispatchAttemptId.replace(/-/g, "").toLowerCase();
433
+ if (!/^[0-9a-f]{32}$/.test(attemptHex))
434
+ throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
435
+ return path.join(this.config.sandboxStateRoot, "ledger", `${attemptHex}.json`);
436
+ }
437
+ async removeLedger(dispatchAttemptId) {
438
+ await fs.rm(this.ledgerPath(dispatchAttemptId), { force: true });
439
+ }
440
+ async buildImage(contextRoot, contextDigest, project, contract, upstreamImages, policy, sink, signal) {
441
+ const toolingImage = process.env.FORGE_AGENT_TOOLING_IMAGE ?? "node:22.17.0-bookworm-slim";
442
+ if (policy === "sandbox_required" && !/@sha256:[0-9a-f]{64}$/i.test(toolingImage)) {
443
+ throw new Error("sandbox_build_failed: FORGE_AGENT_TOOLING_IMAGE must be digest-pinned for sandbox_required.");
444
+ }
445
+ const codexVersion = process.env.FORGE_CODEX_PACKAGE_VERSION ?? "0.144.1";
446
+ if (!/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(codexVersion))
447
+ throw new Error("sandbox_build_failed: invalid pinned Codex package version.");
448
+ const policyConfigurationDigest = await sandboxImagePolicyConfigurationDigest();
449
+ const key = createHash("sha256").update([
450
+ contextDigest,
451
+ contract.digest,
452
+ process.platform,
453
+ process.arch,
454
+ this.config.version,
455
+ toolingImage,
456
+ codexVersion,
457
+ policy,
458
+ policyConfigurationDigest
459
+ ].join("\0")).digest("hex");
460
+ const existing = this.builds.get(key);
461
+ if (existing) {
462
+ await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} is being built by another dispatch.`, { cacheKey: key, singleflight: true }));
463
+ const waitStartedAt = Date.now();
464
+ const built = await existing;
465
+ await sink.emit(event("sandbox_build_singleflight_wait", `Waited for the existing sandbox image build ${key.slice(0, 12)}.`, {
466
+ durationMs: Date.now() - waitStartedAt,
467
+ imageDigest: built.imageDigest
468
+ }));
469
+ return built;
470
+ }
471
+ const promise = this.withBuildPermit(() => this.buildImageCore(contextRoot, contextDigest, contract.digest, key, project, toolingImage, upstreamImages, codexVersion, policy, sink, signal), signal);
472
+ this.builds.set(key, promise);
473
+ try {
474
+ return await promise;
475
+ }
476
+ finally {
477
+ this.builds.delete(key);
478
+ }
479
+ }
480
+ async withBuildPermit(action, signal) {
481
+ let release = () => undefined;
482
+ const turn = new Promise(resolve => { release = resolve; });
483
+ const previous = this.buildTail;
484
+ this.buildTail = previous.catch(() => undefined).then(() => turn);
485
+ await previous.catch(() => undefined);
486
+ try {
487
+ if (signal?.aborted)
488
+ throw new Error(String(signal.reason ?? "sandbox build cancelled"));
489
+ return await action();
490
+ }
491
+ finally {
492
+ release();
493
+ }
494
+ }
495
+ async buildImageCore(contextRoot, contextDigest, contractDigest, key, project, toolingImage, upstreamImages, codexVersion, policy, sink, signal) {
496
+ const projectTag = `forge/project:${key}`;
497
+ const helperTag = `forge/tooling:${key}`;
498
+ const overlayTag = `forge/agent:${key}`;
499
+ const [cachedHelper, cachedOverlay] = await Promise.all([
500
+ this.docker(["image", "inspect", helperTag, "--format", "{{.Id}}"], 15_000).catch(() => null),
501
+ this.docker(["image", "inspect", overlayTag, "--format", "{{.Id}}"], 15_000).catch(() => null)
502
+ ]);
503
+ const cachedHelperDigest = cachedHelper?.stdout.trim() ?? "";
504
+ const cachedOverlayDigest = cachedOverlay?.stdout.trim() ?? "";
505
+ const cacheRecordPath = path.join(this.config.sandboxStateRoot, "image-cache", `${key}.json`);
506
+ const cacheRecord = await this.readImageCacheRecord(cacheRecordPath);
507
+ const policyRecordValid = cacheRecord?.policyDigest
508
+ ? await hasSandboxImagePolicyRecord(this.config.sandboxStateRoot, key, cacheRecord.policyDigest, policy === "sandbox_required")
509
+ : false;
510
+ if (/^sha256:[0-9a-f]{64}$/i.test(cachedHelperDigest)
511
+ && /^sha256:[0-9a-f]{64}$/i.test(cachedOverlayDigest)
512
+ && cacheRecord?.helperImageDigest === cachedHelperDigest
513
+ && cacheRecord.imageDigest === cachedOverlayDigest
514
+ && policyRecordValid) {
515
+ await this.writeImageCacheRecord({
516
+ ...cacheRecord,
517
+ lastUsedAt: new Date().toISOString(),
518
+ });
519
+ await sink.emit(event("sandbox_image_cache_hit", `Using cached sandbox image ${cachedOverlayDigest}.`, { imageDigest: cachedOverlayDigest }));
520
+ return { imageDigest: cachedOverlayDigest, helperImageDigest: cachedHelperDigest };
521
+ }
522
+ if (cachedHelperDigest || cachedOverlayDigest) {
523
+ await Promise.all([overlayTag, helperTag, projectTag].map(tag => this.docker(["image", "rm", "--force", tag], 60_000).catch(() => undefined)));
524
+ }
525
+ await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} requires a build.`, { cacheKey: key }));
526
+ if (policy === "sandbox_required" && process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED !== "true") {
527
+ throw new Error("sandbox_build_policy_failed: sandbox_required builds need an operator-enforced builder egress policy.");
528
+ }
529
+ const buildStartedAt = Date.now();
530
+ await sink.emit(event("sandbox_build_started", `Building sandbox image ${key.slice(0, 12)}.`, { cacheKey: key }));
531
+ const builder = await this.ensureBuildxBuilder(signal);
532
+ const metadataRoot = path.join(this.config.sandboxStateRoot, "attestations", key, "buildkit");
533
+ const overlayRoot = path.join(this.config.sandboxStateRoot, "build", `overlay-${key}`);
534
+ await prepareOverlayBuildRoot(overlayRoot);
535
+ await fs.mkdir(metadataRoot, { recursive: true, mode: 0o700 });
536
+ const projectOciArchive = path.join(overlayRoot, "project.oci.tar");
537
+ const projectOciRoot = path.join(overlayRoot, "project-oci");
538
+ await this.runBuildStage("project", key, buildxBuildArgs(builder, path.join(metadataRoot, "project.json"), [
539
+ "--pull=false",
540
+ ...imageLabelArgs(project, key, "project"),
541
+ "--tag", projectTag,
542
+ "--output", `type=oci,dest=${projectOciArchive}`,
543
+ "--file", path.join(contextRoot, ".forge", "Dockerfile"),
544
+ contextRoot
545
+ ], process.env, { load: false }), sink, signal);
546
+ await extractOciLayoutArchive(projectOciArchive, projectOciRoot, signal);
547
+ const projectImage = await readOciLayoutImageDigest(projectOciRoot);
548
+ const projectContextName = "forge_project";
549
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
550
+ await fs.cp(path.join(packageRoot, "dist"), path.join(overlayRoot, "bridge", "dist"), { recursive: true });
551
+ const installMode = await copyBridgePackageManifests(packageRoot, path.join(overlayRoot, "bridge"));
552
+ const dockerfile = agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion);
553
+ await fs.writeFile(path.join(overlayRoot, "Dockerfile"), dockerfile);
554
+ await fs.writeFile(path.join(overlayRoot, ".dockerignore"), agentOverlayDockerignore());
555
+ await this.runBuildStage("tooling", key, buildxBuildArgs(builder, path.join(metadataRoot, "tooling.json"), ["--pull=false", "--target", "tooling", ...imageLabelArgs(project, key, "tooling"), "--tag", helperTag, overlayRoot]), sink, signal);
556
+ const helperImageDigest = (await this.docker(["image", "inspect", helperTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
557
+ if (!/^sha256:[0-9a-f]{64}$/i.test(helperImageDigest))
558
+ throw new Error("sandbox_build_failed: helper image did not resolve to an immutable digest.");
559
+ await this.runBuildStage("agent", key, buildxBuildArgs(builder, path.join(metadataRoot, "agent.json"), [
560
+ "--pull=false",
561
+ "--build-context", `${projectContextName}=${ociLayoutContextRef(projectOciRoot)}`,
562
+ ...imageLabelArgs(project, key, "agent"),
563
+ "--tag", overlayTag,
564
+ overlayRoot
565
+ ]), sink, signal);
566
+ const digest = (await this.docker(["image", "inspect", overlayTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
567
+ if (!/^sha256:[0-9a-f]{64}$/i.test(digest))
568
+ throw new Error("sandbox_build_failed: overlay image did not resolve to an immutable digest.");
569
+ const imagePolicy = await evaluateSandboxImagePolicy({
570
+ key,
571
+ project: dockerLabelValue(project),
572
+ executionPolicy: policy,
573
+ stateRoot: this.config.sandboxStateRoot,
574
+ builderVersion: this.config.version,
575
+ contextDigest,
576
+ contractDigest,
577
+ toolingImage,
578
+ upstreamImages,
579
+ codexVersion,
580
+ images: { project: projectImage, tooling: helperImageDigest, agent: digest },
581
+ imageScanTargets: { project: ociLayoutScanTarget(projectOciRoot) },
582
+ signal
583
+ });
584
+ 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 }));
585
+ await removeExtractedOciLayout(projectOciRoot);
586
+ await this.writeImageCacheRecord({
587
+ schemaVersion: 1,
588
+ key,
589
+ project: dockerLabelValue(project),
590
+ createdAt: new Date().toISOString(),
591
+ lastUsedAt: new Date().toISOString(),
592
+ projectTag,
593
+ helperTag,
594
+ overlayTag,
595
+ imageDigest: digest,
596
+ helperImageDigest,
597
+ policyDigest: imagePolicy.policyDigest
598
+ });
599
+ await sink.emit(event("sandbox_build_completed", `Sandbox image ready at ${digest}.`, {
600
+ durationMs: Date.now() - buildStartedAt,
601
+ imageDigest: digest
602
+ }));
603
+ return { imageDigest: digest, helperImageDigest };
604
+ }
605
+ async runBuildStage(stage, key, args, sink, signal) {
606
+ const result = await runSandboxProcess({
607
+ command: this.config.dockerExecutable,
608
+ args: this.dockerArgs(args),
609
+ timeoutMs: 30 * 60_000,
610
+ maxOutputBytes: 2 * 1024 * 1024,
611
+ terminateOnOutputLimit: true,
612
+ signal
613
+ });
614
+ if (result.exitCode === 0 && !result.truncated)
615
+ return;
616
+ const exactSecrets = [this.config.deviceToken, ...Object.entries(process.env)
617
+ .filter(([name, value]) => /(?:api[_-]?key|authorization|bearer|client[_-]?secret|password|secret|token)/iu.test(name)
618
+ && typeof value === "string"
619
+ && value.length >= 8)
620
+ .map(([, value]) => value)].filter(Boolean);
621
+ const artifact = restrictedBuildLog(`stage=${stage}\nexitCode=${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, exactSecrets);
622
+ const root = path.join(this.config.sandboxStateRoot, "build-logs", key);
623
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
624
+ const target = path.join(root, `${stage}.log`);
625
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
626
+ const handle = await fs.open(temporary, "wx", 0o600);
627
+ try {
628
+ await handle.writeFile(artifact.content);
629
+ await handle.sync();
630
+ }
631
+ finally {
632
+ await handle.close();
633
+ }
634
+ await fs.rename(temporary, target);
635
+ await sink.emit(event("sandbox_build_log_preserved", `Restricted build log retained for failed ${stage} image stage.`, {
636
+ stage,
637
+ digest: artifact.digest,
638
+ byteCount: artifact.byteCount,
639
+ truncated: artifact.truncated || result.truncated
640
+ })).catch(() => undefined);
641
+ throw new Error(`sandbox_build_failed: ${stage} image build failed; restricted log digest ${artifact.digest}.`);
642
+ }
643
+ async ensureBuildxBuilder(signal) {
644
+ const name = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
645
+ const existing = await this.docker(["buildx", "inspect", name], 30_000, signal).catch(() => null);
646
+ if (!existing) {
647
+ await this.docker(["buildx", "create", "--name", name, "--driver", "docker-container"], 60_000, signal);
648
+ }
649
+ await this.docker(["buildx", "inspect", "--bootstrap", name], 2 * 60_000, signal, 1024 * 1024);
650
+ const pidsLimit = buildResourcePolicy().builderPidsLimit;
651
+ const container = dedicatedBuildxBuilderContainerName(name);
652
+ await this.docker(["container", "update", "--pids-limit", String(pidsLimit), container], 30_000, signal);
653
+ const applied = await this.docker(["container", "inspect", container, "--format", "{{.HostConfig.PidsLimit}}"], 30_000, signal);
654
+ if (Number(applied.stdout.trim()) !== pidsLimit) {
655
+ throw new Error("sandbox_build_policy_failed: dedicated BuildKit builder PID limit was not applied.");
656
+ }
657
+ return name;
658
+ }
659
+ async initializeVolumes(helperImage, resources, includeCredentials, signal) {
660
+ const cacheMounts = Object.entries(resources.cacheVolumes).flatMap(([name, volume]) => ["--volume", `${volume}:/cache/${name}`]);
661
+ const credentialSetup = includeCredentials ? "f.chownSync('/credentials',10001,10001);f.chmodSync('/credentials',0o700);" : "";
662
+ const script = `const f=require('fs');f.mkdirSync('/control/input',{recursive:true});f.mkdirSync('/control/output',{recursive:true});for(const p of ['/control','/control/input','/control/output','/workspace'])f.chmodSync(p,0o777);${credentialSetup}for(const p of f.readdirSync('/cache').map(n=>'/cache/'+n)){f.chownSync(p,10001,10001);f.chmodSync(p,0o700)}`;
663
+ const credentialMount = includeCredentials ? ["--volume", `${resources.credentialsVolume}:/credentials`] : [];
664
+ await this.docker(["run", "--rm", "--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.workspaceVolume}:/workspace`, "--volume", `${resources.controlVolume}:/control`, ...credentialMount, ...cacheMounts, "--tmpfs", "/cache:rw,nosuid,nodev,noexec,size=1m", helperImage, "-e", script], 60_000, signal);
665
+ }
666
+ async inspectContainerState(containerId) {
667
+ const result = await this.docker(["container", "inspect", containerId, "--format", "{{json .State}}"], 15_000, undefined, 256 * 1024);
668
+ const value = JSON.parse(result.stdout);
669
+ return {
670
+ oomKilled: value.OOMKilled === true,
671
+ exitCode: typeof value.ExitCode === "number" ? value.ExitCode : null,
672
+ error: typeof value.Error === "string" && value.Error.trim() ? value.Error.trim().slice(0, 1000) : null
673
+ };
674
+ }
675
+ async initializeServiceSecrets(helperImage, contract, resources, signal) {
676
+ 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}))";
677
+ for (const service of contract.contract.services) {
678
+ const secretName = service.type === "postgres" ? "postgres-password" : service.type === "redis" ? "redis-password" : "browser-token";
679
+ const writeScript = script.replace("postgres-password", secretName);
680
+ await requireSandboxProcess({
681
+ command: this.config.dockerExecutable,
682
+ args: this.dockerArgs(["run", "--rm", "--interactive", "--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]),
683
+ input: randomBytes(32).toString("base64url"),
684
+ timeoutMs: 60_000,
685
+ maxOutputBytes: 64 * 1024,
686
+ signal
687
+ });
688
+ }
689
+ }
690
+ async initializeProviderCredentials(helperImage, dispatch, resources, signal) {
691
+ const providerAuth = await sandboxProviderAuthForDispatch(dispatch);
692
+ if (!providerAuth) {
693
+ return;
694
+ }
695
+ 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.chownSync(target,10001,10001)}f.chownSync(root,10001,10001);f.chmodSync(root,0o700)})";
696
+ await requireSandboxProcess({
697
+ command: this.config.dockerExecutable,
698
+ args: this.dockerArgs(["run", "--rm", "--interactive", "--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.credentialsVolume}:/credentials`, helperImage, "-e", script]),
699
+ input: JSON.stringify({ files: providerAuth.files }),
700
+ timeoutMs: 60_000,
701
+ maxOutputBytes: 64 * 1024,
702
+ signal
703
+ });
704
+ }
705
+ async initializeEgressPolicy(helperImage, dispatch, resources, signal) {
706
+ if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
707
+ return;
708
+ 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}))";
709
+ await requireSandboxProcess({
710
+ command: this.config.dockerExecutable,
711
+ args: this.dockerArgs(["run", "--rm", "--interactive", "--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]),
712
+ input: egressProxyConfig(dispatch.sandbox.networkPolicy.allowedHosts),
713
+ timeoutMs: 60_000,
714
+ maxOutputBytes: 64 * 1024,
715
+ signal
716
+ });
717
+ }
718
+ async startEgressProxy(dispatch, resources, labels, securityOptions, sink, signal) {
719
+ if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
720
+ return null;
721
+ const image = process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim();
722
+ if (!image || !/@sha256:[0-9a-f]{64}$/iu.test(image)) {
723
+ throw new Error("sandbox_egress_proxy_unavailable: public_allowlist needs a digest-pinned Squid-compatible proxy image.");
724
+ }
725
+ if (process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
726
+ throw new Error("sandbox_egress_proxy_unavailable: runner egress enforcement is not enabled.");
727
+ }
728
+ await this.verifyCatalogImage(image, dispatch.sandbox.executionPolicy, sink, signal);
729
+ const name = `${resources.prefix}-egress`;
730
+ const containerId = dockerObjectId((await this.docker([
731
+ "run", "--detach", "--name", name, ...labelArgs(labels), "--network", resources.egressNetwork,
732
+ "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", ...securityOptions,
733
+ "--pids-limit", "128", "--memory", "256m", "--memory-swap", "256m", "--cpus", "0.5",
734
+ "--tmpfs", "/var/spool/squid:rw,nosuid,nodev,noexec,size=128m", "--tmpfs", "/run:rw,nosuid,nodev,noexec,size=16m",
735
+ "--log-driver", "none", "--volume", `${resources.egressConfigVolume}:/etc/squid/forge:ro`,
736
+ "--entrypoint", "squid", image, "-N", "-f", "/etc/squid/forge/forge.conf"
737
+ ], 60_000, signal)).stdout, "container");
738
+ await this.docker(["network", "connect", "--alias", "forge-egress", resources.network, name], 30_000, signal);
739
+ return { id: containerId, name, role: "egress_proxy" };
740
+ }
741
+ async startServices(contract, executionPolicy, resources, labels, securityOptions, sink, signal) {
742
+ const started = [];
743
+ for (const service of contract.contract.services) {
744
+ const image = serviceImage(service.type);
745
+ if (!image)
746
+ throw new Error(`service_unavailable: no digest-pinned catalogue image configured for ${service.type}.`);
747
+ await this.verifyCatalogImage(image, executionPolicy, sink, signal);
748
+ const name = `${resources.prefix}-${service.type}`;
749
+ const args = serviceContainerArgs(service, image, resources, labels, securityOptions);
750
+ const containerId = dockerObjectId((await this.docker(args, 60_000, signal)).stdout, "container");
751
+ await this.waitForServiceHealthy(name, signal);
752
+ started.push({ id: containerId, name, role: `service:${service.type}` });
753
+ }
754
+ return started;
755
+ }
756
+ async verifyCatalogImage(image, executionPolicy, sink, signal) {
757
+ if (this.verifiedCatalogImages.has(image))
758
+ return;
759
+ const status = await verifySandboxImageSignature({ image, executionPolicy, signal });
760
+ if (status === "passed") {
761
+ this.verifiedCatalogImages.add(image);
762
+ return;
763
+ }
764
+ await sink.emit(event("sandbox_image_signature_warning", `Image signature enforcement is not configured for adoption-mode image ${image}.`));
765
+ }
766
+ async waitForServiceHealthy(name, signal) {
767
+ const deadline = Date.now() + 60_000;
768
+ while (Date.now() < deadline) {
769
+ if (signal?.aborted)
770
+ throw new Error(String(signal.reason ?? "sandbox service startup cancelled"));
771
+ const status = (await this.docker(["container", "inspect", name, "--format", "{{.State.Health.Status}}"], 10_000, signal)).stdout.trim();
772
+ if (status === "healthy")
773
+ return;
774
+ if (status === "unhealthy")
775
+ throw new Error(`sandbox_service_health_timeout: ${name} reported unhealthy.`);
776
+ await new Promise(resolve => setTimeout(resolve, 500));
777
+ }
778
+ throw new Error(`sandbox_service_health_timeout: ${name} did not become healthy before the startup deadline.`);
779
+ }
780
+ async teardown(resources, expectedLabels, source, includeCredentials = true) {
781
+ const attemptId = expectedLabels["dev.forge.dispatch-attempt-id"];
782
+ const reportId = randomUUID();
783
+ const actions = [];
784
+ const record = async (kind, id) => {
785
+ try {
786
+ await this.removeResource(kind, id);
787
+ actions.push(cleanupAction(expectedLabels, kind, id, "removed"));
788
+ }
789
+ catch (error) {
790
+ actions.push(cleanupAction(expectedLabels, kind, id, "failed", cleanupErrorMessage(error)));
791
+ }
792
+ };
793
+ for (const id of await this.listResources("container", `dev.forge.dispatch-attempt-id=${attemptId}`, false)) {
794
+ try {
795
+ const labels = await this.inspectResourceLabels("container", id);
796
+ if (!labelsMatch(labels, expectedLabels))
797
+ continue;
798
+ await record("container", id);
799
+ }
800
+ catch (error) {
801
+ actions.push(cleanupAction(expectedLabels, "container", id, "failed", cleanupErrorMessage(error)));
802
+ }
803
+ }
804
+ for (const [kind, names] of [["network", [resources.network, resources.egressNetwork]], ["volume", sandboxRuntimeCleanupVolumeNames(resources, includeCredentials)]]) {
805
+ for (const name of names) {
806
+ if (!name)
807
+ continue;
808
+ const labels = await this.inspectResourceLabels(kind, name).catch(() => null);
809
+ if (labels && labelsMatch(labels, expectedLabels))
810
+ await record(kind, name);
811
+ }
812
+ }
813
+ const remaining = (await Promise.all(["container", "network", "volume"].map(async (kind) => ({
814
+ kind,
815
+ ids: await this.listResources(kind, `dev.forge.dispatch-attempt-id=${attemptId}`, false)
816
+ })))).flatMap(entry => entry.ids.map(id => ({ kind: entry.kind, id })));
817
+ const reportedFailures = new Set(actions.filter(action => action.outcome === "failed").map(action => `${action.resourceType}\0${action.resourceId}`));
818
+ for (const resource of remaining) {
819
+ if (!reportedFailures.has(`${resource.kind}\0${resource.id}`)) {
820
+ actions.push(cleanupAction(expectedLabels, resource.kind, resource.id, "failed", "Exact-labeled resource remained after teardown."));
821
+ }
822
+ }
823
+ const report = { reportId, source, actions };
824
+ if (remaining.length > 0 || actions.some(action => action.outcome === "failed")) {
825
+ throw new SandboxTeardownError(report, `teardown_failed: ${remaining.length} exact-labeled Docker resources remain for attempt ${attemptId}.`);
826
+ }
827
+ return report;
828
+ }
829
+ async listResources(kind, label, suppressErrors = true) {
830
+ const args = kind === "container"
831
+ ? ["ps", "-aq", "--filter", `label=${label}`]
832
+ : [kind, "ls", "-q", "--filter", `label=${label}`];
833
+ const result = suppressErrors ? await this.docker(args, 30_000).catch(() => null) : await this.docker(args, 30_000);
834
+ return result?.stdout.split(/\s+/).filter(Boolean) ?? [];
835
+ }
836
+ async inspectResourceLabels(kind, id) {
837
+ const args = kind === "container" ? ["container", "inspect", id] : [kind, "inspect", id];
838
+ const result = await this.docker(args, 15_000, undefined, 256 * 1024);
839
+ const document = JSON.parse(result.stdout);
840
+ return document[0]?.Config?.Labels ?? document[0]?.Labels ?? {};
841
+ }
842
+ async removeResource(kind, id) {
843
+ if (kind === "container") {
844
+ await this.docker(["container", "stop", "--time", "10", id], 20_000).catch(() => undefined);
845
+ }
846
+ const args = kind === "container" ? ["container", "rm", "--force", id] : kind === "volume" ? ["volume", "rm", "--force", id] : ["network", "rm", id];
847
+ await this.docker(args, 30_000);
848
+ }
849
+ async writeImageCacheRecord(record) {
850
+ const root = path.join(this.config.sandboxStateRoot, "image-cache");
851
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
852
+ const target = path.join(root, `${record.key}.json`);
853
+ const previous = await this.readImageCacheRecord(target);
854
+ const value = previous ? { ...record, createdAt: previous.createdAt } : record;
855
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
856
+ await fs.writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: "wx" });
857
+ await fs.rename(temporary, target);
858
+ }
859
+ async readImageCacheRecords() {
860
+ const root = path.join(this.config.sandboxStateRoot, "image-cache");
861
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
862
+ const records = [];
863
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
864
+ if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/i.test(entry.name))
865
+ continue;
866
+ const record = await this.readImageCacheRecord(path.join(root, entry.name));
867
+ if (record)
868
+ records.push(record);
869
+ }
870
+ return records;
871
+ }
872
+ async readImageCacheRecord(target) {
873
+ const stat = await fs.lstat(target).catch(() => null);
874
+ if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
875
+ return null;
876
+ const value = await fs.readFile(target, "utf8").then(text => JSON.parse(text)).catch(() => null);
877
+ return value && validImageCacheRecord(value) ? value : null;
878
+ }
879
+ async collectExpiredImages() {
880
+ const records = await this.readImageCacheRecords();
881
+ const candidates = selectImageGcCandidates(records, Math.max(1, Math.floor(this.config.imageRetentionCount)), Math.max(60_000, this.config.imageRetentionTtlMs), Date.now());
882
+ const protectedDigests = new Set([...this.active.values()]
883
+ .map(value => value.ledger?.labels["dev.forge.image-digest"])
884
+ .filter((value) => Boolean(value)));
885
+ for (const record of candidates) {
886
+ if (protectedDigests.has(record.imageDigest) || protectedDigests.has(record.helperImageDigest))
887
+ continue;
888
+ for (const tag of [record.overlayTag, record.helperTag, record.projectTag]) {
889
+ await this.docker(["image", "rm", tag], 60_000).catch(() => undefined);
890
+ }
891
+ const remains = await Promise.all([record.overlayTag, record.helperTag, record.projectTag]
892
+ .map(tag => this.docker(["image", "inspect", tag, "--format", "{{.Id}}"], 15_000).then(() => true).catch(() => false)));
893
+ if (!remains.some(Boolean)) {
894
+ await fs.rm(path.join(this.config.sandboxStateRoot, "image-cache", `${record.key}.json`), { force: true });
895
+ }
896
+ }
897
+ const builder = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
898
+ const builderExists = await this.docker(["buildx", "inspect", builder], 30_000).then(() => true).catch(() => false);
899
+ if (builderExists) {
900
+ await this.docker([
901
+ "buildx", "prune", "--builder", builder, "--force",
902
+ "--max-used-space", boundedBuildCacheSize(process.env.FORGE_SANDBOX_BUILDKIT_CACHE_MAX, "20gb"),
903
+ "--min-free-space", String(Math.max(1, Math.floor(this.config.minimumWorkspaceBytes))),
904
+ "--reserved-space", boundedBuildCacheSize(process.env.FORGE_SANDBOX_BUILDKIT_CACHE_RESERVED, "2gb")
905
+ ], 5 * 60_000, undefined, 4 * 1024 * 1024).catch(() => undefined);
906
+ }
907
+ }
908
+ async purgeRestrictedAuditArtifacts() {
909
+ const activeDigests = new Set([...this.active.values()]
910
+ .map(value => value.ledger?.labels["dev.forge.image-digest"])
911
+ .filter((value) => Boolean(value)));
912
+ const protectedKeys = new Set((await this.readImageCacheRecords())
913
+ .filter(record => activeDigests.has(record.imageDigest) || activeDigests.has(record.helperImageDigest))
914
+ .map(record => record.key));
915
+ for (const name of ["build-logs", "attestations"]) {
916
+ const root = path.join(this.config.sandboxStateRoot, name);
917
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
918
+ const records = [];
919
+ for (const entry of entries) {
920
+ if (!entry.isDirectory() || !/^[0-9a-f]{64}$/iu.test(entry.name))
921
+ continue;
922
+ const target = path.join(root, entry.name);
923
+ const stat = await fs.lstat(target).catch(() => null);
924
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
925
+ continue;
926
+ records.push({ key: entry.name.toLowerCase(), lastModifiedMs: stat.mtimeMs });
927
+ }
928
+ const candidates = selectAuditArtifactGcCandidates(records, Math.max(1, Math.floor(this.config.auditRetentionCount)), Math.max(60 * 60_000, this.config.auditRetentionTtlMs), protectedKeys);
929
+ for (const key of candidates) {
930
+ await fs.rm(path.join(root, key), { recursive: true, force: true }).catch(() => undefined);
931
+ }
932
+ }
933
+ }
934
+ docker(args, timeoutMs, signal, maxOutputBytes = 1024 * 1024) {
935
+ return requireSandboxProcess({ command: this.config.dockerExecutable, args: this.dockerArgs(args), timeoutMs, maxOutputBytes, signal });
936
+ }
937
+ dockerArgs(args) { return this.config.dockerContext ? ["--context", this.config.dockerContext, ...args] : [...args]; }
938
+ }
939
+ class HostGit {
940
+ config;
941
+ repoPath;
942
+ coordinator;
943
+ constructor(config, repoPath) {
944
+ this.config = config;
945
+ this.repoPath = repoPath;
946
+ this.coordinator = new RepositoryCoordinatorLock(config.sandboxStateRoot);
947
+ }
948
+ async prepare(dispatch, attemptRoot, signal) {
949
+ return await this.coordinator.withLock(this.repoPath, () => this.prepareLocked(dispatch, attemptRoot), signal);
950
+ }
951
+ async prepareLocked(dispatch, attemptRoot) {
952
+ const baseBranch = dispatch.cycleRun.baseBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch;
953
+ await this.git(["fetch", "--prune", "origin"], 5 * 60_000).catch(() => null);
954
+ const worktreeRef = `refs/heads/${dispatch.lease.worktreeKey}`;
955
+ const baseCandidates = [worktreeRef, `refs/remotes/origin/${baseBranch}`, `refs/heads/${baseBranch}`];
956
+ const inputCommitSha = await this.resolveFirst(baseCandidates);
957
+ const configSelection = selectSandboxConfigRefs(dispatch.sandbox.executionPolicy, dispatch.sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
958
+ const configCommitSha = await this.resolveFirst([...configSelection.refs]);
959
+ const approvedConfigSha = configSelection.approvedSha;
960
+ if (approvedConfigSha && configCommitSha !== approvedConfigSha) {
961
+ throw new Error("sandbox_config_commit_mismatch: approved sandbox config commit resolved to a different object.");
962
+ }
963
+ const contractSource = (await this.git(["show", `${configCommitSha}:.forge/forge.sandbox.yml`], 30_000)).stdout;
964
+ const dockerfile = (await this.git(["show", `${configCommitSha}:.forge/Dockerfile`], 30_000)).stdout;
965
+ const inputRef = `refs/forge/inputs/${dispatch.sandbox.dispatchAttemptId.replace(/-/g, "")}`;
966
+ const bundlePath = path.join(attemptRoot, "input", "repo.bundle");
967
+ await this.git(["update-ref", inputRef, inputCommitSha], 30_000);
968
+ try {
969
+ await this.git(["bundle", "create", bundlePath, inputRef], 5 * 60_000);
970
+ }
971
+ finally {
972
+ await this.git(["update-ref", "-d", inputRef], 30_000).catch(() => null);
973
+ }
974
+ const contextArchive = path.join(attemptRoot, "input", "context.tar");
975
+ await this.git(["archive", "--format=tar", "--output", contextArchive, configCommitSha], 5 * 60_000);
976
+ const contextRoot = path.join(attemptRoot, "context");
977
+ await fs.mkdir(contextRoot, { recursive: true });
978
+ await requireSandboxProcess({ command: "tar", args: ["-xf", contextArchive, "-C", contextRoot], timeoutMs: 5 * 60_000, maxOutputBytes: 1024 * 1024 });
979
+ const contextDigest = createHash("sha256").update(await fs.readFile(contextArchive)).digest("hex");
980
+ return { inputCommitSha, configCommitSha, contractSource, dockerfile, bundlePath, contextRoot, contextDigest };
981
+ }
982
+ async importResult(dispatch, output, assertCurrent) {
983
+ await this.coordinator.withLock(this.repoPath, () => this.importResultLocked(dispatch, output, assertCurrent));
984
+ }
985
+ async publishResult(dispatch, output, assertCurrent, credential, signal) {
986
+ await this.coordinator.withLock(this.repoPath, async () => {
987
+ const handle = dispatch.sandbox?.publishGrantHandle;
988
+ const resultCommitSha = output.manifest.resultCommitSha;
989
+ if (dispatch.sandbox?.publicationMode !== "bridge_push"
990
+ || !handle
991
+ || !resultCommitSha
992
+ || handle.branch !== dispatch.lease.worktreeKey
993
+ || !/^[0-9a-f-]{32,36}$/iu.test(handle.grantId)) {
994
+ throw new Error("sandbox_output_publish_forbidden: remote publication grant is missing or outside its attempt scope.");
995
+ }
996
+ assertCurrent();
997
+ const credentialEnvironment = publicationCredentialEnvironment(credential);
998
+ await this.git(["check-ref-format", "--branch", handle.branch], 30_000, signal);
999
+ const remoteRef = `refs/heads/${handle.branch}`;
1000
+ const remote = await this.git(["ls-remote", "--heads", credential.remoteUrl, remoteRef], 60_000, signal, credentialEnvironment);
1001
+ const remoteSha = remote.stdout.trim().split(/\s+/u)[0] ?? "";
1002
+ const pushArgs = publicationPushArgs(handle.branch, resultCommitSha, output.manifest.inputCommitSha, remoteSha, credential.remoteUrl);
1003
+ if (!pushArgs) {
1004
+ assertCurrent();
1005
+ return;
1006
+ }
1007
+ assertCurrent();
1008
+ await this.git(pushArgs, 5 * 60_000, signal, credentialEnvironment);
1009
+ assertCurrent();
1010
+ });
1011
+ }
1012
+ async importResultLocked(dispatch, output, assertCurrent) {
1013
+ if (!output.bundlePath || !output.manifest.resultCommitSha)
1014
+ return;
1015
+ await this.git(["bundle", "verify", output.bundlePath], 2 * 60_000);
1016
+ const attemptHex = dispatch.sandbox.dispatchAttemptId.replace(/-/g, "");
1017
+ const sourceRef = `refs/forge/results/${attemptHex}`;
1018
+ const importRef = `refs/forge/imports/${attemptHex}`;
1019
+ const quarantine = path.join(this.config.sandboxStateRoot, "quarantine", attemptHex);
1020
+ await fs.rm(quarantine, { recursive: true, force: true });
1021
+ await fs.mkdir(path.dirname(quarantine), { recursive: true, mode: 0o700 });
1022
+ await requireSandboxProcess({ command: "git", args: ["init", "--bare", quarantine], timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 });
1023
+ try {
1024
+ await quarantineGit(quarantine, ["fetch", "--no-write-fetch-head", output.bundlePath, `${sourceRef}:${importRef}`], 5 * 60_000);
1025
+ await quarantineGit(quarantine, ["fsck", "--strict", "--full", "--no-reflogs"], 5 * 60_000);
1026
+ const objectInventory = await runSandboxProcess({
1027
+ command: "git",
1028
+ args: ["-C", quarantine, "cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
1029
+ timeoutMs: 2 * 60_000,
1030
+ maxOutputBytes: 64 * 1024 * 1024,
1031
+ terminateOnOutputLimit: true
1032
+ });
1033
+ if (objectInventory.exitCode !== 0 || objectInventory.truncated) {
1034
+ throw new Error("sandbox_output_invalid: Git bundle exceeds object validation limits.");
1035
+ }
1036
+ validateGitObjectBudget(objectInventory.stdout);
1037
+ const imported = (await quarantineGit(quarantine, ["rev-parse", `${importRef}^{commit}`], 30_000)).stdout.trim();
1038
+ if (imported !== output.manifest.resultCommitSha)
1039
+ throw new Error("sandbox_output_invalid: imported result SHA differs from manifest.");
1040
+ const ancestry = await runSandboxProcess({ command: "git", args: ["-C", quarantine, "merge-base", "--is-ancestor", output.manifest.inputCommitSha, imported], timeoutMs: 30_000 });
1041
+ if (ancestry.exitCode !== 0)
1042
+ throw new Error("sandbox_output_invalid: result commit is not descended from input commit.");
1043
+ const commitDepth = await quarantineGit(quarantine, ["rev-list", "--count", `${output.manifest.inputCommitSha}..${imported}`], 2 * 60_000);
1044
+ validateGitCommitDepth(commitDepth.stdout);
1045
+ await this.git(["fetch", "--no-write-fetch-head", quarantine, `${importRef}:${importRef}`], 5 * 60_000);
1046
+ try {
1047
+ assertCurrent();
1048
+ await this.updateManagedBranch(dispatch.lease.worktreeKey, imported, output.manifest.inputCommitSha);
1049
+ }
1050
+ finally {
1051
+ await this.git(["update-ref", "-d", importRef], 30_000).catch(() => null);
1052
+ }
1053
+ }
1054
+ finally {
1055
+ await fs.rm(quarantine, { recursive: true, force: true }).catch(() => undefined);
1056
+ }
1057
+ }
1058
+ async updateManagedBranch(worktreeKey, imported, expected) {
1059
+ const branchRef = `refs/heads/${worktreeKey}`;
1060
+ const listing = (await this.git(["worktree", "list", "--porcelain"], 30_000)).stdout;
1061
+ const checkedOut = parseWorktreeListing(listing).find(entry => entry.branch === branchRef);
1062
+ if (!checkedOut) {
1063
+ await this.git(["update-ref", branchRef, imported, expected], 30_000);
1064
+ return;
1065
+ }
1066
+ const head = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "rev-parse", "HEAD"], timeoutMs: 30_000 })).stdout.trim();
1067
+ const status = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "status", "--porcelain=v1", "--untracked-files=no"], timeoutMs: 30_000, maxOutputBytes: 4 * 1024 * 1024 })).stdout;
1068
+ if (head !== expected || status.trim())
1069
+ throw new Error("sandbox_output_conflict: managed worktree changed after sandbox input was captured.");
1070
+ await this.git(["update-ref", branchRef, imported, expected], 30_000);
1071
+ await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "reset", "--hard"], timeoutMs: 2 * 60_000, maxOutputBytes: 4 * 1024 * 1024 });
1072
+ }
1073
+ async resolveFirst(refs) {
1074
+ for (const ref of refs) {
1075
+ const result = await runSandboxProcess({ command: "git", args: ["-C", this.repoPath, "rev-parse", "--verify", `${ref}^{commit}`], timeoutMs: 30_000 });
1076
+ const sha = result.stdout.trim();
1077
+ if (result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(sha))
1078
+ return sha;
1079
+ }
1080
+ throw new Error("sandbox_input_failed: no approved input Git ref could be resolved.");
1081
+ }
1082
+ git(args, timeoutMs, signal, env) { return requireSandboxProcess({ command: "git", args: ["-C", this.repoPath, ...args], timeoutMs, maxOutputBytes: 4 * 1024 * 1024, signal, env }); }
1083
+ }
1084
+ export function publicationPushArgs(branch, resultCommitSha, inputCommitSha, remoteCommitSha, remote = "origin") {
1085
+ const isCommit = (value) => /^[0-9a-f]{40,64}$/iu.test(value);
1086
+ if (!branch || branch.length > 240 || !isCommit(resultCommitSha) || !isCommit(inputCommitSha) || (remoteCommitSha && !isCommit(remoteCommitSha))) {
1087
+ throw new Error("sandbox_output_publish_rejected: publication inputs contain an invalid branch or object id.");
1088
+ }
1089
+ if (remoteCommitSha === resultCommitSha)
1090
+ return null;
1091
+ if (remoteCommitSha && remoteCommitSha !== inputCommitSha) {
1092
+ throw new Error("sandbox_output_publish_stale: remote branch changed after the sandbox input was captured.");
1093
+ }
1094
+ const remoteRef = `refs/heads/${branch}`;
1095
+ return [
1096
+ "push",
1097
+ "--porcelain",
1098
+ remote,
1099
+ `${resultCommitSha}:${remoteRef}`,
1100
+ `--force-with-lease=${remoteRef}:${remoteCommitSha}`
1101
+ ];
1102
+ }
1103
+ export function validatePublicationCredential(credential, nowMs = Date.now()) {
1104
+ const remote = (() => { try {
1105
+ return new URL(credential.remoteUrl);
1106
+ }
1107
+ catch {
1108
+ return null;
1109
+ } })();
1110
+ if (!remote
1111
+ || remote.protocol !== "https:"
1112
+ || remote.hostname.toLowerCase() !== "github.com"
1113
+ || remote.port
1114
+ || remote.username
1115
+ || remote.password
1116
+ || remote.search
1117
+ || remote.hash
1118
+ || !/^\/[a-z0-9_.-]+\/[a-z0-9_.-]+\.git$/iu.test(remote.pathname)
1119
+ || !/^[a-z0-9_-]{1,64}$/iu.test(credential.username)
1120
+ || !credential.secret
1121
+ || credential.secret.length > 16 * 1024
1122
+ || /[\r\n\0]/u.test(credential.secret)
1123
+ || Date.parse(credential.expiresAt) <= nowMs) {
1124
+ throw new Error("sandbox_output_publish_forbidden: redeemed publication credential is invalid or expired.");
1125
+ }
1126
+ }
1127
+ export function publicationCredentialEnvironment(credential, environment = process.env) {
1128
+ validatePublicationCredential(credential);
1129
+ const authorization = Buffer.from(`${credential.username}:${credential.secret}`, "utf8").toString("base64");
1130
+ return {
1131
+ ...environment,
1132
+ GIT_CONFIG_COUNT: "1",
1133
+ GIT_CONFIG_KEY_0: "http.https://github.com/.extraHeader",
1134
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${authorization}`,
1135
+ GIT_TERMINAL_PROMPT: "0",
1136
+ GCM_INTERACTIVE: "Never"
1137
+ };
1138
+ }
1139
+ export function restrictedBuildLog(source, exactSecrets = [], maximumBytes = 4 * 1024 * 1024) {
1140
+ const limit = Math.max(1024, Math.min(maximumBytes, 4 * 1024 * 1024));
1141
+ const redacted = Buffer.from(redactText(source, exactSecrets), "utf8");
1142
+ const truncated = redacted.length > limit;
1143
+ const retained = truncated ? redacted.subarray(0, limit) : redacted;
1144
+ return {
1145
+ content: retained.toString("utf8"),
1146
+ digest: `sha256:${createHash("sha256").update(retained).digest("hex")}`,
1147
+ byteCount: retained.length,
1148
+ truncated
1149
+ };
1150
+ }
1151
+ export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000, maximumExpandedBytes = 2 * 1024 * 1024 * 1024) {
1152
+ const lines = inventory.split(/\r?\n/u).filter(Boolean);
1153
+ if (lines.length === 0 || lines.length > maximumObjectCount) {
1154
+ throw new Error("sandbox_output_invalid: Git bundle exceeds the object-count budget.");
1155
+ }
1156
+ const maximum = BigInt(maximumExpandedBytes);
1157
+ let expanded = 0n;
1158
+ const objects = new Set();
1159
+ for (const line of lines) {
1160
+ const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (blob|tree|commit|tag) ([0-9]+)$/iu.exec(line);
1161
+ if (!match || objects.has(match[1].toLowerCase())) {
1162
+ throw new Error("sandbox_output_invalid: Git object inventory is malformed or duplicated.");
1163
+ }
1164
+ objects.add(match[1].toLowerCase());
1165
+ const size = BigInt(match[3]);
1166
+ expanded += size;
1167
+ if (size > maximum || expanded > maximum) {
1168
+ throw new Error("sandbox_output_invalid: Git bundle exceeds the expanded-object byte budget.");
1169
+ }
1170
+ }
1171
+ return { objectCount: lines.length, expandedBytes: Number(expanded) };
1172
+ }
1173
+ export function validateGitCommitDepth(value, maximumCommitDepth = 50_000) {
1174
+ const normalized = value.trim();
1175
+ if (!/^[0-9]+$/u.test(normalized)) {
1176
+ throw new Error("sandbox_output_invalid: Git commit-depth result is malformed.");
1177
+ }
1178
+ const depth = BigInt(normalized);
1179
+ if (depth > BigInt(maximumCommitDepth)) {
1180
+ throw new Error("sandbox_output_invalid: Git result exceeds the commit-depth budget.");
1181
+ }
1182
+ return Number(depth);
1183
+ }
1184
+ export function selectAuditArtifactGcCandidates(records, retentionCount, retentionTtlMs, protectedKeys = new Set(), nowMs = Date.now()) {
1185
+ const count = Math.max(1, Math.floor(retentionCount));
1186
+ const ttl = Math.max(60 * 60_000, retentionTtlMs);
1187
+ return records
1188
+ .filter(record => !protectedKeys.has(record.key))
1189
+ .sort((left, right) => right.lastModifiedMs - left.lastModifiedMs)
1190
+ .filter((record, index) => index >= count || nowMs - record.lastModifiedMs > ttl)
1191
+ .map(record => record.key);
1192
+ }
1193
+ export function selectSandboxConfigRefs(executionPolicy, configuredSha, defaultBranch) {
1194
+ const approvedSha = configuredSha?.trim().toLowerCase() || null;
1195
+ if (executionPolicy === "sandbox_required" && !approvedSha) {
1196
+ throw new Error("sandbox_config_commit_unapproved: sandbox_required dispatches need a server-approved config commit SHA.");
1197
+ }
1198
+ if (approvedSha && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(approvedSha)) {
1199
+ throw new Error("sandbox_config_commit_invalid: approved sandbox config commit is not a full Git object id.");
1200
+ }
1201
+ return approvedSha
1202
+ ? { refs: [approvedSha], approvedSha }
1203
+ : { refs: [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`], approvedSha: null };
1204
+ }
1205
+ export function sandboxTimeLimits(environment = process.env) {
1206
+ const hardWallSeconds = boundedInteger(environment.FORGE_SANDBOX_HARD_WALL_SECONDS, 8 * 60 * 60, 60, 8 * 60 * 60);
1207
+ const idleSeconds = boundedInteger(environment.FORGE_SANDBOX_IDLE_SECONDS, 30 * 60, 60, hardWallSeconds);
1208
+ return { hardWallMs: hardWallSeconds * 1000, idleMs: idleSeconds * 1000 };
1209
+ }
1210
+ function boundedInteger(value, fallback, minimum, maximum) {
1211
+ const parsed = Number(value);
1212
+ return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
1213
+ }
1214
+ function quarantineGit(repoPath, args, timeoutMs) {
1215
+ return requireSandboxProcess({
1216
+ command: "git",
1217
+ args: ["-C", repoPath, "-c", "transfer.fsckObjects=true", "-c", "fetch.fsckObjects=true", ...args],
1218
+ timeoutMs,
1219
+ maxOutputBytes: 16 * 1024 * 1024,
1220
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" }
1221
+ });
1222
+ }
1223
+ function parseWorktreeListing(value) {
1224
+ return value.trim().split(/\r?\n\r?\n/u).map(block => {
1225
+ const lines = block.split(/\r?\n/u);
1226
+ return {
1227
+ path: lines.find(line => line.startsWith("worktree "))?.slice(9) ?? "",
1228
+ branch: lines.find(line => line.startsWith("branch "))?.slice(7) ?? null
1229
+ };
1230
+ }).filter(entry => path.isAbsolute(entry.path));
1231
+ }
1232
+ async function validateOutput(attemptRoot, dispatch) {
1233
+ const outputRoot = path.join(attemptRoot, "output");
1234
+ const manifestPath = path.join(outputRoot, "manifest.json");
1235
+ const stat = await fs.lstat(manifestPath);
1236
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
1237
+ throw new Error("sandbox_output_invalid: manifest must be a bounded regular file.");
1238
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
1239
+ if (!dispatch.sandbox
1240
+ || manifest.dispatchAttemptId !== dispatch.sandbox.dispatchAttemptId
1241
+ || manifest.executionLeaseId !== dispatch.sandbox.executionLeaseId
1242
+ || manifest.fencingToken !== dispatch.lease.fencingToken
1243
+ || manifest.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
1244
+ || manifest.inputCommitSha !== dispatch.sandbox.inputCommitSha
1245
+ || manifest.branch !== dispatch.lease.worktreeKey
1246
+ || manifest.contractDigest !== dispatch.sandbox.contractDigest
1247
+ || manifest.imageDigest !== dispatch.sandbox.imageDigest)
1248
+ throw new Error("sandbox_output_invalid: manifest identity or fence mismatch.");
1249
+ if ((manifest.resultCommitSha === null) !== (manifest.bundle === null)
1250
+ || (manifest.resultCommitSha !== null && !/^[0-9a-f]{40,64}$/i.test(manifest.resultCommitSha))) {
1251
+ throw new Error("sandbox_output_invalid: result commit and bundle must be present together.");
1252
+ }
1253
+ const resultPath = await validateManifestFile(outputRoot, manifest.result, 16 * 1024 * 1024);
1254
+ const bundlePath = manifest.bundle ? await validateManifestFile(outputRoot, manifest.bundle, 512 * 1024 * 1024) : null;
1255
+ return { manifest, resultPath, bundlePath };
1256
+ }
1257
+ async function validateManifestFile(root, file, max) {
1258
+ if (!/^[a-z0-9_.-]+$/i.test(file.path))
1259
+ throw new Error("sandbox_output_invalid: output path is invalid.");
1260
+ const target = path.join(root, file.path);
1261
+ const stat = await fs.lstat(target);
1262
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.bytes || stat.size > max)
1263
+ throw new Error("sandbox_output_invalid: output file bounds mismatch.");
1264
+ const digest = createHash("sha256").update(await fs.readFile(target)).digest("hex");
1265
+ if (digest !== file.sha256)
1266
+ throw new Error("sandbox_output_invalid: output hash mismatch.");
1267
+ return target;
1268
+ }
1269
+ function identityFor(dispatch) {
1270
+ const value = dispatch.sandbox?.dispatchAttemptId.replace(/-/g, "").toLowerCase() ?? "";
1271
+ if (!/^[0-9a-f]{32}$/.test(value))
1272
+ throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
1273
+ return { attemptHex: value };
1274
+ }
1275
+ class SandboxTeardownError extends Error {
1276
+ report;
1277
+ constructor(report, message) {
1278
+ super(message);
1279
+ this.report = report;
1280
+ this.name = "SandboxTeardownError";
1281
+ }
1282
+ }
1283
+ function emptyCleanupReport(source) {
1284
+ return { reportId: randomUUID(), source, actions: [] };
1285
+ }
1286
+ function cleanupAction(labels, resourceType, resourceId, outcome, message = null) {
1287
+ return {
1288
+ dispatchAttemptId: labels["dev.forge.dispatch-attempt-id"] ?? "",
1289
+ fencingToken: labels["dev.forge.fencing-token"] ?? "",
1290
+ runnerInstanceId: labels["dev.forge.runner-instance-id"] ?? "",
1291
+ resourceType,
1292
+ resourceId: resourceId.slice(0, 255),
1293
+ outcome,
1294
+ message,
1295
+ occurredAt: new Date().toISOString()
1296
+ };
1297
+ }
1298
+ function cleanupErrorMessage(error) {
1299
+ return redactText(error instanceof Error ? error.message : String(error)).replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000)
1300
+ || "Docker resource removal failed.";
1301
+ }
1302
+ function parseCleanupReport(value) {
1303
+ if (!value || typeof value !== "object")
1304
+ return null;
1305
+ const report = value;
1306
+ 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))
1307
+ return null;
1308
+ if (report.source !== "teardown" && report.source !== "reaper")
1309
+ return null;
1310
+ if (!Array.isArray(report.actions) || report.actions.length > 128)
1311
+ return null;
1312
+ const actions = [];
1313
+ for (const candidate of report.actions) {
1314
+ if (!candidate || typeof candidate !== "object")
1315
+ return null;
1316
+ const action = candidate;
1317
+ if (typeof action.dispatchAttemptId !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.dispatchAttemptId)
1318
+ || typeof action.fencingToken !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.fencingToken)
1319
+ || typeof action.runnerInstanceId !== "string" || !/^[0-9a-f-]{32,36}$/iu.test(action.runnerInstanceId)
1320
+ || (action.resourceType !== "container" && action.resourceType !== "network" && action.resourceType !== "volume")
1321
+ || typeof action.resourceId !== "string" || action.resourceId.length === 0 || action.resourceId.length > 255 || hasControlCharacters(action.resourceId)
1322
+ || (action.outcome !== "removed" && action.outcome !== "failed")
1323
+ || (action.message != null && (typeof action.message !== "string" || action.message.length > 1000))
1324
+ || typeof action.occurredAt !== "string" || !Number.isFinite(Date.parse(action.occurredAt)))
1325
+ return null;
1326
+ actions.push(action);
1327
+ }
1328
+ return { reportId: report.reportId, source: report.source, actions };
1329
+ }
1330
+ function hasControlCharacters(value) {
1331
+ return [...value].some(character => {
1332
+ const code = character.codePointAt(0) ?? 0;
1333
+ return code < 32 || code === 127;
1334
+ });
1335
+ }
1336
+ export function assertSandboxImportCurrent(expected, current, signal, nowMs = Date.now()) {
1337
+ if (signal.aborted
1338
+ || !current
1339
+ || current.dispatchAttemptId !== expected.dispatchAttemptId
1340
+ || current.fencingToken !== expected.fencingToken
1341
+ || Date.parse(expected.expiresAt) <= nowMs) {
1342
+ throw new Error("sandbox_output_stale: output cannot move the managed ref because its attempt, fence, or lease is no longer current.");
1343
+ }
1344
+ }
1345
+ function validLedger(value) {
1346
+ return value?.schemaVersion === 1
1347
+ && /^[0-9a-f-]{32,36}$/i.test(value.dispatchAttemptId)
1348
+ && typeof value.fencingToken === "string"
1349
+ && typeof value.runnerInstanceId === "string"
1350
+ && Number.isFinite(Date.parse(value.expiresAt))
1351
+ && typeof value.resources?.network === "string"
1352
+ && value.labels?.["dev.forge.dispatch-attempt-id"] === value.dispatchAttemptId
1353
+ && value.labels?.["dev.forge.fencing-token"] === value.fencingToken
1354
+ && value.labels?.["dev.forge.runner-instance-id"] === value.runnerInstanceId;
1355
+ }
1356
+ 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}`])) }; }
1357
+ export function dockerObjectId(output, kind) {
1358
+ const id = output.trim();
1359
+ if (!/^[0-9a-f]{12,64}$/iu.test(id)) {
1360
+ throw new Error(`sandbox_create_failed: Docker returned an invalid ${kind} id.`);
1361
+ }
1362
+ return id.toLowerCase();
1363
+ }
1364
+ export function sandboxRuntimeVolumes(resources, includeEgressPolicy = true, includeCredentials = true) {
1365
+ return [
1366
+ { name: resources.workspaceVolume, role: "workspace" },
1367
+ { name: resources.controlVolume, role: "control" },
1368
+ { name: resources.secretsVolume, role: "secrets" },
1369
+ ...(includeCredentials ? [{ name: resources.credentialsVolume, role: "credentials" }] : []),
1370
+ ...(includeEgressPolicy ? [{ name: resources.egressConfigVolume, role: "egress_policy" }] : []),
1371
+ ...Object.entries(resources.cacheVolumes).map(([cache, name]) => ({ name, role: `cache:${cache}` }))
1372
+ ];
1373
+ }
1374
+ export function sandboxRuntimeCleanupVolumeNames(resources, includeCredentials = true) {
1375
+ return sandboxRuntimeVolumes(resources, true, includeCredentials)
1376
+ .map(volume => volume.name)
1377
+ .filter((name) => typeof name === "string" && name.length > 0);
1378
+ }
1379
+ function labelsFor(dispatch, image, contract) {
1380
+ return {
1381
+ "dev.forge.managed": "true", "dev.forge.session-id": dispatch.session.id, "dev.forge.execution-lease-id": dispatch.sandbox.executionLeaseId,
1382
+ "dev.forge.dispatch-attempt-id": dispatch.sandbox.dispatchAttemptId, "dev.forge.fencing-token": dispatch.lease.fencingToken,
1383
+ "dev.forge.runner-id": dispatch.execution.runnerId ?? "", "dev.forge.runner-instance-id": dispatch.sandbox.runnerInstanceId,
1384
+ "dev.forge.project": dispatch.repo.name.slice(0, 120), "dev.forge.image-digest": image, "dev.forge.contract-digest": contract,
1385
+ "dev.forge.initial-expires-at": dispatch.lease.expiresAt
1386
+ };
1387
+ }
1388
+ function labelArgs(labels) { return Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]); }
1389
+ function labelsMatch(actual, expected) { return Object.entries(expected).every(([key, value]) => actual[key] === value); }
1390
+ export function mayReapManagedResource(labels, runnerId, liveAttempts) {
1391
+ const attemptId = labels["dev.forge.dispatch-attempt-id"];
1392
+ return labels["dev.forge.managed"] === "true"
1393
+ && labels["dev.forge.runner-id"] === runnerId
1394
+ && Boolean(attemptId)
1395
+ && !liveAttempts.has(attemptId);
1396
+ }
1397
+ export function shouldReapSandboxLedger(ledger, currentRunnerInstanceId, serverAttempt, snapshotAvailable, nowMs = Date.now()) {
1398
+ if (ledger.runnerInstanceId !== currentRunnerInstanceId)
1399
+ return true;
1400
+ if (!snapshotAvailable)
1401
+ return Date.parse(ledger.expiresAt) <= nowMs;
1402
+ return !serverAttempt
1403
+ || serverAttempt.dispatchAttemptId !== ledger.dispatchAttemptId
1404
+ || serverAttempt.fencingToken !== ledger.fencingToken
1405
+ || serverAttempt.runnerInstanceId !== ledger.runnerInstanceId
1406
+ || Date.parse(serverAttempt.expiresAt) <= nowMs;
1407
+ }
1408
+ function dockerLabelValue(value) { return value.replace(/[\r\n\0]/gu, " ").trim().slice(0, 120) || "unknown"; }
1409
+ function imageLabelArgs(project, key, kind) {
1410
+ return [
1411
+ "--label", managedLabel,
1412
+ "--label", `dev.forge.project=${dockerLabelValue(project)}`,
1413
+ "--label", `dev.forge.image-key=${key}`,
1414
+ "--label", `dev.forge.image-kind=${kind}`
1415
+ ];
1416
+ }
1417
+ function validImageCacheRecord(value) {
1418
+ return value?.schemaVersion === 1
1419
+ && /^[0-9a-f]{64}$/i.test(value.key)
1420
+ && typeof value.project === "string"
1421
+ && Number.isFinite(Date.parse(value.createdAt))
1422
+ && Number.isFinite(Date.parse(value.lastUsedAt))
1423
+ && [value.projectTag, value.helperTag, value.overlayTag].every(tag => /^forge\/(?:project|tooling|agent):[0-9a-f]{64}$/i.test(tag))
1424
+ && /^sha256:[0-9a-f]{64}$/i.test(value.imageDigest)
1425
+ && /^sha256:[0-9a-f]{64}$/i.test(value.helperImageDigest)
1426
+ && /^[0-9a-f]{64}$/i.test(value.policyDigest);
1427
+ }
1428
+ export async function prepareOverlayBuildRoot(overlayRoot) {
1429
+ const buildRoot = path.dirname(overlayRoot);
1430
+ await fs.mkdir(buildRoot, { recursive: true, mode: 0o700 });
1431
+ await fs.chmod(buildRoot, 0o700);
1432
+ await fs.rm(overlayRoot, { recursive: true, force: true });
1433
+ await fs.mkdir(overlayRoot, { recursive: true, mode: 0o700 });
1434
+ await fs.chmod(overlayRoot, 0o700);
1435
+ const bridgeRoot = path.join(overlayRoot, "bridge");
1436
+ await fs.mkdir(bridgeRoot, { recursive: true, mode: 0o700 });
1437
+ await fs.chmod(bridgeRoot, 0o700);
1438
+ }
1439
+ export async function extractOciLayoutArchive(archivePath, destination, signal) {
1440
+ await fs.rm(destination, { recursive: true, force: true });
1441
+ await fs.mkdir(destination, { recursive: true, mode: 0o700 });
1442
+ await fs.chmod(destination, 0o700);
1443
+ const result = await runSandboxProcess({
1444
+ command: process.platform === "win32" ? "tar.exe" : "tar",
1445
+ args: ["-xf", archivePath, "-C", destination],
1446
+ timeoutMs: 5 * 60_000,
1447
+ maxOutputBytes: 1024 * 1024,
1448
+ signal
1449
+ });
1450
+ if (result.exitCode !== 0 || result.truncated) {
1451
+ throw new Error(`sandbox_build_failed: project OCI layout extraction failed with exit code ${result.exitCode}; ${formatOciExtractionFailure(result)}. Archive retained at ${archivePath}.`);
1452
+ }
1453
+ await fs.rm(archivePath, { force: true }).catch(() => undefined);
1454
+ }
1455
+ export function ociLayoutContextRef(layoutPath) {
1456
+ const absolute = /^[A-Za-z]:[\\/]/u.test(layoutPath) ? layoutPath : path.resolve(layoutPath);
1457
+ const normalized = absolute.replace(/\\/gu, "/");
1458
+ // BuildKit's oci-layout resolver expects a raw filesystem path here; it does not percent-decode
1459
+ // URI path escapes such as %20 before opening index.json.
1460
+ return normalized.startsWith("/") ? `oci-layout://${normalized}` : `oci-layout:///${normalized}`;
1461
+ }
1462
+ export function formatOciExtractionFailure(result) {
1463
+ const output = result.stderr.trim() || result.stdout.trim() || "no tar output";
1464
+ return `tar output: ${output.slice(0, 2048)}${result.truncated ? " (output truncated)" : ""}`;
1465
+ }
1466
+ export async function readOciLayoutImageDigest(layoutRoot) {
1467
+ const indexPath = path.join(layoutRoot, "index.json");
1468
+ const index = JSON.parse(await fs.readFile(indexPath, "utf8"));
1469
+ const manifest = index.manifests?.find(entry => typeof entry.digest === "string"
1470
+ && /^sha256:[0-9a-f]{64}$/iu.test(entry.digest)
1471
+ && entry.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest");
1472
+ if (!manifest || typeof manifest.digest !== "string") {
1473
+ throw new Error("sandbox_build_failed: project OCI layout did not expose an immutable image digest.");
1474
+ }
1475
+ return manifest.digest;
1476
+ }
1477
+ export async function removeExtractedOciLayout(layoutRoot) {
1478
+ await fs.rm(layoutRoot, { recursive: true, force: true });
1479
+ }
1480
+ export function ociLayoutScanTarget(layoutPath) {
1481
+ const absolute = /^[A-Za-z]:[\\/]/u.test(layoutPath) ? layoutPath : path.resolve(layoutPath);
1482
+ return `oci-dir:${absolute.replace(/\\/gu, "/")}`;
1483
+ }
1484
+ export function agentOverlayDockerignore() {
1485
+ return "project.oci.tar\nproject-oci\nproject-oci/**\n";
1486
+ }
1487
+ export function agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion) {
1488
+ return `FROM ${toolingImage} AS tooling
1489
+ WORKDIR /opt/forge-bridge
1490
+ COPY bridge/package*.json ./
1491
+ RUN ${bridgePackageInstallCommand(installMode, codexVersion)}
1492
+ COPY bridge/dist ./dist
1493
+ FROM ${projectContextName}
1494
+ USER 0
1495
+ COPY --from=tooling /usr/local/bin/node /usr/local/bin/node
1496
+ COPY --from=tooling /usr/local/bin/codex /usr/local/bin/codex
1497
+ COPY --from=tooling /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
1498
+ COPY --from=tooling /opt/forge-bridge /opt/forge-bridge
1499
+ RUN (getent group 10001 || groupadd -g 10001 forge) && (getent passwd 10001 || useradd -u 10001 -g 10001 -d /home/forge -m forge)
1500
+ ENV HOME=/home/forge TMPDIR=/tmp
1501
+ ENTRYPOINT []
1502
+ CMD []
1503
+ USER 10001:10001
1504
+ `;
1505
+ }
1506
+ export async function copyBridgePackageManifests(packageRoot, bridgeRoot) {
1507
+ await fs.copyFile(path.join(packageRoot, "package.json"), path.join(bridgeRoot, "package.json"));
1508
+ return await copyOptionalBridgeManifest(packageRoot, bridgeRoot, "package-lock.json") ? "ci" : "install";
1509
+ }
1510
+ export function bridgePackageInstallCommand(mode, codexVersion) {
1511
+ const bridgeInstall = mode === "ci"
1512
+ ? "npm ci --omit=dev --ignore-scripts"
1513
+ : "npm install --omit=dev --ignore-scripts";
1514
+ return `${bridgeInstall} && npm install --global @openai/codex@${codexVersion} && npm cache clean --force`;
1515
+ }
1516
+ async function copyOptionalBridgeManifest(packageRoot, bridgeRoot, fileName) {
1517
+ try {
1518
+ await fs.copyFile(path.join(packageRoot, fileName), path.join(bridgeRoot, fileName));
1519
+ return true;
1520
+ }
1521
+ catch (error) {
1522
+ if (error.code === "ENOENT")
1523
+ return false;
1524
+ throw error;
1525
+ }
1526
+ }
1527
+ export function buildxBuildArgs(builder, metadataFile, tail, environment = process.env, options = {}) {
1528
+ if (!/^forge-[0-9a-f]{20}$/u.test(builder))
1529
+ throw new Error("sandbox_build_failed: invalid dedicated builder name.");
1530
+ const resources = buildResourcePolicy(environment);
1531
+ const args = ["buildx", "build", "--builder", builder];
1532
+ if (options.load !== false)
1533
+ args.push("--load");
1534
+ args.push("--ulimit", `nofile=${resources.nofile}:${resources.nofile}`, "--shm-size", resources.sharedMemory, "--provenance=mode=max", "--sbom=true", "--metadata-file", metadataFile, ...tail);
1535
+ return args;
1536
+ }
1537
+ export function buildResourcePolicy(environment = process.env) {
1538
+ return {
1539
+ nofile: boundedInteger(environment.FORGE_SANDBOX_BUILD_NOFILE, 4_096, 256, 65_536),
1540
+ sharedMemory: "256m",
1541
+ builderPidsLimit: boundedInteger(environment.FORGE_SANDBOX_BUILDER_PIDS_LIMIT, 512, 128, 4_096)
1542
+ };
1543
+ }
1544
+ export function dedicatedBuildxBuilderName(runnerId, dockerContext) {
1545
+ const identity = `${runnerId}\0${dockerContext || "default"}`;
1546
+ return `forge-${createHash("sha256").update(identity).digest("hex").slice(0, 20)}`;
1547
+ }
1548
+ export function dedicatedBuildxBuilderContainerName(builder) {
1549
+ if (!/^forge-[0-9a-f]{20}$/u.test(builder))
1550
+ throw new Error("sandbox_build_failed: invalid dedicated builder name.");
1551
+ return `buildx_buildkit_${builder}0`;
1552
+ }
1553
+ function boundedBuildCacheSize(value, fallback) {
1554
+ const normalized = value?.trim().toLowerCase();
1555
+ const match = /^(\d+)(gb|mb)$/u.exec(normalized ?? "");
1556
+ if (!match)
1557
+ return fallback;
1558
+ const bytes = Number(match[1]) * (match[2] === "gb" ? 1024 ** 3 : 1024 ** 2);
1559
+ return bytes >= 512 * 1024 ** 2 && bytes <= 200 * 1024 ** 3 ? normalized : fallback;
1560
+ }
1561
+ export function selectImageGcCandidates(records, keepPerProject, ttlMs, nowMs) {
1562
+ const groups = new Map();
1563
+ for (const record of records)
1564
+ groups.set(record.project, [...(groups.get(record.project) ?? []), record]);
1565
+ const cutoff = nowMs - ttlMs;
1566
+ return [...groups.values()].flatMap(group => group
1567
+ .sort((left, right) => Date.parse(right.lastUsedAt) - Date.parse(left.lastUsedAt))
1568
+ .slice(Math.max(1, keepPerProject))
1569
+ .filter(record => Date.parse(record.lastUsedAt) <= cutoff));
1570
+ }
1571
+ async function sandboxProviderAuthForDispatch(dispatch) {
1572
+ const unavailable = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1573
+ if (unavailable) {
1574
+ throw new Error(`sandbox_auth_unavailable: ${unavailable}`);
1575
+ }
1576
+ const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
1577
+ return adapter ? adapter.prepareAttemptAuth() : null;
1578
+ }
1579
+ export function workerCreateArgs(dispatch, contract, resources, labels, image, securityOptions = resolveSandboxSecurityOptions(dispatch.sandbox.executionPolicy)) {
1580
+ const r = dispatch.sandbox.requestedResources;
1581
+ return ["create", "--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.memoryBytes), "--memory-swap", String(r.memoryBytes), "--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", "--volume", `${resources.workspaceVolume}:/workspace`, "--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_EXECUTION_LEASE_ID=${dispatch.sandbox.executionLeaseId}`, "--env", `FORGE_FENCING_TOKEN=${dispatch.lease.fencingToken}`, "--env", `FORGE_SANDBOX_ID=${dispatch.sandbox.dispatchAttemptId}`, "--env", "FORGE_WORKSPACE=/workspace", "--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"];
1582
+ }
1583
+ function providerAuthVolumeArgs(dispatch, resources) {
1584
+ const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
1585
+ if (!adapter) {
1586
+ return [];
1587
+ }
1588
+ return ["--volume", `${resources.credentialsVolume}:${adapter.containerMountPath}:${adapter.writable ? "rw" : "ro"}`];
1589
+ }
1590
+ export function cacheVolumeArgs(contract, resources) {
1591
+ const targets = {
1592
+ npm: "/home/forge/.npm", pnpm: "/home/forge/.local/share/pnpm/store", yarn: "/home/forge/.cache/yarn",
1593
+ nuget: "/home/forge/.nuget/packages", cargo: "/home/forge/.cargo/registry", pip: "/home/forge/.cache/pip"
1594
+ };
1595
+ return contract.contract.caches
1596
+ .filter(cache => cache.mode === "per_attempt")
1597
+ .flatMap(cache => ["--volume", `${resources.cacheVolumes[cache.name]}:${targets[cache.name]}`]);
1598
+ }
1599
+ export function proxyEnvironmentArgs(dispatch, contract, resources) {
1600
+ if (dispatch.sandbox.networkPolicy.mode !== "public_allowlist")
1601
+ return [];
1602
+ const bypass = ["localhost", "127.0.0.1", "::1", ...contract.contract.services.map(service => `${resources.prefix}-${service.type}`)].join(",");
1603
+ 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}`];
1604
+ }
1605
+ export function egressProxyConfig(allowedHosts) {
1606
+ const domains = allowedHosts.map(host => host.startsWith("*.") ? `.${host.slice(2)}` : host).join(" ");
1607
+ if (!domains)
1608
+ throw new Error("sandbox_egress_policy_invalid: public_allowlist requires at least one approved host.");
1609
+ return [
1610
+ "http_port 3128",
1611
+ "access_log none",
1612
+ "cache deny all",
1613
+ "acl SSL_ports port 443",
1614
+ "acl Safe_ports port 80 443",
1615
+ "acl CONNECT method CONNECT",
1616
+ "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",
1617
+ `acl forge_allowed dstdomain ${domains}`,
1618
+ "http_access deny private_dst",
1619
+ "http_access deny !Safe_ports",
1620
+ "http_access deny CONNECT !SSL_ports",
1621
+ "http_access allow forge_allowed",
1622
+ "http_access deny all",
1623
+ "shutdown_lifetime 1 seconds",
1624
+ "visible_hostname forge-egress"
1625
+ ].join("\n") + "\n";
1626
+ }
1627
+ export function serviceContainerArgs(service, image, resources, labels, securityOptions, environment = process.env) {
1628
+ const name = `${resources.prefix}-${service.type}`;
1629
+ const serviceUser = service.type === "browser" ? "10001:10001" : "999:999";
1630
+ 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"];
1631
+ if (service.type === "postgres") {
1632
+ 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");
1633
+ }
1634
+ else if (service.type === "redis") {
1635
+ 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");
1636
+ }
1637
+ else {
1638
+ const healthCommand = environment.FORGE_SERVICE_BROWSER_HEALTH_CMD?.trim();
1639
+ if (!healthCommand || /[\r\n\0]/u.test(healthCommand) || healthCommand.length > 1000) {
1640
+ throw new Error("sandbox_service_health_policy_invalid: browser service needs an operator-configured bounded health command.");
1641
+ }
1642
+ 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");
1643
+ }
1644
+ args.push(image);
1645
+ if (service.type === "redis")
1646
+ args.push("-c", "exec redis-server --requirepass \"$(cat /run/secrets/redis-password)\"");
1647
+ return args;
1648
+ }
1649
+ export function resolveSandboxSecurityOptions(executionPolicy, environment = process.env) {
1650
+ const seccomp = environment.FORGE_SANDBOX_SECCOMP_PROFILE?.trim();
1651
+ const lsm = environment.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim();
1652
+ if (seccomp && (!path.isAbsolute(seccomp) || /[\r\n\0]/u.test(seccomp))) {
1653
+ throw new Error("sandbox_security_profile_invalid: seccomp profile must be an absolute host path.");
1654
+ }
1655
+ if (lsm && !/^(?:apparmor=[a-zA-Z0-9_.-]{1,128}|label=[a-zA-Z0-9_:,.-]{1,256})$/u.test(lsm)) {
1656
+ throw new Error("sandbox_security_profile_invalid: LSM security option is invalid.");
1657
+ }
1658
+ if (executionPolicy === "sandbox_required" && (!seccomp || !lsm)) {
1659
+ throw new Error("sandbox_security_profile_unavailable: sandbox_required needs explicit seccomp and LSM profiles.");
1660
+ }
1661
+ return [
1662
+ ...(seccomp ? ["--security-opt", `seccomp=${seccomp}`] : []),
1663
+ ...(lsm ? ["--security-opt", lsm] : [])
1664
+ ];
1665
+ }
1666
+ export function networkCreateArgs(dispatch, environment = process.env) {
1667
+ const policy = dispatch.sandbox.networkPolicy;
1668
+ if (policy.mode === "open_public") {
1669
+ if (dispatch.sandbox.executionPolicy === "sandbox_required" || environment.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true") {
1670
+ throw new Error("policy_mismatch: open_public sandbox networking is not permitted by this runner policy.");
1671
+ }
1672
+ return [];
1673
+ }
1674
+ if (policy.mode === "public_allowlist" && policy.allowedHosts.length > 0) {
1675
+ if (environment.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
1676
+ throw new Error("policy_mismatch: public_allowlist requires an operator-enforced Forge egress proxy or firewall.");
1677
+ }
1678
+ return ["--internal"];
1679
+ }
1680
+ return ["--internal"];
1681
+ }
1682
+ 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 } }; }
1683
+ function serviceImage(type) { const key = `FORGE_SERVICE_${type.toUpperCase()}_IMAGE`; const image = process.env[key]; return image?.includes("@sha256:") ? image : null; }
1684
+ function event(eventType, content, data) {
1685
+ return {
1686
+ role: "runner",
1687
+ kind: /(?:failed|timeout|expired|exhausted|killed|rejected|invalid|unavailable|limit)/iu.test(eventType) ? "error" : "checkpoint",
1688
+ content,
1689
+ eventType,
1690
+ occurredAt: new Date().toISOString(),
1691
+ data: data ? JSON.stringify(data) : null
1692
+ };
1693
+ }
1694
+ export function classifySandboxRuntimeFailure(value) {
1695
+ const message = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000) || "Sandbox execution failed.";
1696
+ const explicit = /^([a-z][a-z0-9_.-]{2,79}):/u.exec(message)?.[1] ?? null;
1697
+ let code = explicit ?? "sandbox_runtime_failed";
1698
+ if (/no space left|disk quota exceeded|enospc/iu.test(message))
1699
+ code = "sandbox_storage_exhausted";
1700
+ const phase = code.startsWith("sandbox_build") || code.startsWith("image_") ? "build"
1701
+ : code.startsWith("sandbox_output") || code.startsWith("sandbox_import") ? "import"
1702
+ : code.startsWith("teardown") ? "teardown"
1703
+ : code.startsWith("sandbox_config") || code.startsWith("sandbox_auth") || code.startsWith("contract_") || code.startsWith("sandbox_input") || code.startsWith("sandbox_create") ? "prepare"
1704
+ : "agent";
1705
+ return { code, phase, message };
1706
+ }
1707
+ export function classifySandboxResourceFailure(value) {
1708
+ const diagnostic = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000);
1709
+ if (!diagnostic)
1710
+ return null;
1711
+ if (/(?:oomkilled|out of memory|memory cgroup out of memory|cannot allocate memory)/iu.test(diagnostic)) {
1712
+ return { code: "sandbox_oom_killed", message: "Sandbox exceeded its memory limit." };
1713
+ }
1714
+ if (/(?:resource temporarily unavailable|cannot fork|fork:\s*(?:retry|resource)|pthread_create[^.]*resource|process limit|pids? limit)/iu.test(diagnostic)) {
1715
+ return { code: "sandbox_pid_exhausted", message: "Sandbox exceeded its process/PID limit." };
1716
+ }
1717
+ if (/(?:no space left on device|disk quota exceeded|\benospc\b|workspace quota|storage limit)/iu.test(diagnostic)) {
1718
+ return { code: "sandbox_storage_exhausted", message: "Sandbox exceeded its workspace/storage limit." };
1719
+ }
1720
+ if (/(?:too many open files|\bemfile\b|\benfile\b)/iu.test(diagnostic)) {
1721
+ return { code: "sandbox_file_descriptor_exhausted", message: "Sandbox exceeded its file-descriptor limit." };
1722
+ }
1723
+ return null;
1724
+ }
1725
+ function parseSandboxEventLine(line) {
1726
+ if (!line.trim().startsWith("{"))
1727
+ return null;
1728
+ let value;
1729
+ try {
1730
+ value = JSON.parse(line);
1731
+ }
1732
+ catch {
1733
+ return null;
1734
+ }
1735
+ if (!value || typeof value !== "object" || value.type !== "runner_event")
1736
+ return null;
1737
+ const candidate = value.event;
1738
+ if (!candidate || typeof candidate !== "object")
1739
+ throw new Error("sandbox_event_invalid: runner event envelope is missing event data.");
1740
+ const eventValue = candidate;
1741
+ for (const field of ["role", "kind", "content", "eventType"]) {
1742
+ if (typeof eventValue[field] !== "string")
1743
+ throw new Error(`sandbox_event_invalid: ${field} must be a string.`);
1744
+ }
1745
+ if (eventValue.role.length > 64
1746
+ || eventValue.kind.length > 128
1747
+ || eventValue.eventType.length > 128
1748
+ || Buffer.byteLength(eventValue.content, "utf8") > 256 * 1024) {
1749
+ throw new Error("sandbox_event_invalid: runner event exceeds field bounds.");
1750
+ }
1751
+ if (eventValue.data !== undefined && eventValue.data !== null
1752
+ && (typeof eventValue.data !== "string" || Buffer.byteLength(eventValue.data, "utf8") > 256 * 1024)) {
1753
+ throw new Error("sandbox_event_invalid: runner event data exceeds field bounds.");
1754
+ }
1755
+ if (eventValue.occurredAt !== undefined && eventValue.occurredAt !== null
1756
+ && (typeof eventValue.occurredAt !== "string" || !Number.isFinite(Date.parse(eventValue.occurredAt)))) {
1757
+ throw new Error("sandbox_event_invalid: runner event occurredAt is invalid.");
1758
+ }
1759
+ return {
1760
+ role: eventValue.role,
1761
+ kind: eventValue.kind,
1762
+ content: eventValue.content,
1763
+ eventType: eventValue.eventType,
1764
+ occurredAt: eventValue.occurredAt,
1765
+ data: eventValue.data
1766
+ };
1767
+ }