@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.41da3780c451

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 (165) hide show
  1. package/README.md +88 -2
  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 +4 -1
  5. package/dist/bridge/bridge.service.d.ts +72 -3
  6. package/dist/bridge/bridge.service.js +1563 -152
  7. package/dist/bridge/bridge.types.d.ts +46 -1
  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 +1 -0
  21. package/dist/cleanup/cleanup.service.js +70 -9
  22. package/dist/cli/cli.help.js +2 -2
  23. package/dist/cli/cli.service.d.ts +14 -6
  24. package/dist/cli/cli.service.js +75 -16
  25. package/dist/cli/local-config.d.ts +1 -0
  26. package/dist/cli/local-config.js +4 -0
  27. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  28. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  29. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  30. package/dist/runtime/attempt-database-broker.js +390 -0
  31. package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
  32. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  33. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  34. package/dist/runtime/attempt-path-operation.js +94 -0
  35. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  36. package/dist/runtime/attempt-private-directory.js +54 -0
  37. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  38. package/dist/runtime/attempt-process-supervisor.js +70 -22
  39. package/dist/runtime/canonical-source-file.d.ts +3 -0
  40. package/dist/runtime/canonical-source-file.js +54 -0
  41. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  42. package/dist/runtime/docker-engine-probe.js +333 -0
  43. package/dist/runtime/generated-output-reset.d.ts +10 -0
  44. package/dist/runtime/generated-output-reset.js +62 -8
  45. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  46. package/dist/runtime/host-database-capability-boundary.js +62 -0
  47. package/dist/runtime/host-diagnostics.d.ts +11 -0
  48. package/dist/runtime/host-diagnostics.js +192 -0
  49. package/dist/runtime/host-execution-root.d.ts +4 -0
  50. package/dist/runtime/host-execution-root.js +39 -0
  51. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  52. package/dist/runtime/host-listener-inspector.js +121 -60
  53. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  54. package/dist/runtime/host-runtime-contract.js +168 -55
  55. package/dist/runtime/host-runtime-lease.d.ts +92 -8
  56. package/dist/runtime/host-runtime-lease.js +1141 -114
  57. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  58. package/dist/runtime/host-runtime-promotion.js +59 -12
  59. package/dist/runtime/memory-capacity.d.ts +70 -0
  60. package/dist/runtime/memory-capacity.js +123 -0
  61. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  62. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  63. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  64. package/dist/runtime/node-host-runtime-adapter.js +29 -11
  65. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  66. package/dist/runtime/pinned-directory-process.js +61 -0
  67. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  68. package/dist/runtime/pinned-source-directory.js +94 -0
  69. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  70. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  71. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  72. package/dist/runtime/prepared-runtime-identity.js +218 -0
  73. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  74. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  75. package/dist/runtime/runtime-isolation-lease.js +4 -1
  76. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  77. package/dist/runtime/workspace-resource-estimator.js +174 -0
  78. package/dist/sandbox/preflight.d.ts +31 -1
  79. package/dist/sandbox/preflight.js +237 -51
  80. package/dist/sandbox/repository-lock.d.ts +2 -1
  81. package/dist/sandbox/repository-lock.js +19 -9
  82. package/dist/sandbox/runtime.d.ts +6 -1
  83. package/dist/sandbox/runtime.js +137 -107
  84. package/dist/sandbox/sandbox.types.d.ts +7 -0
  85. package/dist/sandbox/workspace.service.js +2 -0
  86. package/dist/session/managed-attempt-tools.d.ts +17 -1
  87. package/dist/session/managed-attempt-tools.js +489 -34
  88. package/dist/session/managed-build-coordinator.d.ts +5 -0
  89. package/dist/session/managed-build-coordinator.js +106 -0
  90. package/dist/session/managed-build-receipt.d.ts +21 -0
  91. package/dist/session/managed-build-receipt.js +337 -0
  92. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  93. package/dist/session/managed-verification-timeouts.js +17 -0
  94. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  95. package/dist/session/phase-completion.artifacts.js +224 -22
  96. package/dist/session/phase-completion.d.ts +11 -1
  97. package/dist/session/phase-completion.helpers.d.ts +5 -0
  98. package/dist/session/phase-completion.helpers.js +30 -7
  99. package/dist/session/phase-completion.js +14 -3
  100. package/dist/session/phase-completion.prompts.js +17 -18
  101. package/dist/session/phase-completion.runtime.d.ts +3 -1
  102. package/dist/session/phase-completion.runtime.js +126 -17
  103. package/dist/session/phase-completion.schemas.js +49 -2
  104. package/dist/session/phase-completion.validation.d.ts +1 -0
  105. package/dist/session/phase-completion.validation.js +111 -21
  106. package/dist/session/pre-publication-validation.d.ts +75 -0
  107. package/dist/session/pre-publication-validation.js +661 -0
  108. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  109. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  110. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  111. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  112. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  113. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  114. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  115. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  116. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  117. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  118. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  119. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  120. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  121. package/dist/session/provider-cli/provider-cli.runtime.d.ts +14 -1
  122. package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
  123. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  124. package/dist/session/provider-cli/provider-cli.service.js +773 -69
  125. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  126. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  127. package/dist/session/provider-cli/provider-output-state.js +361 -17
  128. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  129. package/dist/session/provider-cli/provider-usage.js +28 -5
  130. package/dist/session/session-redaction.js +434 -9
  131. package/dist/session/session.service.js +145 -23
  132. package/dist/session/session.types.d.ts +147 -2
  133. package/dist/session/session.types.js +587 -7
  134. package/dist/session/unreferenced-exports.d.ts +12 -0
  135. package/dist/session/unreferenced-exports.js +59 -0
  136. package/dist/shared/format/format-bytes.d.ts +1 -0
  137. package/dist/shared/format/format-bytes.js +9 -0
  138. package/dist/shared/git/git.js +2 -1
  139. package/dist/shared/path/path.d.ts +1 -0
  140. package/dist/shared/path/path.js +18 -0
  141. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  142. package/dist/shared/process/dotnet-toolchain.js +307 -0
  143. package/dist/shared/process/host-process-recovery.js +3 -2
  144. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  145. package/dist/shared/process/host-process-supervisor.js +60 -11
  146. package/dist/shared/process/process.d.ts +2 -0
  147. package/dist/shared/process/process.js +12 -7
  148. package/dist/shared/provider-capabilities.d.ts +3 -0
  149. package/dist/shared/provider-capabilities.js +85 -0
  150. package/dist/shared/redaction/redaction.d.ts +1 -0
  151. package/dist/shared/redaction/redaction.js +39 -3
  152. package/dist/shared/time/monotonic.d.ts +1 -0
  153. package/dist/shared/time/monotonic.js +8 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/dist/worktree/dependency-warmer.d.ts +41 -9
  157. package/dist/worktree/dependency-warmer.js +497 -22
  158. package/dist/worktree/index.d.ts +1 -1
  159. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  160. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  161. package/dist/worktree/worktree.service.d.ts +7 -0
  162. package/dist/worktree/worktree.service.js +253 -21
  163. package/dist/worktree/worktree.types.d.ts +7 -1
  164. package/package.json +7 -5
  165. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { failureResult } from "../session/session.types.js";
