@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.1ff37038366e

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 (162) 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 +67 -20
  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 +191 -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 +28 -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 +17 -8
  82. package/dist/sandbox/runtime.d.ts +6 -1
  83. package/dist/sandbox/runtime.js +132 -103
  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 +485 -31
  88. package/dist/session/managed-build-coordinator.d.ts +5 -0
  89. package/dist/session/managed-build-coordinator.js +105 -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 +8 -1
  122. package/dist/session/provider-cli/provider-cli.runtime.js +43 -3
  123. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  124. package/dist/session/provider-cli/provider-cli.service.js +771 -68
  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-supervisor.d.ts +2 -0
  144. package/dist/shared/process/host-process-supervisor.js +57 -9
  145. package/dist/shared/process/process.d.ts +2 -0
  146. package/dist/shared/process/process.js +12 -7
  147. package/dist/shared/provider-capabilities.d.ts +3 -0
  148. package/dist/shared/provider-capabilities.js +85 -0
  149. package/dist/shared/redaction/redaction.d.ts +1 -0
  150. package/dist/shared/redaction/redaction.js +39 -3
  151. package/dist/version.d.ts +1 -1
  152. package/dist/version.js +1 -1
  153. package/dist/worktree/dependency-warmer.d.ts +41 -9
  154. package/dist/worktree/dependency-warmer.js +497 -22
  155. package/dist/worktree/index.d.ts +1 -1
  156. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  157. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  158. package/dist/worktree/worktree.service.d.ts +7 -0
  159. package/dist/worktree/worktree.service.js +253 -21
  160. package/dist/worktree/worktree.types.d.ts +7 -1
  161. package/package.json +7 -5
  162. 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";
