@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.04a1e5b9e484

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