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

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