@@ -85,7 +85,7 @@ export class DockerSandboxRuntime {
85
85
  await fs.mkdir(path.join(attemptRoot, "output"), { recursive: true, mode: 0o700 });
86
86
  const repoPath = dispatch.repo.serverLocalPath ? path.resolve(dispatch.repo.serverLocalPath) : null;
87
87
  if (!repoPath)
88
- throw new Error("sandbox_input_failed: bridge repository mapping is required for bundle handoff.");
88
+ throw trustedSandboxFailure("sandbox_input_failed", "bridge repository mapping is required for bundle handoff.");
89
89
  await sink.emit(event("sandbox_preparing", `Preparing Docker sandbox attempt ${dispatch.sandbox.dispatchAttemptId}.`));
90
90
  const git = new HostGit(this.config, repoPath);
91
91
  const prepared = await git.prepare(dispatch, attemptRoot, runtimeSignal);
@@ -100,7 +100,7 @@ export class DockerSandboxRuntime {
100
100
  const providerAuthAdapter = sandboxProviderAuthAdapterFor(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
101
101
  const providerAuthUnavailable = sandboxProviderAuthUnavailableMessage(runtimeDispatch.execution.provider, runtimeDispatch.execution.authKind);
102
102
  if (providerAuthUnavailable) {
103
- throw new Error(`sandbox_auth_unavailable: ${providerAuthUnavailable}`);
103
+ throw trustedSandboxFailure("sandbox_auth_unavailable", providerAuthUnavailable);
104
104
  }
105
105
  await sink.emit(event("sandbox_metadata", "Resolved immutable sandbox inputs.", {
106
106
  inputCommitSha: prepared.inputCommitSha,
@@ -125,6 +125,7 @@ export class DockerSandboxRuntime {
125
125
  await this.writeLedger(active.ledger);
126
126
  let teardownComplete = false;
127
127
  let memoryTelemetryEvent = null;
128
+ let trustedHostFailure = null;
128
129
  try {
129
130
  const internalNetworkId = dockerObjectId((await this.docker(["network", "create", ...networkArgs, ...labelArgs(labels), resources.network], 30_000, runtimeSignal)).stdout, "network");
130
131
  await this.docker(["volume", "create", ...labelArgs(labels), resources.workspaceVolume], 30_000, runtimeSignal);
@@ -217,19 +218,18 @@ export class DockerSandboxRuntime {
217
218
  await sink.emit(memoryTelemetryEvent).catch(() => undefined);
218
219
  });
219
220
  if (start.truncated)
220
- throw new Error("sandbox_log_limit: sandbox output exceeded the 8 MiB supervisor capture budget.");
221
+ throw trustedSandboxFailure("sandbox_log_limit", "sandbox output exceeded the 8 MiB supervisor capture budget.");
221
222
  const containerState = await this.inspectContainerState(containerId);
222
223
  if (containerState.oomKilled) {
223
- throw new Error("sandbox_oom_killed: Docker terminated the agent after it exceeded the memory limit.");
224
+ trustedHostFailure = {
225
+ code: "sandbox_oom_killed",
226
+ message: "Docker terminated the agent after it exceeded the memory limit.",
227
+ };
224
228
  }
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}`);
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
233
  }
234
234
  active.phase = "active";
235
235
  await sink.emit(event("sandbox_agent_exited", `Sandbox worker exited with code ${start.exitCode}.`, {
@@ -250,7 +250,7 @@ export class DockerSandboxRuntime {
250
250
  if (runtimeDispatch.sandbox.publicationMode === "bridge_push") {
251
251
  const handle = runtimeDispatch.sandbox.publishGrantHandle;
252
252
  if (!handle || !output.manifest.resultCommitSha || !this.publicationGrantRedeemer) {
253
- throw new Error("sandbox_output_publish_forbidden: bridge publication grant redemption is unavailable.");
253
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "bridge publication grant redemption is unavailable.");
254
254
  }
255
255
  const credential = await this.publicationGrantRedeemer({
256
256
  dispatchAttemptId: runtimeDispatch.sandbox.dispatchAttemptId,
@@ -269,29 +269,26 @@ export class DockerSandboxRuntime {
269
269
  resultCommitSha: output.manifest.resultCommitSha
270
270
  }));
271
271
  }
272
- const result = JSON.parse(await fs.readFile(output.resultPath, "utf8"));
273
- if (result.exitCode !== 0) {
274
- const resourceFailure = classifySandboxResourceFailure([
275
- result.summary,
276
- ...result.events.flatMap(resultEvent => [resultEvent.eventType, resultEvent.content, resultEvent.data ?? ""])
277
- ].join("\n"));
278
- if (resourceFailure) {
279
- return appendRunOnceEvent({
280
- ...result,
281
- summary: resourceFailure.message,
282
- events: [...result.events, event(resourceFailure.code, resourceFailure.message, { phase: "agent" })]
283
- }, memoryTelemetryEvent);
284
- }
285
- }
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);
286
281
  await sink.emit(event("sandbox_output_imported", "Validated and imported sandbox output.", {
287
282
  resultCommitSha: output.manifest.resultCommitSha,
288
283
  }));
289
- return appendRunOnceEvent(result, memoryTelemetryEvent);
284
+ return appendRunOnceEvent(canonicalResult, memoryTelemetryEvent);
290
285
  }
291
286
  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);
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);
295
292
  }
296
293
  finally {
297
294
  try {
@@ -525,7 +522,7 @@ export class DockerSandboxRuntime {
525
522
  ledgerPath(dispatchAttemptId) {
526
523
  const attemptHex = dispatchAttemptId.replace(/-/g, "").toLowerCase();
527
524
  if (!/^[0-9a-f]{32}$/.test(attemptHex))
528
- throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
525
+ throw trustedSandboxFailure("sandbox_identity_invalid", "dispatch attempt id must be a full UUID.");
529
526
  return path.join(this.config.sandboxStateRoot, "ledger", `${attemptHex}.json`);
530
527
  }
531
528
  async removeLedger(dispatchAttemptId) {
@@ -534,11 +531,11 @@ export class DockerSandboxRuntime {
534
531
  async buildImage(contextRoot, contextDigest, project, contract, upstreamImages, policy, sink, signal) {
535
532
  const toolingImage = process.env.FORGE_AGENT_TOOLING_IMAGE ?? "node:22.17.0-bookworm-slim";
536
533
  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.");
534
+ throw trustedSandboxFailure("sandbox_build_failed", "FORGE_AGENT_TOOLING_IMAGE must be digest-pinned for sandbox_required.");
538
535
  }
539
536
  const codexVersion = process.env.FORGE_CODEX_PACKAGE_VERSION ?? "0.144.1";
540
537
  if (!/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(codexVersion))
541
- throw new Error("sandbox_build_failed: invalid pinned Codex package version.");
538
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid pinned Codex package version.");
542
539
  const policyConfigurationDigest = await sandboxImagePolicyConfigurationDigest();
543
540
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
544
541
  const bridgeRuntimeDigest = await bridgeRuntimeContentDigest(packageRoot);
@@ -631,7 +628,7 @@ export class DockerSandboxRuntime {
631
628
  }
632
629
  await sink.emit(event("sandbox_image_cache_miss", `Sandbox image ${key.slice(0, 12)} requires a build.`, { cacheKey: key }));
633
630
  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.");
631
+ throw trustedSandboxFailure("sandbox_build_policy_failed", "sandbox_required builds need an operator-enforced builder egress policy.");
635
632
  }
636
633
  const buildStartedAt = Date.now();
637
634
  await sink.emit(event("sandbox_build_started", `Building sandbox image ${key.slice(0, 12)}.`, { cacheKey: key }));
@@ -658,7 +655,7 @@ export class DockerSandboxRuntime {
658
655
  const installMode = await copyBridgePackageManifests(packageRoot, stagedBridgeRoot);
659
656
  const stagedBridgeRuntimeDigest = await bridgeRuntimeContentDigest(stagedBridgeRoot);
660
657
  if (stagedBridgeRuntimeDigest !== bridgeRuntimeDigest) {
661
- throw new Error("sandbox_build_failed: bridge runtime payload changed while preparing the sandbox image; retry the dispatch.");
658
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime payload changed while preparing the sandbox image; retry the dispatch.");
662
659
  }
663
660
  const dockerfile = agentOverlayDockerfile(toolingImage, projectContextName, installMode, codexVersion);
664
661
  await fs.writeFile(path.join(overlayRoot, "Dockerfile"), dockerfile);
@@ -666,7 +663,7 @@ export class DockerSandboxRuntime {
666
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);
667
664
  const helperImageDigest = (await this.docker(["image", "inspect", helperTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
668
665
  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.");
666
+ throw trustedSandboxFailure("sandbox_build_failed", "helper image did not resolve to an immutable digest.");
670
667
  const projectContextRef = ociLayoutContextRef(path.relative(overlayRoot, projectOciRoot));
671
668
  await this.runBuildStage("agent", key, buildxBuildArgs(builder, path.join(metadataRoot, "agent.json"), [
672
669
  "--pull=false",
@@ -677,7 +674,7 @@ export class DockerSandboxRuntime {
677
674
  ]), sink, signal, { cwd: overlayRoot });
678
675
  const digest = (await this.docker(["image", "inspect", overlayTag, "--format", "{{.Id}}"], 15_000)).stdout.trim();
679
676
  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.");
677
+ throw trustedSandboxFailure("sandbox_build_failed", "overlay image did not resolve to an immutable digest.");
681
678
  const imagePolicy = await evaluateSandboxImagePolicy({
682
679
  key,
683
680
  project: dockerLabelValue(project),
@@ -752,7 +749,7 @@ export class DockerSandboxRuntime {
752
749
  byteCount: artifact.byteCount,
753
750
  truncated: artifact.truncated || result.truncated
754
751
  })).catch(() => undefined);
755
- throw new Error(`sandbox_build_failed: ${stage} image build failed; restricted log digest ${artifact.digest}.`);
752
+ throw trustedSandboxFailure("sandbox_build_failed", `${stage} image build failed; restricted log digest ${artifact.digest}.`);
756
753
  }
757
754
  async ensureBuildxBuilder(signal) {
758
755
  const name = dedicatedBuildxBuilderName(this.config.runnerId, this.config.dockerContext);
@@ -775,7 +772,7 @@ export class DockerSandboxRuntime {
775
772
  if (Number(hostConfig.PidsLimit) !== policy.builderPidsLimit
776
773
  || Number(hostConfig.Memory) !== policy.builderMemoryBytes
777
774
  || Number(hostConfig.MemorySwap) !== policy.builderMemoryBytes) {
778
- throw new Error("sandbox_build_policy_failed: dedicated BuildKit builder resource limits were not applied.");
775
+ throw trustedSandboxFailure("sandbox_build_policy_failed", "dedicated BuildKit builder resource limits were not applied.");
779
776
  }
780
777
  return name;
781
778
  }
@@ -848,10 +845,10 @@ export class DockerSandboxRuntime {
848
845
  return null;
849
846
  const image = process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim();
850
847
  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.");
848
+ throw trustedSandboxFailure("sandbox_egress_proxy_unavailable", "public_allowlist needs a digest-pinned Squid-compatible proxy image.");
852
849
  }
853
850
  if (process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
854
- throw new Error("sandbox_egress_proxy_unavailable: runner egress enforcement is not enabled.");
851
+ throw trustedSandboxFailure("sandbox_egress_proxy_unavailable", "runner egress enforcement is not enabled.");
855
852
  }
856
853
  await this.verifyCatalogImage(image, dispatch.sandbox.executionPolicy, sink, signal);
857
854
  const name = `${resources.prefix}-egress`;
@@ -1162,7 +1159,7 @@ class HostGit {
1162
1159
  || !resultCommitSha
1163
1160
  || handle.branch !== dispatch.lease.worktreeKey
1164
1161
  || !/^[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.");
1162
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "remote publication grant is missing or outside its attempt scope.");
1166
1163
  }
1167
1164
  assertCurrent();
1168
1165
  const credentialEnvironment = publicationCredentialEnvironment(credential);
@@ -1202,15 +1199,15 @@ class HostGit {
1202
1199
  terminateOnOutputLimit: true
1203
1200
  });
1204
1201
  if (objectInventory.exitCode !== 0 || objectInventory.truncated) {
1205
- throw new Error("sandbox_output_invalid: Git bundle exceeds object validation limits.");
1202
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds object validation limits.");
1206
1203
  }
1207
1204
  validateGitObjectBudget(objectInventory.stdout);
1208
1205
  const imported = (await quarantineGit(quarantine, ["rev-parse", `${importRef}^{commit}`], 30_000)).stdout.trim();
1209
1206
  if (imported !== output.manifest.resultCommitSha)
1210
- throw new Error("sandbox_output_invalid: imported result SHA differs from manifest.");
1207
+ throw trustedSandboxFailure("sandbox_output_invalid", "imported result SHA differs from manifest.");
1211
1208
  const ancestry = await runSandboxProcess({ command: "git", args: ["-C", quarantine, "merge-base", "--is-ancestor", output.manifest.inputCommitSha, imported], timeoutMs: 30_000 });
1212
1209
  if (ancestry.exitCode !== 0)
1213
- throw new Error("sandbox_output_invalid: result commit is not descended from input commit.");
1210
+ throw trustedSandboxFailure("sandbox_output_invalid", "result commit is not descended from input commit.");
1214
1211
  const commitDepth = await quarantineGit(quarantine, ["rev-list", "--count", `${output.manifest.inputCommitSha}..${imported}`], 2 * 60_000);
1215
1212
  validateGitCommitDepth(commitDepth.stdout);
1216
1213
  await this.git(["fetch", "--no-write-fetch-head", quarantine, `${importRef}:${importRef}`], 5 * 60_000);
@@ -1238,7 +1235,7 @@ class HostGit {
1238
1235
  const head = (await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "rev-parse", "HEAD"], timeoutMs: 30_000 })).stdout.trim();
1239
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;
1240
1237
  if (head !== expected || status.trim())
1241
- throw new Error("sandbox_output_conflict: managed worktree changed after sandbox input was captured.");
1238
+ throw trustedSandboxFailure("sandbox_output_conflict", "managed worktree changed after sandbox input was captured.");
1242
1239
  await this.git(["update-ref", branchRef, imported, expected], 30_000);
1243
1240
  await requireSandboxProcess({ command: "git", args: ["-C", checkedOut.path, "reset", "--hard"], timeoutMs: 2 * 60_000, maxOutputBytes: 4 * 1024 * 1024 });
1244
1241
  }
@@ -1249,7 +1246,7 @@ class HostGit {
1249
1246
  if (result.exitCode === 0 && /^[0-9a-f]{40,64}$/i.test(sha))
1250
1247
  return sha;
1251
1248
  }
1252
- throw new Error("sandbox_input_failed: no approved input Git ref could be resolved.");
1249
+ throw trustedSandboxFailure("sandbox_input_failed", "no approved input Git ref could be resolved.");
1253
1250
  }
1254
1251
  git(args, timeoutMs, signal, env) { return requireSandboxProcess({ command: "git", args: ["-C", this.repoPath, ...args], timeoutMs, maxOutputBytes: 4 * 1024 * 1024, signal, env }); }
1255
1252
  }
@@ -1259,19 +1256,19 @@ export function managedBranchUpdateExpectedValue(current, expectedInput, importe
1259
1256
  return "0".repeat(imported.length);
1260
1257
  }
1261
1258
  if (normalized !== expectedInput) {
1262
- throw new Error("sandbox_output_conflict: managed branch changed after sandbox input was captured.");
1259
+ throw trustedSandboxFailure("sandbox_output_conflict", "managed branch changed after sandbox input was captured.");
1263
1260
  }
1264
1261
  return normalized;
1265
1262
  }
1266
1263
  export function publicationPushArgs(branch, resultCommitSha, inputCommitSha, remoteCommitSha, remote = "origin") {
1267
1264
  const isCommit = (value) => /^[0-9a-f]{40,64}$/iu.test(value);
1268
1265
  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.");
1266
+ throw trustedSandboxFailure("sandbox_output_publish_rejected", "publication inputs contain an invalid branch or object id.");
1270
1267
  }
1271
1268
  if (remoteCommitSha === resultCommitSha)
1272
1269
  return null;
1273
1270
  if (remoteCommitSha && remoteCommitSha !== inputCommitSha) {
1274
- throw new Error("sandbox_output_publish_stale: remote branch changed after the sandbox input was captured.");
1271
+ throw trustedSandboxFailure("sandbox_output_publish_stale", "remote branch changed after the sandbox input was captured.");
1275
1272
  }
1276
1273
  const remoteRef = `refs/heads/${branch}`;
1277
1274
  return [
@@ -1303,7 +1300,7 @@ export function validatePublicationCredential(credential, nowMs = Date.now()) {
1303
1300
  || credential.secret.length > 16 * 1024
1304
1301
  || /[\r\n\0]/u.test(credential.secret)
1305
1302
  || Date.parse(credential.expiresAt) <= nowMs) {
1306
- throw new Error("sandbox_output_publish_forbidden: redeemed publication credential is invalid or expired.");
1303
+ throw trustedSandboxFailure("sandbox_output_publish_forbidden", "redeemed publication credential is invalid or expired.");
1307
1304
  }
1308
1305
  }
1309
1306
  export function publicationCredentialEnvironment(credential, environment = process.env) {
@@ -1333,7 +1330,7 @@ export function restrictedBuildLog(source, exactSecrets = [], maximumBytes = 4 *
1333
1330
  export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000, maximumExpandedBytes = 2 * 1024 * 1024 * 1024) {
1334
1331
  const lines = inventory.split(/\r?\n/u).filter(Boolean);
1335
1332
  if (lines.length === 0 || lines.length > maximumObjectCount) {
1336
- throw new Error("sandbox_output_invalid: Git bundle exceeds the object-count budget.");
1333
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds the object-count budget.");
1337
1334
  }
1338
1335
  const maximum = BigInt(maximumExpandedBytes);
1339
1336
  let expanded = 0n;
@@ -1341,13 +1338,13 @@ export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000,
1341
1338
  for (const line of lines) {
1342
1339
  const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (blob|tree|commit|tag) ([0-9]+)$/iu.exec(line);
1343
1340
  if (!match || objects.has(match[1].toLowerCase())) {
1344
- throw new Error("sandbox_output_invalid: Git object inventory is malformed or duplicated.");
1341
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git object inventory is malformed or duplicated.");
1345
1342
  }
1346
1343
  objects.add(match[1].toLowerCase());
1347
1344
  const size = BigInt(match[3]);
1348
1345
  expanded += size;
1349
1346
  if (size > maximum || expanded > maximum) {
1350
- throw new Error("sandbox_output_invalid: Git bundle exceeds the expanded-object byte budget.");
1347
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git bundle exceeds the expanded-object byte budget.");
1351
1348
  }
1352
1349
  }
1353
1350
  return { objectCount: lines.length, expandedBytes: Number(expanded) };
@@ -1355,11 +1352,11 @@ export function validateGitObjectBudget(inventory, maximumObjectCount = 500_000,
1355
1352
  export function validateGitCommitDepth(value, maximumCommitDepth = 50_000) {
1356
1353
  const normalized = value.trim();
1357
1354
  if (!/^[0-9]+$/u.test(normalized)) {
1358
- throw new Error("sandbox_output_invalid: Git commit-depth result is malformed.");
1355
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git commit-depth result is malformed.");
1359
1356
  }
1360
1357
  const depth = BigInt(normalized);
1361
1358
  if (depth > BigInt(maximumCommitDepth)) {
1362
- throw new Error("sandbox_output_invalid: Git result exceeds the commit-depth budget.");
1359
+ throw trustedSandboxFailure("sandbox_output_invalid", "Git result exceeds the commit-depth budget.");
1363
1360
  }
1364
1361
  return Number(depth);
1365
1362
  }
@@ -1375,10 +1372,10 @@ export function selectAuditArtifactGcCandidates(records, retentionCount, retenti
1375
1372
  export function selectSandboxConfigRefs(executionPolicy, configuredSha, defaultBranch) {
1376
1373
  const approvedSha = configuredSha?.trim().toLowerCase() || null;
1377
1374
  if (executionPolicy === "sandbox_required" && !approvedSha) {
1378
- throw new Error("sandbox_config_commit_unapproved: sandbox_required dispatches need a server-approved config commit SHA.");
1375
+ throw trustedSandboxFailure("sandbox_config_commit_unapproved", "sandbox_required dispatches need a server-approved config commit SHA.");
1379
1376
  }
1380
1377
  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.");
1378
+ throw trustedSandboxFailure("sandbox_config_commit_invalid", "approved sandbox config commit is not a full Git object id.");
1382
1379
  }
1383
1380
  return approvedSha
1384
1381
  ? { refs: [approvedSha], approvedSha }
@@ -1423,12 +1420,12 @@ async function validateOutput(attemptRoot, dispatch) {
1423
1420
  const manifestPath = path.join(outputRoot, "manifest.json");
1424
1421
  const stat = await fs.lstat(manifestPath).catch(error => {
1425
1422
  if (isNodeErrorWithCode(error, "ENOENT")) {
1426
- throw new Error("sandbox_output_missing: sandbox worker did not produce manifest.json.");
1423
+ throw trustedSandboxFailure("sandbox_output_missing", "sandbox worker did not produce manifest.json.");
1427
1424
  }
1428
1425
  throw error;
1429
1426
  });
1430
1427
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
1431
- throw new Error("sandbox_output_invalid: manifest must be a bounded regular file.");
1428
+ throw trustedSandboxFailure("sandbox_output_invalid", "manifest must be a bounded regular file.");
1432
1429
  const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
1433
1430
  if (!dispatch.sandbox
1434
1431
  || manifest.dispatchAttemptId !== dispatch.sandbox.dispatchAttemptId
@@ -1439,10 +1436,10 @@ async function validateOutput(attemptRoot, dispatch) {
1439
1436
  || manifest.branch !== dispatch.lease.worktreeKey
1440
1437
  || manifest.contractDigest !== dispatch.sandbox.contractDigest
1441
1438
  || manifest.imageDigest !== dispatch.sandbox.imageDigest)
1442
- throw new Error("sandbox_output_invalid: manifest identity or fence mismatch.");
1439
+ throw trustedSandboxFailure("sandbox_output_invalid", "manifest identity or fence mismatch.");
1443
1440
  if ((manifest.resultCommitSha === null) !== (manifest.bundle === null)
1444
1441
  || (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.");
1442
+ throw trustedSandboxFailure("sandbox_output_invalid", "result commit and bundle must be present together.");
1446
1443
  }
1447
1444
  const resultPath = await validateManifestFile(outputRoot, manifest.result, 16 * 1024 * 1024);
1448
1445
  const bundlePath = manifest.bundle ? await validateManifestFile(outputRoot, manifest.bundle, 512 * 1024 * 1024) : null;
@@ -1450,14 +1447,14 @@ async function validateOutput(attemptRoot, dispatch) {
1450
1447
  }
1451
1448
  async function validateManifestFile(root, file, max) {
1452
1449
  if (!/^[a-z0-9_.-]+$/i.test(file.path))
1453
- throw new Error("sandbox_output_invalid: output path is invalid.");
1450
+ throw trustedSandboxFailure("sandbox_output_invalid", "output path is invalid.");
1454
1451
  const target = path.join(root, file.path);
1455
1452
  const stat = await fs.lstat(target);
1456
1453
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.bytes || stat.size > max)
1457
- throw new Error("sandbox_output_invalid: output file bounds mismatch.");
1454
+ throw trustedSandboxFailure("sandbox_output_invalid", "output file bounds mismatch.");
1458
1455
  const digest = createHash("sha256").update(await fs.readFile(target)).digest("hex");
1459
1456
  if (digest !== file.sha256)
1460
- throw new Error("sandbox_output_invalid: output hash mismatch.");
1457
+ throw trustedSandboxFailure("sandbox_output_invalid", "output hash mismatch.");
1461
1458
  return target;
1462
1459
  }
1463
1460
  function isMissingSandboxManifest(error) {
@@ -1479,7 +1476,7 @@ export function sandboxWorkerFailureMessage(exitCode, stderr, containerError) {
1479
1476
  function identityFor(dispatch) {
1480
1477
  const value = dispatch.sandbox?.dispatchAttemptId.replace(/-/g, "").toLowerCase() ?? "";
1481
1478
  if (!/^[0-9a-f]{32}$/.test(value))
1482
- throw new Error("sandbox_identity_invalid: dispatch attempt id must be a full UUID.");
1479
+ throw trustedSandboxFailure("sandbox_identity_invalid", "dispatch attempt id must be a full UUID.");
1483
1480
  return { attemptHex: value };
1484
1481
  }
1485
1482
  class SandboxTeardownError extends Error {
@@ -1688,7 +1685,7 @@ export function assertSandboxImportCurrent(expected, current, signal, nowMs = Da
1688
1685
  || current.dispatchAttemptId !== expected.dispatchAttemptId
1689
1686
  || current.fencingToken !== expected.fencingToken
1690
1687
  || 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.");
1688
+ throw trustedSandboxFailure("sandbox_output_stale", "output cannot move the managed ref because its attempt, fence, or lease is no longer current.");
1692
1689
  }
1693
1690
  }
1694
1691
  export function validSandboxLedger(value) {
@@ -1830,7 +1827,7 @@ export function sandboxResourceLabels(dispatch, image, contract) {
1830
1827
  ];
1831
1828
  const invalid = ownershipIds.find(([, value]) => !isFullUuid(value));
1832
1829
  if (invalid)
1833
- throw new Error(`sandbox_identity_invalid: ${invalid[0]} id must be a full UUID.`);
1830
+ throw trustedSandboxFailure("sandbox_identity_invalid", `${invalid[0]} id must be a full UUID.`);
1834
1831
  return {
1835
1832
  "dev.forge.managed": "true", "dev.forge.session-id": dispatch.session.id, "dev.forge.resource-lease-id": resourceLeaseId, "dev.forge.execution-lease-id": resourceLeaseId,
1836
1833
  "dev.forge.dispatch-attempt-id": dispatch.sandbox.dispatchAttemptId, "dev.forge.fencing-token": dispatch.lease.fencingToken,
@@ -1919,13 +1916,13 @@ export async function extractOciLayoutArchive(archivePath, destination, signal)
1919
1916
  signal
1920
1917
  });
1921
1918
  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}.`);
1919
+ throw trustedSandboxFailure("sandbox_build_failed", `project OCI layout extraction failed with exit code ${result.exitCode}; ${formatOciExtractionFailure(result)}. Archive retained at ${archivePath}.`);
1923
1920
  }
1924
1921
  await fs.rm(archivePath, { force: true }).catch(() => undefined);
1925
1922
  }
1926
1923
  export function ociLayoutContextRef(layoutPath) {
1927
1924
  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.");
1925
+ throw trustedSandboxFailure("sandbox_build_failed", "Windows absolute OCI layout paths are not BuildKit-safe; use a relative path from the build working directory.");
1929
1926
  }
1930
1927
  const normalized = layoutPath.replace(/\\/gu, "/").replace(/^\.\//u, "");
1931
1928
  // BuildKit's oci-layout resolver expects a raw filesystem path here; it does not percent-decode
@@ -1945,7 +1942,7 @@ export async function readOciLayoutImageDigest(layoutRoot) {
1945
1942
  && /^sha256:[0-9a-f]{64}$/iu.test(entry.digest)
1946
1943
  && entry.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest");
1947
1944
  if (!manifest || typeof manifest.digest !== "string") {
1948
- throw new Error("sandbox_build_failed: project OCI layout did not expose an immutable image digest.");
1945
+ throw trustedSandboxFailure("sandbox_build_failed", "project OCI layout did not expose an immutable image digest.");
1949
1946
  }
1950
1947
  return manifest.digest;
1951
1948
  }
@@ -1980,11 +1977,11 @@ export async function bridgeRuntimeContentDigest(packageRoot) {
1980
1977
  const distRoot = path.join(root, "dist");
1981
1978
  const distStat = await fs.lstat(distRoot).catch(() => null);
1982
1979
  if (!distStat?.isDirectory() || distStat.isSymbolicLink()) {
1983
- throw new Error("sandbox_build_failed: bridge runtime dist must be a real directory.");
1980
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime dist must be a real directory.");
1984
1981
  }
1985
1982
  const distFiles = await collectBridgeRuntimeFiles(root, "dist");
1986
1983
  if (distFiles.length === 0) {
1987
- throw new Error("sandbox_build_failed: bridge runtime dist is empty.");
1984
+ throw trustedSandboxFailure("sandbox_build_failed", "bridge runtime dist is empty.");
1988
1985
  }
1989
1986
  const files = [packageManifest, ...(lockManifest ? [lockManifest] : []), ...distFiles]
1990
1987
  .sort((left, right) => compareBridgeRuntimePaths(left.relativePath, right.relativePath));
@@ -2003,7 +2000,7 @@ export async function bridgeRuntimeContentDigest(packageRoot) {
2003
2000
  async function requireBridgeRuntimeFile(root, relativePath) {
2004
2001
  const file = await optionalBridgeRuntimeFile(root, relativePath);
2005
2002
  if (!file)
2006
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} is missing.`);
2003
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath} is missing.`);
2007
2004
  return file;
2008
2005
  }
2009
2006
  async function optionalBridgeRuntimeFile(root, relativePath) {
@@ -2012,7 +2009,7 @@ async function optionalBridgeRuntimeFile(root, relativePath) {
2012
2009
  if (!stat)
2013
2010
  return null;
2014
2011
  if (!stat.isFile() || stat.isSymbolicLink()) {
2015
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath} must be a regular file.`);
2012
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath} must be a regular file.`);
2016
2013
  }
2017
2014
  return { absolutePath, relativePath: relativePath.replace(/\\/gu, "/") };
2018
2015
  }
@@ -2024,14 +2021,14 @@ async function collectBridgeRuntimeFiles(root, relativeDirectory) {
2024
2021
  for (const entry of entries) {
2025
2022
  const relativePath = path.join(relativeDirectory, entry.name);
2026
2023
  if (entry.isSymbolicLink()) {
2027
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} cannot be a symbolic link.`);
2024
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath.replace(/\\/gu, "/")} cannot be a symbolic link.`);
2028
2025
  }
2029
2026
  if (entry.isDirectory()) {
2030
2027
  files.push(...await collectBridgeRuntimeFiles(root, relativePath));
2031
2028
  continue;
2032
2029
  }
2033
2030
  if (!entry.isFile()) {
2034
- throw new Error(`sandbox_build_failed: bridge runtime ${relativePath.replace(/\\/gu, "/")} must be a regular file.`);
2031
+ throw trustedSandboxFailure("sandbox_build_failed", `bridge runtime ${relativePath.replace(/\\/gu, "/")} must be a regular file.`);
2035
2032
  }
2036
2033
  files.push({
2037
2034
  absolutePath: path.join(root, relativePath),
@@ -2086,7 +2083,7 @@ async function copyOptionalBridgeManifest(packageRoot, bridgeRoot, fileName) {
2086
2083
  }
2087
2084
  export function buildxBuildArgs(builder, metadataFile, tail, environment = process.env, options = {}) {
2088
2085
  if (!/^forge-[0-9a-f]{20}$/u.test(builder))
2089
- throw new Error("sandbox_build_failed: invalid dedicated builder name.");
2086
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid dedicated builder name.");
2090
2087
  const resources = buildResourcePolicy(environment);
2091
2088
  const args = ["buildx", "build", "--builder", builder];
2092
2089
  if (options.load !== false)
@@ -2108,7 +2105,7 @@ export function dedicatedBuildxBuilderName(runnerId, dockerContext) {
2108
2105
  }
2109
2106
  export function dedicatedBuildxBuilderContainerName(builder) {
2110
2107
  if (!/^forge-[0-9a-f]{20}$/u.test(builder))
2111
- throw new Error("sandbox_build_failed: invalid dedicated builder name.");
2108
+ throw trustedSandboxFailure("sandbox_build_failed", "invalid dedicated builder name.");
2112
2109
  return `buildx_buildkit_${builder}0`;
2113
2110
  }
2114
2111
  function boundedBuildCacheSize(value, fallback) {
@@ -2132,7 +2129,7 @@ export function selectImageGcCandidates(records, keepPerProject, ttlMs, nowMs) {
2132
2129
  async function sandboxProviderAuthForDispatch(dispatch) {
2133
2130
  const unavailable = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
2134
2131
  if (unavailable) {
2135
- throw new Error(`sandbox_auth_unavailable: ${unavailable}`);
2132
+ throw trustedSandboxFailure("sandbox_auth_unavailable", unavailable);
2136
2133
  }
2137
2134
  const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
2138
2135
  return adapter ? adapter.prepareAttemptAuth() : null;
@@ -2176,7 +2173,7 @@ export function proxyEnvironmentArgs(dispatch, contract, resources) {
2176
2173
  export function egressProxyConfig(allowedHosts) {
2177
2174
  const domains = allowedHosts.map(host => host.startsWith("*.") ? `.${host.slice(2)}` : host).join(" ");
2178
2175
  if (!domains)
2179
- throw new Error("sandbox_egress_policy_invalid: public_allowlist requires at least one approved host.");
2176
+ throw trustedSandboxFailure("sandbox_egress_policy_invalid", "public_allowlist requires at least one approved host.");
2180
2177
  return [
2181
2178
  "http_port 3128",
2182
2179
  "access_log none",
@@ -2208,7 +2205,7 @@ export function serviceContainerArgs(service, image, resources, labels, security
2208
2205
  else {
2209
2206
  const healthCommand = environment.FORGE_SERVICE_BROWSER_HEALTH_CMD?.trim();
2210
2207
  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.");
2208
+ throw trustedSandboxFailure("sandbox_service_health_policy_invalid", "browser service needs an operator-configured bounded health command.");
2212
2209
  }
2213
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");
2214
2211
  }
@@ -2221,13 +2218,13 @@ export function resolveSandboxSecurityOptions(executionPolicy, environment = pro
2221
2218
  const seccomp = environment.FORGE_SANDBOX_SECCOMP_PROFILE?.trim();
2222
2219
  const lsm = environment.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim();
2223
2220
  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.");
2221
+ throw trustedSandboxFailure("sandbox_security_profile_invalid", "seccomp profile must be an absolute host path.");
2225
2222
  }
2226
2223
  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.");
2224
+ throw trustedSandboxFailure("sandbox_security_profile_invalid", "LSM security option is invalid.");
2228
2225
  }
2229
2226
  if (executionPolicy === "sandbox_required" && (!seccomp || !lsm)) {
2230
- throw new Error("sandbox_security_profile_unavailable: sandbox_required needs explicit seccomp and LSM profiles.");
2227
+ throw trustedSandboxFailure("sandbox_security_profile_unavailable", "sandbox_required needs explicit seccomp and LSM profiles.");
2231
2228
  }
2232
2229
  return [
2233
2230
  ...(seccomp ? ["--security-opt", `seccomp=${seccomp}`] : []),
@@ -2263,17 +2260,46 @@ function event(eventType, content, data) {
2263
2260
  };
2264
2261
  }
2265
2262
  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 };
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
+ };
2277
2303
  }
2278
2304
  export function classifySandboxResourceFailure(value) {
2279
2305
  const diagnostic = value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 2000);
@@ -2293,6 +2319,9 @@ export function classifySandboxResourceFailure(value) {
2293
2319
  }
2294
2320
  return null;
2295
2321
  }
2322
+ function trustedSandboxFailure(code, detail, options) {
2323
+ return new ForgeFailureError(code, `${code}: ${detail}`, "sandbox", options);
2324
+ }
2296
2325
  function parseSandboxEventLine(line) {
2297
2326
  if (!line.trim().startsWith("{"))
2298
2327
  return null;
@@ -2307,25 +2336,25 @@ function parseSandboxEventLine(line) {
2307
2336
  return null;
2308
2337
  const candidate = value.event;
2309
2338
  if (!candidate || typeof candidate !== "object")
2310
- throw new Error("sandbox_event_invalid: runner event envelope is missing event data.");
2339
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event envelope is missing event data.");
2311
2340
  const eventValue = candidate;
2312
2341
  for (const field of ["role", "kind", "content", "eventType"]) {
2313
2342
  if (typeof eventValue[field] !== "string")
2314
- throw new Error(`sandbox_event_invalid: ${field} must be a string.`);
2343
+ throw trustedSandboxFailure("sandbox_event_invalid", `${field} must be a string.`);
2315
2344
  }
2316
2345
  if (eventValue.role.length > 64
2317
2346
  || eventValue.kind.length > 128
2318
2347
  || eventValue.eventType.length > 128
2319
2348
  || Buffer.byteLength(eventValue.content, "utf8") > 256 * 1024) {
2320
- throw new Error("sandbox_event_invalid: runner event exceeds field bounds.");
2349
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event exceeds field bounds.");
2321
2350
  }
2322
2351
  if (eventValue.data !== undefined && eventValue.data !== null
2323
2352
  && (typeof eventValue.data !== "string" || Buffer.byteLength(eventValue.data, "utf8") > 256 * 1024)) {
2324
- throw new Error("sandbox_event_invalid: runner event data exceeds field bounds.");
2353
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event data exceeds field bounds.");
2325
2354
  }
2326
2355
  if (eventValue.occurredAt !== undefined && eventValue.occurredAt !== null
2327
2356
  && (typeof eventValue.occurredAt !== "string" || !Number.isFinite(Date.parse(eventValue.occurredAt)))) {
2328
- throw new Error("sandbox_event_invalid: runner event occurredAt is invalid.");
2357
+ throw trustedSandboxFailure("sandbox_event_invalid", "runner event occurredAt is invalid.");
2329
2358
  }
2330
2359
  return {
2331
2360
  role: eventValue.role,