5
+ import { createFailureEnvelope, ForgeFailureError, failureFactsForCode, failurePolicyForCode, failureResult, parseRunOnceResultPacket, } from "../session/session.types.js";
6
6
  import { dockerfileUpstreamImages, validateDockerfilePolicy, validateSandboxContract } from "./contract.js";
7
7
  import { requireSandboxProcess, runSandboxProcess } from "./process.js";
8
8
  import { createLeaseUpdate } from "./lease-watchdog.js";
@@ -12,6 +12,7 @@ import { RepositoryCoordinatorLock } from "./repository-lock.js";
12
12
  import { redactText } from "../shared/redaction/redaction.js";
13
13
  import { createRuntimeIsolationLease, renewRuntimeIsolationLease, transitionRuntimeIsolationLease, validRuntimeIsolationLease, withRuntimeIsolationResources, } from "../runtime/runtime-isolation-lease.js";
14
14
  import { sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "./provider-auth/codex.js";
15
+ import { monotonicNowMs } from "../shared/time/monotonic.js";
15
16
  const managedLabel = "dev.forge.managed=true";
16
17
  const bridgeRuntimeDigestLabel = "dev.forge.bridge-runtime-digest";
17
18
  export class DockerSandboxRuntime {
@@ -85,7 +86,7 @@ export class DockerSandboxRuntime {
85
86
  await fs.mkdir(path.join(attemptRoot, "output"), { recursive: true, mode: 0o700 });
86
87
  const repoPath = dispatch.repo.serverLocalPath ? path.resolve(dispatch.repo.serverLocalPath) : null;
87
88
  if (!repoPath)
88
- throw new Error("sandbox_input_failed: bridge repository mapping is required for bundle handoff.");
89
+ throw trustedSandboxFailure("sandbox_input_failed", "bridge repository mapping is required for bundle handoff.");
89
90
  await sink.emit(event("sandbox_preparing", `Preparing Docker sandbox attempt ${dispatch.sandbox.dispatchAttemptId}.`));
90
91
  const git = new HostGit(this.config, repoPath);
91
92
  const prepared = await git.prepare(dispatch, attemptRoot, runtimeSignal);
@@ -100,7 +101,7 @@ export class DockerSandboxRuntime {
100
101
  const providerAuthAdapter = sandboxProviderAuthAdapterFor(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
101
102
  const providerAuthUnavailable = sandboxProviderAuthUnavailableMessage(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
102
103
  if (providerAuthUnavailable) {
103
- throw new Error(`sandbox_auth_unavailable: ${providerAuthUnavailable}`);
104
+ throw trustedSandboxFailure("sandbox_auth_unavailable", providerAuthUnavailable);
104
105
  }
105
106
  await sink.emit(event("sandbox_metadata", "Resolved immutable sandbox inputs.", {
106
107
  inputCommitSha: prepared.inputCommitSha,
@@ -125,6 +126,7 @@ export class DockerSandboxRuntime {
125
126
  await this.writeLedger(active.ledger);
126
127
  let teardownComplete = false;
127
128
  let memoryTelemetryEvent = null;
129
+ let trustedHostFailure = null;
128
130
  try {
129
131
  const internalNetworkId = dockerObjectId((await this.docker(["network", "create", ...networkArgs, ...labelArgs(labels), resources.network], 30_000, runtimeSignal)).stdout, "network");
130
132
  await this.docker(["volume", "create", ...labelArgs(labels), resources.workspaceVolume], 30_000, runtimeSignal);
@@ -217,19 +219,18 @@ export class DockerSandboxRuntime {
217
219
  await sink.emit(memoryTelemetryEvent).catch(() => undefined);
218
220
  });
219
221
  if (start.truncated)
220
- throw new Error("sandbox_log_limit: sandbox output exceeded the 8 MiB supervisor capture budget.");
222
+ throw trustedSandboxFailure("sandbox_log_limit", "sandbox output exceeded the 8 MiB supervisor capture budget.");
221
223
  const containerState = await this.inspectContainerState(containerId);
222
224
  if (containerState.oomKilled) {
223
- throw new Error("sandbox_oom_killed: Docker terminated the agent after it exceeded the memory limit.");
225
+ trustedHostFailure = {
226
+ code: "sandbox_oom_killed",
227
+ message: "Docker terminated the agent after it exceeded the memory limit.",
228
+ };
224
229
  }
225
- if (start.exitCode !== 0) {
226
- const resourceFailure = classifySandboxResourceFailure([
227
- containerState.error,
228
- start.stderr,
229
- start.stdout
230
- ].filter(Boolean).join("\n"));
231
- if (resourceFailure)
232
- throw new Error(`${resourceFailure.code}: ${resourceFailure.message}`);
230
+ if (start.exitCode !== 0 && !trustedHostFailure) {
231
+ // Docker's inspect state is trusted host telemetry. Child stdout/stderr is
232
+ // evidence only and must not be able to choose a machine failure code.
233
+ trustedHostFailure = classifySandboxResourceFailure(containerState.error ?? "");
233
234
  }
234
235
  active.phase = "active";
235
236
  await sink.emit(event("sandbox_agent_exited", `Sandbox worker exited with code ${start.exitCode}.`, {
@@ -250,7 +251,7 @@ export class DockerSandboxRuntime {
250
251
  if (runtimeDispatch.sandbox.publicationMode === "bridge_push") {
251
252
  const handle = runtimeDispatch.sandbox.publishGrantHandle;
252
253
  if (!handle || !output.manifest.resultCommitSha || !this.publicationGrantRedeemer) {
253
- throw new Error("sandbox_output_publish_forbidden: bridge publication grant redemption is unavailable.");
254
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "bridge publication grant redemption is unavailable.");
254
255
  }
255
256
  const credential = await this.publicationGrantRedeemer({
256
257
  dispatchAttemptId: runtimeDispatch.sandbox.dispatchAttemptId,
@@ -269,29 +270,26 @@ export class DockerSandboxRuntime {
269
270
  resultCommitSha: output.manifest.resultCommitSha
270
271
  }));
271
272
  }
272
- const result = JSON.parse(await fs.readFile(output.resultPath, "utf8"));
273
- if (result.exitCode !== 0) {
274
- const resourceFailure = classifySandboxResourceFailure([
275
- result.summary,
276
- ...result.events.flatMap(resultEvent => [resultEvent.eventType, resultEvent.content, resultEvent.data ?? ""])
277
- ].join("\n"));
278
- if (resourceFailure) {
279
- return appendRunOnceEvent({
280
- ...result,
281
- summary: resourceFailure.message,
282
- events: [...result.events, event(resourceFailure.code, resourceFailure.message, { phase: "agent" })]
283
- }, memoryTelemetryEvent);
284
- }
285
- }
273
+ const rawResult = parseRunOnceResultPacket(JSON.parse(await fs.readFile(output.resultPath, "utf8")));
274
+ const result = {
275
+ ...rawResult,
276
+ resultCommitSha: output.manifest.resultCommitSha ?? rawResult.resultCommitSha ?? null,
277
+ publishedCommitSha: runtimeDispatch.sandbox.publicationMode === "bridge_push"
278
+ ? output.manifest.resultCommitSha
279
+ : rawResult.publishedCommitSha ?? null,
280
+ };
281
+ const canonicalResult = applyTrustedHostFailure(result, trustedHostFailure);
286
282
  await sink.emit(event("sandbox_output_imported", "Validated and imported sandbox output.", {
287
283
  resultCommitSha: output.manifest.resultCommitSha,
288
284
  }));
289
- return appendRunOnceEvent(result, memoryTelemetryEvent);
285
+ return appendRunOnceEvent(canonicalResult, memoryTelemetryEvent);
290
286
  }
291
287
  catch (error) {
292
- const message = error instanceof Error ? error.message : String(error);
293
- const classified = classifySandboxRuntimeFailure(message);
294
- return appendRunOnceEvent(failureResult(classified.message, [event(classified.code, classified.message, { phase: classified.phase })]), memoryTelemetryEvent);
288
+ const effectiveError = trustedHostFailure
289
+ ? new ForgeFailureError(trustedHostFailure.code, trustedHostFailure.message, "sandbox", { cause: error })
290
+ : error;
291
+ const classified = classifySandboxRuntimeFailure(effectiveError);
292
+ return appendRunOnceEvent(failureResult(classified.message, [event(classified.code, classified.message, { phase: classified.phase })], failureFactsForCode(classified.code, "sandbox")), memoryTelemetryEvent);
295
293
  }
296
294
  finally {
297
295
  try {
@@ -525,7 +523,7 @@ export class DockerSandboxRuntime {
525
523
  ledgerPath(dispatchAttemptId) {
526
524
  const attemptHex = dispatchAttemptId.replace(/-/g, "").toLowerCase();
527
525
  if (!/^[0-9a-f]{32}$/.test(attemptHex))
528
- throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
526
+ throw trustedSandboxFailure("sandbox_identity_invalid", "dispatch attempt id must be a full UUID.");
529
527
  return path.join(this.config.sandboxStateRoot, "ledger", `${attemptHex}.json`);
530
528
  }
531
529
  async removeLedger(dispatchAttemptId) {
@@ -534,11 +532,11 @@ export class DockerSandboxRuntime {
534
532
  async buildImage(contextRoot, contextDigest, project, contract, upstreamImages, policy, sink, signal) {
535
533
  const toolingImage = process.env.FORGE_AGENT_TOOLING_IMAGE ?? "node:22.17.0-bookworm-slim";
536
534
  if (policy === "sandbox_required" && !/@sha256:[0-9a-f]{64}$/i.test(toolingImage)) {
537
- throw new Error("sandbox_build_failed: FORGE_AGENT_TOOLING_IMAGE must be digest-pinned for sandbox_required.");
535
+ throw trustedSandboxFailure("sandbox_build_failed", "FORGE_AGENT_TOOLING_IMAGE must be digest-pinned for sandbox_required.");
538
536
  }
539
537
  const codexVersion = process.env.FORGE_CODEX_PACKAGE_VERSION ?? "0.144.1";
540
538
  if (!/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(codexVersion))
541
- throw new Error("sandbox_build_failed: invalid pinned Codex package version.");
539
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid pinned Codex package version.");
542
540
  const policyConfigurationDigest = await sandboxImagePolicyConfigurationDigest();
543
541
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
544
542
  const bridgeRuntimeDigest = await bridgeRuntimeContentDigest(packageRoot);
@@ -557,10 +555,10 @@ export class DockerSandboxRuntime {
557
555
  const existing = this.builds.get(key);
558
556
  if (existing) {
559
557
  await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} is being built by another dispatch.`, { cacheKey: key, singleflight: true }));
560
- const waitStartedAt = Date.now();
558
+ const waitStartedAt = monotonicNowMs();
561
559
  const built = await existing;
562
560
  await sink.emit(event("sandbox_build_singleflight_wait", `Waited for the existing sandbox image build ${key.slice(0, 12)}.`, {
563
- durationMs: Date.now() - waitStartedAt,
561
+ durationMs: Math.round(monotonicNowMs() - waitStartedAt),
564
562
  imageDigest: built.imageDigest
565
563
  }));
566
564
  return built;
@@ -631,7 +629,7 @@ export class DockerSandboxRuntime {
631
629
  }
632
630
  await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} requires a build.`, { cacheKey: key }));
633
631
  if (policy === "sandbox_required" && process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED !== "true") {
634
- throw new Error("sandbox_build_policy_failed: sandbox_required builds need an operator-enforced builder egress policy.");
632
+ throw trustedSandboxFailure("sandbox_build_policy_failed", "sandbox_required builds need an operator-enforced builder egress policy.");
635
633
  }
636
634
  const buildStartedAt = Date.now();
637
635
  await sink.emit(event("sandbox_build_started", `Building sandbox image ${key.slice(0, 12)}.`, { cacheKey: key }));
@@ -658,7 +656,7 @@ export class DockerSandboxRuntime {
658
656
  const installMode = await copyBridgePackageManifests(packageRoot, stagedBridgeRoot);
659
657
  const stagedBridgeRuntimeDigest = await bridgeRuntimeContentDigest(stagedBridgeRoot);
660
658
  if (stagedBridgeRuntimeDigest !== bridgeRuntimeDigest) {
661
- throw new Error("sandbox_build_failed: bridge runtime payload changed while preparing the sandbox image; retry the dispatch.");
659
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime payload changed while preparing the sandbox image; retry the dispatch.");
662
660
  }
663
661
  const dockerfile = agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion);
664
662
  await fs.writeFile(path.join(overlayRoot, "Dockerfile"), dockerfile);
@@ -666,7 +664,7 @@ export class DockerSandboxRuntime {
666
664
  await this.runBuildStage("tooling", key, buildxBuildArgs(builder, path.join(metadataRoot, "tooling.json"), ["--pull=false", "--target", "tooling", ...imageLabelArgs(project, key, "tooling", bridgeRuntimeDigest), "--tag", helperTag, overlayRoot]), sink, signal);
667
665
  const helperImageDigest = (await this.docker(["image", "inspect", helperTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
668
666
  if (!/^sha256:[0-9a-f]{64}$/i.test(helperImageDigest))
669
- throw new Error("sandbox_build_failed: helper image did not resolve to an immutable digest.");
667
+ throw trustedSandboxFailure("sandbox_build_failed", "helper image did not resolve to an immutable digest.");
670
668
  const projectContextRef = ociLayoutContextRef(path.relative(overlayRoot, projectOciRoot));
671
669
  await this.runBuildStage("agent", key, buildxBuildArgs(builder, path.join(metadataRoot, "agent.json"), [
672
670
  "--pull=false",
@@ -677,7 +675,7 @@ export class DockerSandboxRuntime {
677
675
  ]), sink, signal, { cwd: overlayRoot });
678
676
  const digest = (await this.docker(["image", "inspect", overlayTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
679
677
  if (!/^sha256:[0-9a-f]{64}$/i.test(digest))
680
- throw new Error("sandbox_build_failed: overlay image did not resolve to an immutable digest.");
678
+ throw trustedSandboxFailure("sandbox_build_failed", "overlay image did not resolve to an immutable digest.");
681
679
  const imagePolicy = await evaluateSandboxImagePolicy({
682
680
  key,
683
681
  project: dockerLabelValue(project),
@@ -752,7 +750,7 @@ export class DockerSandboxRuntime {
752
750
  byteCount: artifact.byteCount,
753
751
  truncated: artifact.truncated || result.truncated
754
752
  })).catch(() => undefined);
755
- throw new Error(`sandbox_build_failed: ${stage} image build failed; restricted log digest ${artifact.digest}.`);
753
+ throw trustedSandboxFailure("sandbox_build_failed", `${stage} image build failed; restricted log digest ${artifact.digest}.`);
756
754
  }
757
755
  async ensureBuildxBuilder(signal) {
758
756
  const name = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
@@ -775,7 +773,7 @@ export class DockerSandboxRuntime {
775
773
  if (Number(hostConfig.PidsLimit) !== policy.builderPidsLimit
776
774
  || Number(hostConfig.Memory) !== policy.builderMemoryBytes
777
775
  || Number(hostConfig.MemorySwap) !== policy.builderMemoryBytes) {
778
- throw new Error("sandbox_build_policy_failed: dedicated BuildKit builder resource limits were not applied.");
776
+ throw trustedSandboxFailure("sandbox_build_policy_failed", "dedicated BuildKit builder resource limits were not applied.");
779
777
  }
780
778
  return name;
781
779
  }
@@ -848,10 +846,10 @@ export class DockerSandboxRuntime {
848
846
  return null;
849
847
  const image = process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim();
850
848
  if (!image || !/@sha256:[0-9a-f]{64}$/iu.test(image)) {
851
- throw new Error("sandbox_egress_proxy_unavailable: public_allowlist needs a digest-pinned Squid-compatible proxy image.");
849
+ throw trustedSandboxFailure("sandbox_egress_proxy_unavailable", "public_allowlist needs a digest-pinned Squid-compatible proxy image.");
852
850
  }
853
851
  if (process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
854
- throw new Error("sandbox_egress_proxy_unavailable: runner egress enforcement is not enabled.");
852
+ throw trustedSandboxFailure("sandbox_egress_proxy_unavailable", "runner egress enforcement is not enabled.");
855
853
  }
856
854
  await this.verifyCatalogImage(image, dispatch.sandbox.executionPolicy, sink, signal);
857
855
  const name = `${resources.prefix}-egress`;
@@ -892,8 +890,8 @@ export class DockerSandboxRuntime {
892
890
  await sink.emit(event("sandbox_image_signature_warning", `Image signature enforcement is not configured for adoption-mode image ${image}.`));
893
891
  }
894
892
  async waitForServiceHealthy(name, signal) {
895
- const deadline = Date.now() + 60_000;
896
- while (Date.now() < deadline) {
893
+ const deadline = monotonicNowMs() + 60_000;
894
+ while (monotonicNowMs() < deadline) {
897
895
  if (signal?.aborted)
898
896
  throw new Error(String(signal.reason ?? "sandbox service startup cancelled"));
899
897
  const status = (await this.docker(["container", "inspect", name, "--format", "{{.State.Health.Status}}"], 10_000, signal)).stdout.trim();
@@ -1162,7 +1160,7 @@ class HostGit {
1162
1160
  || !resultCommitSha
1163
1161
  || handle.branch !== dispatch.lease.worktreeKey
1164
1162
  || !/^[0-9a-f-]{32,36}$/iu.test(handle.grantId)) {
1165
- throw new Error("sandbox_output_publish_forbidden: remote publication grant is missing or outside its attempt scope.");
1163
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "remote publication grant is missing or outside its attempt scope.");
1166
1164
  }
1167
1165
  assertCurrent();
1168
1166
  const credentialEnvironment = publicationCredentialEnvironment(credential);
@@ -1202,15 +1200,15 @@ class HostGit {
1202
1200
  terminateOnOutputLimit: true
1203
1201
  });
1204
1202
  if (objectInventory.exitCode !== 0 || objectInventory.truncated) {
1205
- throw new Error("sandbox_output_invalid: Git bundle exceeds object validation limits.");
1203
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds object validation limits.");
1206
1204
  }
1207
1205
  validateGitObjectBudget(objectInventory.stdout);
1208
1206
  const imported = (await quarantineGit(quarantine, ["rev-parse", `${importRef}^{commit}`], 30_000)).stdout.trim();
1209
1207
  if (imported !== output.manifest.resultCommitSha)
1210
- throw new Error("sandbox_output_invalid: imported result SHA differs from manifest.");
1208
+ throw trustedSandboxFailure("sandbox_output_invalid", "imported result SHA differs from manifest.");
1211
1209
  const ancestry = await runSandboxProcess({ command: "git", args: ["-C", quarantine, "merge-base", "--is-ancestor", output.manifest.inputCommitSha, imported], timeoutMs: 30_000 });
1212
1210
  if (ancestry.exitCode !== 0)
1213
- throw new Error("sandbox_output_invalid: result commit is not descended from input commit.");
1211
+ throw trustedSandboxFailure("sandbox_output_invalid", "result commit is not descended from input commit.");
1214
1212
  const commitDepth = await quarantineGit(quarantine, ["rev-list", "--count", `${output.manifest.inputCommitSha}..${imported}`], 2 * 60_000);
1215
1213
  validateGitCommitDepth(commitDepth.stdout);
1216
1214
  await this.git(["fetch", "--no-write-fetch-head", quarantine, `${importRef}:${importRef}`], 5 * 60_000);
@@ -1238,7 +1236,7 @@ class HostGit {
1238
1236
  const head = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "rev-parse", "HEAD"], timeoutMs: 30_000 })).stdout.trim();
1239
1237
  const status = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "status", "--porcelain=v1", "--untracked-files=no"], timeoutMs: 30_000, maxOutputBytes: 4 * 1024 * 1024 })).stdout;
1240
1238
  if (head !== expected || status.trim())
1241
- throw new Error("sandbox_output_conflict: managed worktree changed after sandbox input was captured.");
1239
+ throw trustedSandboxFailure("sandbox_output_conflict", "managed worktree changed after sandbox input was captured.");
1242
1240
  await this.git(["update-ref", branchRef, imported, expected], 30_000);
1243
1241
  await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "reset", "--hard"], timeoutMs: 2 * 60_000, maxOutputBytes: 4 * 1024 * 1024 });
1244
1242
  }
@@ -1249,7 +1247,7 @@ class HostGit {
1249
1247
  if (result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(sha))
1250
1248
  return sha;
1251
1249
  }
1252
- throw new Error("sandbox_input_failed: no approved input Git ref could be resolved.");
1250
+ throw trustedSandboxFailure("sandbox_input_failed", "no approved input Git ref could be resolved.");
1253
1251
  }
1254
1252
  git(args, timeoutMs, signal, env) { return requireSandboxProcess({ command: "git", args: ["-C", this.repoPath, ...args], timeoutMs, maxOutputBytes: 4 * 1024 * 1024, signal, env }); }
1255
1253
  }
@@ -1259,19 +1257,19 @@ export function managedBranchUpdateExpectedValue(current, expectedInput, importe
1259
1257
  return "0".repeat(imported.length);
1260
1258
  }
1261
1259
  if (normalized !== expectedInput) {
1262
- throw new Error("sandbox_output_conflict: managed branch changed after sandbox input was captured.");
1260
+ throw trustedSandboxFailure("sandbox_output_conflict", "managed branch changed after sandbox input was captured.");
1263
1261
  }
1264
1262
  return normalized;
1265
1263
  }
1266
1264
  export function publicationPushArgs(branch, resultCommitSha, inputCommitSha, remoteCommitSha, remote = "origin") {
1267
1265
  const isCommit = (value) => /^[0-9a-f]{40,64}$/iu.test(value);
1268
1266
  if (!branch || branch.length > 240 || !isCommit(resultCommitSha) || !isCommit(inputCommitSha) || (remoteCommitSha && !isCommit(remoteCommitSha))) {
1269
- throw new Error("sandbox_output_publish_rejected: publication inputs contain an invalid branch or object id.");
1267
+ throw trustedSandboxFailure("sandbox_output_publish_rejected", "publication inputs contain an invalid branch or object id.");
1270
1268
  }
1271
1269
  if (remoteCommitSha === resultCommitSha)
1272
1270
  return null;
1273
1271
  if (remoteCommitSha && remoteCommitSha !== inputCommitSha) {
1274
- throw new Error("sandbox_output_publish_stale: remote branch changed after the sandbox input was captured.");
1272
+ throw trustedSandboxFailure("sandbox_output_publish_stale", "remote branch changed after the sandbox input was captured.");
1275
1273
  }
1276
1274
  const remoteRef = `refs/heads/${branch}`;
1277
1275
  return [
@@ -1303,7 +1301,7 @@ export function validatePublicationCredential(credential, nowMs = Date.now()) {
1303
1301
  || credential.secret.length > 16 * 1024
1304
1302
  || /[\r\n\0]/u.test(credential.secret)
1305
1303
  || Date.parse(credential.expiresAt) <= nowMs) {
1306
- throw new Error("sandbox_output_publish_forbidden: redeemed publication credential is invalid or expired.");
1304
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "redeemed publication credential is invalid or expired.");
1307
1305
  }
1308
1306
  }
1309
1307
  export function publicationCredentialEnvironment(credential, environment = process.env) {
@@ -1333,7 +1331,7 @@ export function restrictedBuildLog(source, exactSecrets = [], maximumBytes = 4 *
1333
1331
  export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000, maximumExpandedBytes = 2 * 1024 * 1024 * 1024) {
1334
1332
  const lines = inventory.split(/\r?\n/u).filter(Boolean);
1335
1333
  if (lines.length === 0 || lines.length > maximumObjectCount) {
1336
- throw new Error("sandbox_output_invalid: Git bundle exceeds the object-count budget.");
1334
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds the object-count budget.");
1337
1335
  }
1338
1336
  const maximum = BigInt(maximumExpandedBytes);
1339
1337
  let expanded = 0n;
@@ -1341,13 +1339,13 @@ export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000,
1341
1339
  for (const line of lines) {
1342
1340
  const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (blob|tree|commit|tag) ([0-9]+)$/iu.exec(line);
1343
1341
  if (!match || objects.has(match[1].toLowerCase())) {
1344
- throw new Error("sandbox_output_invalid: Git object inventory is malformed or duplicated.");
1342
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git object inventory is malformed or duplicated.");
1345
1343
  }
1346
1344
  objects.add(match[1].toLowerCase());
1347
1345
  const size = BigInt(match[3]);
1348
1346
  expanded += size;
1349
1347
  if (size > maximum || expanded > maximum) {
1350
- throw new Error("sandbox_output_invalid: Git bundle exceeds the expanded-object byte budget.");
1348
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds the expanded-object byte budget.");
1351
1349
  }
1352
1350
  }
1353
1351
  return { objectCount: lines.length, expandedBytes: Number(expanded) };
@@ -1355,11 +1353,11 @@ export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000,
1355
1353
  export function validateGitCommitDepth(value, maximumCommitDepth = 50_000) {
1356
1354
  const normalized = value.trim();
1357
1355
  if (!/^[0-9]+$/u.test(normalized)) {
1358
- throw new Error("sandbox_output_invalid: Git commit-depth result is malformed.");
1356
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git commit-depth result is malformed.");
1359
1357
  }
1360
1358
  const depth = BigInt(normalized);
1361
1359
  if (depth > BigInt(maximumCommitDepth)) {
1362
- throw new Error("sandbox_output_invalid: Git result exceeds the commit-depth budget.");
1360
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git result exceeds the commit-depth budget.");
1363
1361
  }
1364
1362
  return Number(depth);
1365
1363
  }
@@ -1375,10 +1373,10 @@ export function selectAuditArtifactGcCandidates(records, retentionCount, retenti
1375
1373
  export function selectSandboxConfigRefs(executionPolicy, configuredSha, defaultBranch) {
1376
1374
  const approvedSha = configuredSha?.trim().toLowerCase() || null;
1377
1375
  if (executionPolicy === "sandbox_required" && !approvedSha) {
1378
- throw new Error("sandbox_config_commit_unapproved: sandbox_required dispatches need a server-approved config commit SHA.");
1376
+ throw trustedSandboxFailure("sandbox_config_commit_unapproved", "sandbox_required dispatches need a server-approved config commit SHA.");
1379
1377
  }
1380
1378
  if (approvedSha && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(approvedSha)) {
1381
- throw new Error("sandbox_config_commit_invalid: approved sandbox config commit is not a full Git object id.");
1379
+ throw trustedSandboxFailure("sandbox_config_commit_invalid", "approved sandbox config commit is not a full Git object id.");
1382
1380
  }
1383
1381
  return approvedSha
1384
1382
  ? { refs: [approvedSha], approvedSha }
@@ -1423,12 +1421,12 @@ async function validateOutput(attemptRoot, dispatch) {
1423
1421
  const manifestPath = path.join(outputRoot, "manifest.json");
1424
1422
  const stat = await fs.lstat(manifestPath).catch(error => {
1425
1423
  if (isNodeErrorWithCode(error, "ENOENT")) {
1426
- throw new Error("sandbox_output_missing: sandbox worker did not produce manifest.json.");
1424
+ throw trustedSandboxFailure("sandbox_output_missing", "sandbox worker did not produce manifest.json.");
1427
1425
  }
1428
1426
  throw error;
1429
1427
  });
1430
1428
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
1431
- throw new Error("sandbox_output_invalid: manifest must be a bounded regular file.");
1429
+ throw trustedSandboxFailure("sandbox_output_invalid", "manifest must be a bounded regular file.");
1432
1430
  const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
1433
1431
  if (!dispatch.sandbox
1434
1432
  || manifest.dispatchAttemptId !== dispatch.sandbox.dispatchAttemptId
@@ -1439,10 +1437,10 @@ async function validateOutput(attemptRoot, dispatch) {
1439
1437
  || manifest.branch !== dispatch.lease.worktreeKey
1440
1438
  || manifest.contractDigest !== dispatch.sandbox.contractDigest
1441
1439
  || manifest.imageDigest !== dispatch.sandbox.imageDigest)
1442
- throw new Error("sandbox_output_invalid: manifest identity or fence mismatch.");
1440
+ throw trustedSandboxFailure("sandbox_output_invalid", "manifest identity or fence mismatch.");
1443
1441
  if ((manifest.resultCommitSha === null) !== (manifest.bundle === null)
1444
1442
  || (manifest.resultCommitSha !== null && !/^[0-9a-f]{40,64}$/i.test(manifest.resultCommitSha))) {
1445
- throw new Error("sandbox_output_invalid: result commit and bundle must be present together.");
1443
+ throw trustedSandboxFailure("sandbox_output_invalid", "result commit and bundle must be present together.");
1446
1444
  }
1447
1445
  const resultPath = await validateManifestFile(outputRoot, manifest.result, 16 * 1024 * 1024);
1448
1446
  const bundlePath = manifest.bundle ? await validateManifestFile(outputRoot, manifest.bundle, 512 * 1024 * 1024) : null;
@@ -1450,14 +1448,14 @@ async function validateOutput(attemptRoot, dispatch) {
1450
1448
  }
1451
1449
  async function validateManifestFile(root, file, max) {
1452
1450
  if (!/^[a-z0-9_.-]+$/i.test(file.path))
1453
- throw new Error("sandbox_output_invalid: output path is invalid.");
1451
+ throw trustedSandboxFailure("sandbox_output_invalid", "output path is invalid.");
1454
1452
  const target = path.join(root, file.path);
1455
1453
  const stat = await fs.lstat(target);
1456
1454
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.bytes || stat.size > max)
1457
- throw new Error("sandbox_output_invalid: output file bounds mismatch.");
1455
+ throw trustedSandboxFailure("sandbox_output_invalid", "output file bounds mismatch.");
1458
1456
  const digest = createHash("sha256").update(await fs.readFile(target)).digest("hex");
1459
1457
  if (digest !== file.sha256)
1460
- throw new Error("sandbox_output_invalid: output hash mismatch.");
1458
+ throw trustedSandboxFailure("sandbox_output_invalid", "output hash mismatch.");
1461
1459
  return target;
1462
1460
  }
1463
1461
  function isMissingSandboxManifest(error) {
@@ -1479,7 +1477,7 @@ export function sandboxWorkerFailureMessage(exitCode, stderr, containerError) {
1479
1477
  function identityFor(dispatch) {
1480
1478
  const value = dispatch.sandbox?.dispatchAttemptId.replace(/-/g, "").toLowerCase() ?? "";
1481
1479
  if (!/^[0-9a-f]{32}$/.test(value))
1482
- throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
1480
+ throw trustedSandboxFailure("sandbox_identity_invalid", "dispatch attempt id must be a full UUID.");
1483
1481
  return { attemptHex: value };
1484
1482
  }
1485
1483
  class SandboxTeardownError extends Error {
@@ -1688,7 +1686,7 @@ export function assertSandboxImportCurrent(expected, current, signal, nowMs = Da
1688
1686
  || current.dispatchAttemptId !== expected.dispatchAttemptId
1689
1687
  || current.fencingToken !== expected.fencingToken
1690
1688
  || Date.parse(expected.expiresAt) <= nowMs) {
1691
- throw new Error("sandbox_output_stale: output cannot move the managed ref because its attempt, fence, or lease is no longer current.");
1689
+ throw trustedSandboxFailure("sandbox_output_stale", "output cannot move the managed ref because its attempt, fence, or lease is no longer current.");
1692
1690
  }
1693
1691
  }
1694
1692
  export function validSandboxLedger(value) {
@@ -1830,7 +1828,7 @@ export function sandboxResourceLabels(dispatch, image, contract) {
1830
1828
  ];
1831
1829
  const invalid = ownershipIds.find(([, value]) => !isFullUuid(value));
1832
1830
  if (invalid)
1833
- throw new Error(`sandbox_identity_invalid: ${invalid[0]} id must be a full UUID.`);
1831
+ throw trustedSandboxFailure("sandbox_identity_invalid", `${invalid[0]} id must be a full UUID.`);
1834
1832
  return {
1835
1833
  "dev.forge.managed": "true", "dev.forge.session-id": dispatch.session.id, "dev.forge.resource-lease-id": resourceLeaseId, "dev.forge.execution-lease-id": resourceLeaseId,
1836
1834
  "dev.forge.dispatch-attempt-id": dispatch.sandbox.dispatchAttemptId, "dev.forge.fencing-token": dispatch.lease.fencingToken,
@@ -1919,13 +1917,13 @@ export async function extractOciLayoutArchive(archivePath, destination, signal)
1919
1917
  signal
1920
1918
  });
1921
1919
  if (result.exitCode !== 0 || result.truncated) {
1922
- throw new Error(`sandbox_build_failed: project OCI layout extraction failed with exit code ${result.exitCode}; ${formatOciExtractionFailure(result)}. Archive retained at ${archivePath}.`);
1920
+ throw trustedSandboxFailure("sandbox_build_failed", `project OCI layout extraction failed with exit code ${result.exitCode}; ${formatOciExtractionFailure(result)}. Archive retained at ${archivePath}.`);
1923
1921
  }
1924
1922
  await fs.rm(archivePath, { force: true }).catch(() => undefined);
1925
1923
  }
1926
1924
  export function ociLayoutContextRef(layoutPath) {
1927
1925
  if (/^[A-Za-z]:[\\/]/u.test(layoutPath)) {
1928
- throw new Error("sandbox_build_failed: Windows absolute OCI layout paths are not BuildKit-safe; use a relative path from the build working directory.");
1926
+ throw trustedSandboxFailure("sandbox_build_failed", "Windows absolute OCI layout paths are not BuildKit-safe; use a relative path from the build working directory.");
1929
1927
  }
1930
1928
  const normalized = layoutPath.replace(/\\/gu, "/").replace(/^\.\//u, "");
1931
1929
  // BuildKit's oci-layout resolver expects a raw filesystem path here; it does not percent-decode
@@ -1945,7 +1943,7 @@ export async function readOciLayoutImageDigest(layoutRoot) {
1945
1943
  && /^sha256:[0-9a-f]{64}$/iu.test(entry.digest)
1946
1944
  && entry.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest");
1947
1945
  if (!manifest || typeof manifest.digest !== "string") {
1948
- throw new Error("sandbox_build_failed: project OCI layout did not expose an immutable image digest.");
1946
+ throw trustedSandboxFailure("sandbox_build_failed", "project OCI layout did not expose an immutable image digest.");
1949
1947
  }
1950
1948
  return manifest.digest;
1951
1949
  }
@@ -1980,11 +1978,11 @@ export async function bridgeRuntimeContentDigest(packageRoot) {
1980
1978
  const distRoot = path.join(root, "dist");
1981
1979
  const distStat = await fs.lstat(distRoot).catch(() => null);
1982
1980
  if (!distStat?.isDirectory() || distStat.isSymbolicLink()) {
1983
- throw new Error("sandbox_build_failed: bridge runtime dist must be a real directory.");
1981
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime dist must be a real directory.");
1984
1982
  }
1985
1983
  const distFiles = await collectBridgeRuntimeFiles(root, "dist");
1986
1984
  if (distFiles.length === 0) {
1987
- throw new Error("sandbox_build_failed: bridge runtime dist is empty.");
1985
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime dist is empty.");
1988
1986
  }
1989
1987
  const files = [packageManifest, ...(lockManifest ? [lockManifest] : []), ...distFiles]
1990
1988
  .sort((left, right) => compareBridgeRuntimePaths(left.relativePath, right.relativePath));
@@ -2003,7 +2001,7 @@ export async function bridgeRuntimeContentDigest(packageRoot) {
2003
2001
  async function requireBridgeRuntimeFile(root, relativePath) {
2004
2002
  const file = await optionalBridgeRuntimeFile(root, relativePath);
2005
2003
  if (!file)
2006
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} is missing.`);
2004
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath} is missing.`);
2007
2005
  return file;
2008
2006
  }
2009
2007
  async function optionalBridgeRuntimeFile(root, relativePath) {
@@ -2012,7 +2010,7 @@ async function optionalBridgeRuntimeFile(root, relativePath) {
2012
2010
  if (!stat)
2013
2011
  return null;
2014
2012
  if (!stat.isFile() || stat.isSymbolicLink()) {
2015
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} must be a regular file.`);
2013
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath} must be a regular file.`);
2016
2014
  }
2017
2015
  return { absolutePath, relativePath: relativePath.replace(/\\/gu, "/") };
2018
2016
  }
@@ -2024,14 +2022,14 @@ async function collectBridgeRuntimeFiles(root, relativeDirectory) {
2024
2022
  for (const entry of entries) {
2025
2023
  const relativePath = path.join(relativeDirectory, entry.name);
2026
2024
  if (entry.isSymbolicLink()) {
2027
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} cannot be a symbolic link.`);
2025
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath.replace(/\\/gu, "/")} cannot be a symbolic link.`);
2028
2026
  }
2029
2027
  if (entry.isDirectory()) {
2030
2028
  files.push(...await collectBridgeRuntimeFiles(root, relativePath));
2031
2029
  continue;
2032
2030
  }
2033
2031
  if (!entry.isFile()) {
2034
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} must be a regular file.`);
2032
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath.replace(/\\/gu, "/")} must be a regular file.`);
2035
2033
  }
2036
2034
  files.push({
2037
2035
  absolutePath: path.join(root, relativePath),
@@ -2086,7 +2084,7 @@ async function copyOptionalBridgeManifest(packageRoot, bridgeRoot, fileName) {
2086
2084
  }
2087
2085
  export function buildxBuildArgs(builder, metadataFile, tail, environment = process.env, options = {}) {
2088
2086
  if (!/^forge-[0-9a-f]{20}$/u.test(builder))
2089
- throw new Error("sandbox_build_failed: invalid dedicated builder name.");
2087
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid dedicated builder name.");
2090
2088
  const resources = buildResourcePolicy(environment);
2091
2089
  const args = ["buildx", "build", "--builder", builder];
2092
2090
  if (options.load !== false)
@@ -2108,7 +2106,7 @@ export function dedicatedBuildxBuilderName(runnerId, dockerContext) {
2108
2106
  }
2109
2107
  export function dedicatedBuildxBuilderContainerName(builder) {
2110
2108
  if (!/^forge-[0-9a-f]{20}$/u.test(builder))
2111
- throw new Error("sandbox_build_failed: invalid dedicated builder name.");
2109
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid dedicated builder name.");
2112
2110
  return `buildx_buildkit_${builder}0`;
2113
2111
  }
2114
2112
  function boundedBuildCacheSize(value, fallback) {
@@ -2132,7 +2130,7 @@ export function selectImageGcCandidates(records, keepPerProject, ttlMs, nowMs) {
2132
2130
  async function sandboxProviderAuthForDispatch(dispatch) {
2133
2131
  const unavailable = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
2134
2132
  if (unavailable) {
2135
- throw new Error(`sandbox_auth_unavailable: ${unavailable}`);
2133
+ throw trustedSandboxFailure("sandbox_auth_unavailable", unavailable);
2136
2134
  }
2137
2135
  const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
2138
2136
  return adapter ? adapter.prepareAttemptAuth() : null;
@@ -2176,7 +2174,7 @@ export function proxyEnvironmentArgs(dispatch, contract, resources) {
2176
2174
  export function egressProxyConfig(allowedHosts) {
2177
2175
  const domains = allowedHosts.map(host => host.startsWith("*.") ? `.${host.slice(2)}` : host).join(" ");
2178
2176
  if (!domains)
2179
- throw new Error("sandbox_egress_policy_invalid: public_allowlist requires at least one approved host.");
2177
+ throw trustedSandboxFailure("sandbox_egress_policy_invalid", "public_allowlist requires at least one approved host.");
2180
2178
  return [
2181
2179
  "http_port 3128",
2182
2180
  "access_log none",
@@ -2208,7 +2206,7 @@ export function serviceContainerArgs(service, image, resources, labels, security
2208
2206
  else {
2209
2207
  const healthCommand = environment.FORGE_SERVICE_BROWSER_HEALTH_CMD?.trim();
2210
2208
  if (!healthCommand || /[\r\n\0]/u.test(healthCommand) || healthCommand.length > 1000) {
2211
- throw new Error("sandbox_service_health_policy_invalid: browser service needs an operator-configured bounded health command.");
2209
+ throw trustedSandboxFailure("sandbox_service_health_policy_invalid", "browser service needs an operator-configured bounded health command.");
2212
2210
  }
2213
2211
  args.push("--env", "FORGE_BROWSER_TOKEN_FILE=/run/secrets/browser-token", "--volume", `${resources.secretsVolume}:/run/secrets:ro`, "--health-cmd", healthCommand, "--health-interval", "1s", "--health-timeout", "5s", "--health-retries", "60");
2214
2212
  }
@@ -2221,13 +2219,13 @@ export function resolveSandboxSecurityOptions(executionPolicy, environment = pro
2221
2219
  const seccomp = environment.FORGE_SANDBOX_SECCOMP_PROFILE?.trim();
2222
2220
  const lsm = environment.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim();
2223
2221
  if (seccomp && (!path.isAbsolute(seccomp) || /[\r\n\0]/u.test(seccomp))) {
2224
- throw new Error("sandbox_security_profile_invalid: seccomp profile must be an absolute host path.");
2222
+ throw trustedSandboxFailure("sandbox_security_profile_invalid", "seccomp profile must be an absolute host path.");
2225
2223
  }
2226
2224
  if (lsm && !/^(?:apparmor=[a-zA-Z0-9_.-]{1,128}|label=[a-zA-Z0-9_:,.-]{1,256})$/u.test(lsm)) {
2227
- throw new Error("sandbox_security_profile_invalid: LSM security option is invalid.");
2225
+ throw trustedSandboxFailure("sandbox_security_profile_invalid", "LSM security option is invalid.");
2228
2226
  }
2229
2227
  if (executionPolicy === "sandbox_required" && (!seccomp || !lsm)) {
2230
- throw new Error("sandbox_security_profile_unavailable: sandbox_required needs explicit seccomp and LSM profiles.");
2228
+ throw trustedSandboxFailure("sandbox_security_profile_unavailable", "sandbox_required needs explicit seccomp and LSM profiles.");
2231
2229
  }
2232
2230
  return [
2233
2231
  ...(seccomp ? ["--security-opt", `seccomp=${seccomp}`] : []),
@@ -2263,17 +2261,46 @@ function event(eventType, content, data) {
2263
2261
  };
2264
2262
  }
2265
2263
  export function classifySandboxRuntimeFailure(value) {
2266
- const message = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000) || "Sandbox execution failed.";
2267
- const explicit = /^([a-z][a-z0-9_.-]{2,79}):/u.exec(message)?.[1] ?? null;
2268
- let code = explicit ?? "sandbox_runtime_failed";
2269
- if (/no space left|disk quota exceeded|enospc/iu.test(message))
2270
- code = "sandbox_storage_exhausted";
2271
- const phase = code.startsWith("sandbox_build") || code.startsWith("image_") ? "build"
2272
- : code.startsWith("sandbox_output") || code.startsWith("sandbox_import") ? "import"
2273
- : code.startsWith("teardown") ? "teardown"
2274
- : code.startsWith("sandbox_config") || code.startsWith("sandbox_auth") || code.startsWith("contract_") || code.startsWith("sandbox_input") || code.startsWith("sandbox_create") ? "prepare"
2275
- : "agent";
2276
- return { code, phase, message };
2264
+ const message = (value instanceof Error ? value.message : String(value))
2265
+ .replace(/[\r\n\t]+/gu, " ")
2266
+ .trim()
2267
+ .slice(0, 2000) || "Sandbox execution failed.";
2268
+ const code = value instanceof ForgeFailureError
2269
+ ? value.failureCode
2270
+ : "sandbox_runtime_failed";
2271
+ return { code, phase: failurePolicyForCode(code).phase, message };
2272
+ }
2273
+ export function applyTrustedHostFailure(result, trustedFailure) {
2274
+ if (!trustedFailure)
2275
+ return result;
2276
+ const originalFailure = result.failure
2277
+ ? [{
2278
+ code: result.failure.code,
2279
+ message: result.failure.message,
2280
+ source: result.failure.source,
2281
+ }, ...result.failure.causalChain]
2282
+ : result.exitCode === 0
2283
+ ? []
2284
+ : [{
2285
+ code: "sandbox_child_result_unknown",
2286
+ message: result.summary,
2287
+ source: "sandbox",
2288
+ }];
2289
+ const facts = {
2290
+ ...failureFactsForCode(trustedFailure.code, "sandbox"),
2291
+ causalChain: originalFailure,
2292
+ };
2293
+ return {
2294
+ ...result,
2295
+ schemaVersion: 2,
2296
+ exitCode: result.exitCode === 0 ? 1 : result.exitCode,
2297
+ summary: trustedFailure.message,
2298
+ failure: createFailureEnvelope(trustedFailure.message, facts),
2299
+ events: [
2300
+ ...result.events,
2301
+ event(trustedFailure.code, trustedFailure.message, { phase: facts.phase }),
2302
+ ],
2303
+ };
2277
2304
  }
2278
2305
  export function classifySandboxResourceFailure(value) {
2279
2306
  const diagnostic = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000);
@@ -2293,6 +2320,9 @@ export function classifySandboxResourceFailure(value) {
2293
2320
  }
2294
2321
  return null;
2295
2322
  }
2323
+ function trustedSandboxFailure(code, detail, options) {
2324
+ return new ForgeFailureError(code, `${code}: ${detail}`, "sandbox", options);
2325
+ }
2296
2326
  function parseSandboxEventLine(line) {
2297
2327
  if (!line.trim().startsWith("{"))
2298
2328
  return null;
@@ -2307,25 +2337,25 @@ function parseSandboxEventLine(line) {
2307
2337
  return null;
2308
2338
  const candidate = value.event;
2309
2339
  if (!candidate || typeof candidate !== "object")
2310
- throw new Error("sandbox_event_invalid: runner event envelope is missing event data.");
2340
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event envelope is missing event data.");
2311
2341
  const eventValue = candidate;
2312
2342
  for (const field of ["role", "kind", "content", "eventType"]) {
2313
2343
  if (typeof eventValue[field] !== "string")
2314
- throw new Error(`sandbox_event_invalid: ${field} must be a string.`);
2344
+ throw trustedSandboxFailure("sandbox_event_invalid", `${field} must be a string.`);
2315
2345
  }
2316
2346
  if (eventValue.role.length > 64
2317
2347
  || eventValue.kind.length > 128
2318
2348
  || eventValue.eventType.length > 128
2319
2349
  || Buffer.byteLength(eventValue.content, "utf8") > 256 * 1024) {
2320
- throw new Error("sandbox_event_invalid: runner event exceeds field bounds.");
2350
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event exceeds field bounds.");
2321
2351
  }
2322
2352
  if (eventValue.data !== undefined && eventValue.data !== null
2323
2353
  && (typeof eventValue.data !== "string" || Buffer.byteLength(eventValue.data, "utf8") > 256 * 1024)) {
2324
- throw new Error("sandbox_event_invalid: runner event data exceeds field bounds.");
2354
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event data exceeds field bounds.");
2325
2355
  }
2326
2356
  if (eventValue.occurredAt !== undefined && eventValue.occurredAt !== null
2327
2357
  && (typeof eventValue.occurredAt !== "string" || !Number.isFinite(Date.parse(eventValue.occurredAt)))) {
2328
- throw new Error("sandbox_event_invalid: runner event occurredAt is invalid.");
2358
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event occurredAt is invalid.");
2329
2359
  }
2330
2360
  return {
2331
2361
  role: eventValue.role,