@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

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 (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -29,7 +29,10 @@ export interface SandboxPreflightResult {
29
29
  readonly generation: number;
30
30
  readonly runtimeVersion: string | null;
31
31
  readonly maxConcurrentSandboxes: number;
32
+ readonly totalMemoryBytes: number;
32
33
  readonly availableMemoryBytes: number;
34
+ readonly hostReserveBytes: number;
35
+ readonly admissionMemoryBytes: number;
33
36
  readonly availableCpuMillis: number;
34
37
  readonly availableWorkspaceBytes: number;
35
38
  readonly diskLow: boolean;
@@ -0,0 +1,76 @@
1
+ import type { BridgeServiceConfig } from "../bridge/bridge.types.js";
2
+ import type { CommandRunner } from "./sandbox.types.js";
3
+ export interface SandboxSmokeRequest {
4
+ readonly repoPath: string;
5
+ readonly reportPath?: string;
6
+ readonly requirePinnedBase?: boolean;
7
+ readonly probeLoopbackPort?: boolean;
8
+ readonly onProgress?: (message: string) => void;
9
+ }
10
+ export interface SandboxSmokeStage {
11
+ readonly name: string;
12
+ readonly status: "passed" | "failed";
13
+ readonly durationMs: number;
14
+ }
15
+ export interface SandboxSmokeReport {
16
+ readonly schemaVersion: 1;
17
+ readonly kind: "forge_sandbox_smoke";
18
+ readonly status: "passed" | "failed";
19
+ readonly startedAt: string;
20
+ readonly completedAt: string;
21
+ readonly dispatchAttemptId: string;
22
+ readonly resourceLeaseId: string;
23
+ readonly repository: {
24
+ readonly name: string;
25
+ readonly contractDigest: string | null;
26
+ readonly dockerfileDigest: string | null;
27
+ readonly policyWarnings: readonly string[];
28
+ };
29
+ readonly docker: {
30
+ readonly runtimeVersion: string | null;
31
+ readonly contextConfigured: boolean;
32
+ readonly capacity: number;
33
+ readonly availableMemoryBytes: number;
34
+ readonly availableCpuMillis: number;
35
+ readonly availableWorkspaceBytes: number;
36
+ };
37
+ readonly publishedPortProbe: {
38
+ readonly containerPort: number;
39
+ readonly hostIp: "127.0.0.1";
40
+ readonly hostPort: number;
41
+ } | null;
42
+ readonly stages: readonly SandboxSmokeStage[];
43
+ readonly cleanup: {
44
+ readonly verified: boolean;
45
+ readonly removed: readonly string[];
46
+ readonly residual: {
47
+ readonly containers: number;
48
+ readonly networks: number;
49
+ readonly volumes: number;
50
+ readonly images: number;
51
+ };
52
+ };
53
+ readonly failure: {
54
+ readonly code: string;
55
+ readonly stage: string;
56
+ readonly message: string;
57
+ } | null;
58
+ }
59
+ export interface SandboxSmokeResult {
60
+ readonly report: SandboxSmokeReport;
61
+ readonly reportPath: string;
62
+ }
63
+ export declare class DockerSandboxSmoke {
64
+ private readonly config;
65
+ private readonly commands;
66
+ constructor(config: BridgeServiceConfig, commands?: CommandRunner);
67
+ run(request: SandboxSmokeRequest): Promise<SandboxSmokeResult>;
68
+ private createPreflight;
69
+ private stage;
70
+ private labelArgs;
71
+ private dockerArgs;
72
+ private requireDocker;
73
+ private cleanup;
74
+ private inventoryCount;
75
+ }
76
+ export declare function parseDockerAssignedLoopbackPort(output: string): SandboxSmokeReport["publishedPortProbe"];
@@ -0,0 +1,380 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { redactSecrets } from "../shared/redaction/redaction.js";
5
+ import { BoundedCommandRunner } from "./command-runner.js";
6
+ import { validateDockerfilePolicy, validateSandboxContract } from "./contract.js";
7
+ import { DockerPreflight } from "./preflight.js";
8
+ const managedLabel = "dev.forge.managed";
9
+ const smokeLabel = "dev.forge.smoke";
10
+ const attemptLabel = "dev.forge.dispatch-attempt-id";
11
+ const resourceLeaseLabel = "dev.forge.resource-lease-id";
12
+ export class DockerSandboxSmoke {
13
+ config;
14
+ commands;
15
+ constructor(config, commands = new BoundedCommandRunner()) {
16
+ this.config = config;
17
+ this.commands = commands;
18
+ }
19
+ async run(request) {
20
+ const startedAt = new Date();
21
+ const dispatchAttemptId = randomUUID();
22
+ const resourceLeaseId = randomUUID();
23
+ const token = dispatchAttemptId.replaceAll("-", "").slice(0, 20);
24
+ const resources = {
25
+ image: `forge-sandbox-smoke:${token}`,
26
+ container: `forge-smoke-agent-${token}`,
27
+ network: `forge-smoke-network-${token}`,
28
+ volume: `forge-smoke-workspace-${token}`,
29
+ portFixtureContainer: `forge-smoke-port-${token}`,
30
+ portFixtureNetwork: `forge-smoke-port-network-${token}`,
31
+ };
32
+ const reportPath = path.resolve(request.reportPath ?? path.join(this.config.sandboxStateRoot, "smoke-reports", `${startedAt.toISOString().replaceAll(":", "-")}-${token}.json`));
33
+ const evidence = {
34
+ contractDigest: null,
35
+ dockerfileDigest: null,
36
+ policyWarnings: [],
37
+ preflight: null,
38
+ stages: [],
39
+ removed: [],
40
+ residual: { containers: -1, networks: -1, volumes: -1, images: -1 },
41
+ cleanupVerified: false,
42
+ publishedPortProbe: null,
43
+ failure: null,
44
+ };
45
+ const preflight = this.createPreflight();
46
+ try {
47
+ await this.stage("doctor", evidence, request, async () => {
48
+ const forgePath = path.join(path.resolve(request.repoPath), ".forge");
49
+ const contractSource = await fs.readFile(path.join(forgePath, "forge.sandbox.yml"), "utf8");
50
+ const dockerfile = await fs.readFile(path.join(forgePath, "Dockerfile"), "utf8");
51
+ evidence.contractDigest = validateSandboxContract(contractSource).digest;
52
+ evidence.dockerfileDigest = `sha256:${createHash("sha256").update(dockerfile).digest("hex")}`;
53
+ evidence.policyWarnings = [...validateDockerfilePolicy(dockerfile, request.requirePinnedBase ?? false)];
54
+ evidence.preflight = await preflight.refresh();
55
+ if (!evidence.preflight.healthy) {
56
+ throw new SmokeFailure(evidence.preflight.unavailableCode ?? "sandbox_preflight_failed", "doctor", evidence.preflight.unavailableReason ?? "Docker preflight did not report a healthy runtime.");
57
+ }
58
+ });
59
+ await this.stage("build_image", evidence, request, async () => {
60
+ const dockerfilePath = path.join(path.resolve(request.repoPath), ".forge", "Dockerfile");
61
+ await this.requireDocker("sandbox_smoke_build_failed", "build_image", [
62
+ "build",
63
+ "--tag", resources.image,
64
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
65
+ "--file", dockerfilePath,
66
+ path.resolve(request.repoPath),
67
+ ], { timeoutMs: 15 * 60_000, maxOutputBytes: 64 * 1024 });
68
+ });
69
+ await this.stage("create_resources", evidence, request, async () => {
70
+ await this.requireDocker("sandbox_smoke_network_failed", "create_resources", [
71
+ "network", "create", "--internal",
72
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
73
+ resources.network,
74
+ ]);
75
+ await this.requireDocker("sandbox_smoke_volume_failed", "create_resources", [
76
+ "volume", "create",
77
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
78
+ resources.volume,
79
+ ]);
80
+ await this.requireDocker("sandbox_smoke_container_failed", "create_resources", [
81
+ "create",
82
+ "--name", resources.container,
83
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
84
+ "--network", resources.network,
85
+ "--read-only",
86
+ "--cap-drop", "ALL",
87
+ "--security-opt", "no-new-privileges",
88
+ "--pids-limit", "64",
89
+ "--memory", String(256 * 1024 ** 2),
90
+ "--memory-swap", String(256 * 1024 ** 2),
91
+ "--cpus", "0.5",
92
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
93
+ "--user", "10001:10001",
94
+ "--workdir", "/workspace",
95
+ "--mount", `type=volume,src=${resources.volume},dst=/workspace,volume-nocopy`,
96
+ "--env", `FORGE_SMOKE_ATTEMPT_ID=${dispatchAttemptId}`,
97
+ "--env", `FORGE_RESOURCE_LEASE_ID=${resourceLeaseId}`,
98
+ "--entrypoint", "/bin/sh",
99
+ resources.image,
100
+ "-lc",
101
+ `set -eu; test "$PWD" = /workspace; test "$FORGE_SMOKE_ATTEMPT_ID" = "${dispatchAttemptId}"; test "$FORGE_RESOURCE_LEASE_ID" = "${resourceLeaseId}"; test ! -S /var/run/docker.sock; printf 'FORGE_SANDBOX_SMOKE_OK\\n'`,
102
+ ]);
103
+ });
104
+ await this.stage("verify_isolation", evidence, request, async () => {
105
+ const inspect = await this.requireDocker("sandbox_smoke_inspect_failed", "verify_isolation", [
106
+ "inspect", "--format",
107
+ "{{json .Mounts}}|{{.Config.User}}|{{.HostConfig.ReadonlyRootfs}}|{{.HostConfig.Privileged}}|{{json .HostConfig.CapDrop}}|{{json .HostConfig.PortBindings}}",
108
+ resources.container,
109
+ ]);
110
+ const [mountsJson, user, readonlyRootfs, privileged, capDropJson, portBindingsJson] = inspect.stdout.trim().split("|");
111
+ const mounts = JSON.parse(mountsJson ?? "[]");
112
+ const capDrop = JSON.parse(capDropJson ?? "[]");
113
+ const workspaceMounted = mounts.some((mount) => mount.Destination === "/workspace" && mount.Name === resources.volume);
114
+ if (!workspaceMounted || user !== "10001:10001" || readonlyRootfs !== "true" || privileged !== "false"
115
+ || !capDrop.includes("ALL") || !["null", "{}"].includes(portBindingsJson ?? "")) {
116
+ throw new SmokeFailure("sandbox_smoke_isolation_failed", "verify_isolation", "The disposable container did not match the hardened smoke profile.");
117
+ }
118
+ });
119
+ await this.stage("execute", evidence, request, async () => {
120
+ const result = await this.requireDocker("sandbox_smoke_execution_failed", "execute", ["start", "--attach", resources.container]);
121
+ if (!result.stdout.includes("FORGE_SANDBOX_SMOKE_OK")) {
122
+ throw new SmokeFailure("sandbox_smoke_marker_missing", "execute", "The disposable container exited without its success marker.");
123
+ }
124
+ });
125
+ if (request.probeLoopbackPort) {
126
+ await this.stage("discover_loopback_port", evidence, request, async () => {
127
+ await this.requireDocker("sandbox_smoke_port_discovery_failed", "discover_loopback_port", [
128
+ "network", "create",
129
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
130
+ resources.portFixtureNetwork,
131
+ ]);
132
+ await this.requireDocker("sandbox_smoke_port_discovery_failed", "discover_loopback_port", [
133
+ "create",
134
+ "--name", resources.portFixtureContainer,
135
+ ...this.labelArgs(dispatchAttemptId, resourceLeaseId),
136
+ "--network", resources.portFixtureNetwork,
137
+ "--publish", "127.0.0.1::8080/tcp",
138
+ "--read-only",
139
+ "--cap-drop", "ALL",
140
+ "--security-opt", "no-new-privileges",
141
+ "--pids-limit", "32",
142
+ "--memory", String(128 * 1024 ** 2),
143
+ "--memory-swap", String(128 * 1024 ** 2),
144
+ "--cpus", "0.25",
145
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=16m,uid=10001,gid=10001",
146
+ "--user", "10001:10001",
147
+ "--entrypoint", "/bin/sh",
148
+ resources.image,
149
+ "-lc", "exec sleep 300",
150
+ ]);
151
+ await this.requireDocker("sandbox_smoke_port_discovery_failed", "discover_loopback_port", [
152
+ "start", resources.portFixtureContainer,
153
+ ]);
154
+ const inspect = await this.requireDocker("sandbox_smoke_port_discovery_failed", "discover_loopback_port", ["inspect", "--format", "{{json .NetworkSettings.Ports}}", resources.portFixtureContainer]);
155
+ evidence.publishedPortProbe = parseDockerAssignedLoopbackPort(inspect.stdout);
156
+ if (!evidence.publishedPortProbe) {
157
+ throw new SmokeFailure("sandbox_smoke_port_discovery_failed", "discover_loopback_port", "Docker did not assign exactly one valid loopback host port to the fixture.");
158
+ }
159
+ });
160
+ }
161
+ }
162
+ catch (error) {
163
+ evidence.failure = failureFrom(error);
164
+ }
165
+ finally {
166
+ await this.cleanup(resources, dispatchAttemptId, evidence, request);
167
+ await preflight.release();
168
+ }
169
+ if (!evidence.cleanupVerified && !evidence.failure) {
170
+ evidence.failure = {
171
+ code: "sandbox_smoke_cleanup_failed",
172
+ stage: "cleanup",
173
+ message: "Exact-label cleanup verification found residual or unreadable Docker resources.",
174
+ };
175
+ }
176
+ const completedAt = new Date();
177
+ const report = redactSecrets({
178
+ schemaVersion: 1,
179
+ kind: "forge_sandbox_smoke",
180
+ status: evidence.failure || !evidence.cleanupVerified ? "failed" : "passed",
181
+ startedAt: startedAt.toISOString(),
182
+ completedAt: completedAt.toISOString(),
183
+ dispatchAttemptId,
184
+ resourceLeaseId,
185
+ repository: {
186
+ name: path.basename(path.resolve(request.repoPath)),
187
+ contractDigest: evidence.contractDigest,
188
+ dockerfileDigest: evidence.dockerfileDigest,
189
+ policyWarnings: evidence.policyWarnings,
190
+ },
191
+ docker: {
192
+ runtimeVersion: evidence.preflight?.runtimeVersion ?? null,
193
+ contextConfigured: this.config.dockerContext.length > 0,
194
+ capacity: evidence.preflight?.maxConcurrentSandboxes ?? 0,
195
+ availableMemoryBytes: evidence.preflight?.availableMemoryBytes ?? 0,
196
+ availableCpuMillis: evidence.preflight?.availableCpuMillis ?? 0,
197
+ availableWorkspaceBytes: evidence.preflight?.availableWorkspaceBytes ?? 0,
198
+ },
199
+ publishedPortProbe: evidence.publishedPortProbe,
200
+ stages: evidence.stages,
201
+ cleanup: {
202
+ verified: evidence.cleanupVerified,
203
+ removed: evidence.removed,
204
+ residual: evidence.residual,
205
+ },
206
+ failure: evidence.failure,
207
+ });
208
+ await writeReport(reportPath, report);
209
+ return { report, reportPath };
210
+ }
211
+ createPreflight() {
212
+ return new DockerPreflight({
213
+ dockerExecutable: this.config.dockerExecutable,
214
+ dockerContext: this.config.dockerContext,
215
+ stateRoot: this.config.sandboxStateRoot,
216
+ maxConcurrentSandboxes: this.config.maxConcurrentSandboxes,
217
+ diskHighWatermarkPercent: this.config.diskHighWatermarkPercent,
218
+ diskLowWatermarkPercent: this.config.diskLowWatermarkPercent,
219
+ minimumWorkspaceBytes: this.config.minimumWorkspaceBytes,
220
+ hostMemoryReserveBytes: this.config.sandboxHostMemoryReserveBytes,
221
+ defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
222
+ defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
223
+ runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
224
+ requireRootless: this.config.requireRootlessDocker,
225
+ }, this.commands);
226
+ }
227
+ async stage(name, evidence, request, action) {
228
+ request.onProgress?.(name);
229
+ const started = Date.now();
230
+ try {
231
+ await action();
232
+ evidence.stages.push({ name, status: "passed", durationMs: Date.now() - started });
233
+ }
234
+ catch (error) {
235
+ evidence.stages.push({ name, status: "failed", durationMs: Date.now() - started });
236
+ if (error instanceof SmokeFailure)
237
+ throw error;
238
+ if (name === "doctor" && error instanceof Error && error.message.includes("docker_context_owned")) {
239
+ throw new SmokeFailure("sandbox_smoke_context_owned", name, "The Docker context is owned by a connected Forge bridge. Stop the bridge and retry.");
240
+ }
241
+ throw new SmokeFailure(`sandbox_smoke_${name}_failed`, name, name === "doctor"
242
+ ? "The repository contract, Dockerfile, or Docker preflight check failed."
243
+ : `The ${name.replaceAll("_", " ")} stage failed.`);
244
+ }
245
+ }
246
+ labelArgs(dispatchAttemptId, resourceLeaseId) {
247
+ return [
248
+ "--label", `${managedLabel}=true`,
249
+ "--label", `${smokeLabel}=true`,
250
+ "--label", `${attemptLabel}=${dispatchAttemptId}`,
251
+ "--label", `${resourceLeaseLabel}=${resourceLeaseId}`,
252
+ ];
253
+ }
254
+ dockerArgs(args) {
255
+ return this.config.dockerContext ? ["--context", this.config.dockerContext, ...args] : [...args];
256
+ }
257
+ async requireDocker(code, stage, args, options = {}) {
258
+ let result;
259
+ try {
260
+ result = await this.commands.run(this.config.dockerExecutable, this.dockerArgs(args), {
261
+ timeoutMs: options.timeoutMs ?? 60_000,
262
+ maxOutputBytes: options.maxOutputBytes ?? 16 * 1024,
263
+ });
264
+ }
265
+ catch {
266
+ throw new SmokeFailure(code, stage, "Docker could not execute the requested smoke stage.");
267
+ }
268
+ if (result.exitCode !== 0) {
269
+ throw new SmokeFailure(code, stage, `Docker exited with code ${result.exitCode} during the smoke stage.`);
270
+ }
271
+ return result;
272
+ }
273
+ async cleanup(resources, dispatchAttemptId, evidence, request) {
274
+ const started = Date.now();
275
+ request.onProgress?.("cleanup");
276
+ const removals = [
277
+ ["portFixtureContainer", ["rm", "--force", resources.portFixtureContainer]],
278
+ ["container", ["rm", "--force", resources.container]],
279
+ ["portFixtureNetwork", ["network", "rm", resources.portFixtureNetwork]],
280
+ ["network", ["network", "rm", resources.network]],
281
+ ["volume", ["volume", "rm", "--force", resources.volume]],
282
+ ["image", ["image", "rm", "--force", resources.image]],
283
+ ];
284
+ for (const [kind, args] of removals) {
285
+ try {
286
+ const result = await this.commands.run(this.config.dockerExecutable, this.dockerArgs(args), {
287
+ timeoutMs: 60_000,
288
+ maxOutputBytes: 8 * 1024,
289
+ });
290
+ if (result.exitCode === 0)
291
+ evidence.removed.push(kind);
292
+ }
293
+ catch {
294
+ // Exact-label inventory below is authoritative for cleanup verification.
295
+ }
296
+ }
297
+ evidence.residual = {
298
+ containers: await this.inventoryCount(["ps", "--all", "--quiet", "--filter", `label=${attemptLabel}=${dispatchAttemptId}`]),
299
+ networks: await this.inventoryCount(["network", "ls", "--quiet", "--filter", `label=${attemptLabel}=${dispatchAttemptId}`]),
300
+ volumes: await this.inventoryCount(["volume", "ls", "--quiet", "--filter", `label=${attemptLabel}=${dispatchAttemptId}`]),
301
+ images: await this.inventoryCount(["image", "ls", "--quiet", "--filter", `label=${attemptLabel}=${dispatchAttemptId}`]),
302
+ };
303
+ evidence.cleanupVerified = Object.values(evidence.residual).every((count) => count === 0);
304
+ evidence.stages.push({
305
+ name: "cleanup",
306
+ status: evidence.cleanupVerified ? "passed" : "failed",
307
+ durationMs: Date.now() - started,
308
+ });
309
+ }
310
+ async inventoryCount(args) {
311
+ try {
312
+ const result = await this.commands.run(this.config.dockerExecutable, this.dockerArgs(args), {
313
+ timeoutMs: 30_000,
314
+ maxOutputBytes: 64 * 1024,
315
+ });
316
+ if (result.exitCode !== 0)
317
+ return -1;
318
+ return result.stdout.split(/\r?\n/u).filter((line) => line.trim().length > 0).length;
319
+ }
320
+ catch {
321
+ return -1;
322
+ }
323
+ }
324
+ }
325
+ export function parseDockerAssignedLoopbackPort(output) {
326
+ try {
327
+ const parsed = JSON.parse(output.trim());
328
+ const bindings = Array.isArray(parsed)
329
+ ? parsed
330
+ : typeof parsed === "object" && parsed !== null
331
+ ? parsed["8080/tcp"]
332
+ : null;
333
+ if (!Array.isArray(bindings) || bindings.length !== 1)
334
+ return null;
335
+ const binding = bindings[0];
336
+ if (binding.HostIp !== "127.0.0.1" || typeof binding.HostPort !== "string" || !/^\d{1,5}$/u.test(binding.HostPort)) {
337
+ return null;
338
+ }
339
+ const hostPort = Number(binding.HostPort);
340
+ return hostPort >= 1 && hostPort <= 65535
341
+ ? { containerPort: 8080, hostIp: "127.0.0.1", hostPort }
342
+ : null;
343
+ }
344
+ catch {
345
+ return null;
346
+ }
347
+ }
348
+ class SmokeFailure extends Error {
349
+ code;
350
+ stage;
351
+ constructor(code, stage, message) {
352
+ super(message);
353
+ this.code = code;
354
+ this.stage = stage;
355
+ }
356
+ }
357
+ function failureFrom(error) {
358
+ if (error instanceof SmokeFailure) {
359
+ return { code: error.code, stage: error.stage, message: error.message };
360
+ }
361
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
362
+ return {
363
+ code: `sandbox_smoke_${String(error.code).toLowerCase()}`,
364
+ stage: "doctor",
365
+ message: "The repository sandbox contract or Dockerfile could not be read or validated.",
366
+ };
367
+ }
368
+ return {
369
+ code: "sandbox_smoke_failed",
370
+ stage: "unknown",
371
+ message: "The disposable sandbox smoke attempt failed.",
372
+ };
373
+ }
374
+ async function writeReport(reportPath, report) {
375
+ await fs.mkdir(path.dirname(reportPath), { recursive: true, mode: 0o700 });
376
+ const temporary = `${reportPath}.${randomUUID()}.tmp`;
377
+ await fs.writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
378
+ await fs.rename(temporary, reportPath);
379
+ await fs.chmod(reportPath, 0o600).catch(() => undefined);
380
+ }
@@ -31,6 +31,6 @@ export function terminationMessage(code) {
31
31
  switch (code) {
32
32
  case "sandbox_lease_expired": return "The durable execution lease expired inside the sandbox.";
33
33
  case "sandbox_wall_timeout": return "The sandbox reached its hard wall-clock limit.";
34
- case "sandbox_idle_timeout": return "The sandbox produced no control or process activity before its idle limit.";
34
+ case "sandbox_idle_timeout": return "The provider produced no process activity before the sandbox idle limit.";
35
35
  }
36
36
  }
@@ -41,7 +41,7 @@ async function main() {
41
41
  let bundleSha256 = null;
42
42
  let bundleBytes = 0;
43
43
  const bundlePath = path.join(controlRoot, "output", "result.bundle");
44
- if (!runOutcome.terminationCode && await exists(path.join(workspace, ".git"))) {
44
+ if (!runOutcome.terminationCode && runOutcome.exitCode === 0 && await exists(path.join(workspace, ".git"))) {
45
45
  resultCommitSha = (await capture("git", ["-C", workspace, "rev-parse", "HEAD"])).trim();
46
46
  const resultRef = `refs/forge/results/${attemptId.replace(/-/g, "")}`;
47
47
  await runRequired("git", ["-C", workspace, "update-ref", resultRef, resultCommitSha]);
@@ -123,10 +123,7 @@ async function run(command, args, watchdog) {
123
123
  readingUpdate = true;
124
124
  fs.readFile(watchdog.updatePath, "utf8")
125
125
  .then(value => {
126
- const applied = watchdog.deadline.apply(JSON.parse(value));
127
- if (applied)
128
- watchdog.supervisor.touch();
129
- return applied;
126
+ return watchdog.deadline.apply(JSON.parse(value));
130
127
  })
131
128
  .catch(() => false)
132
129
  .finally(() => { readingUpdate = false; });
@@ -10,3 +10,4 @@ export declare class SandboxWorkspaceService implements WorktreeService {
10
10
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
11
11
  release(_handle: WorktreeHandle): Promise<void>;
12
12
  }
13
+ export declare function sandboxInputRefFromBundle(bundleHeads: string, inputCommitSha: string): string;
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { GitClient } from "../shared/git/git.js";
4
4
  import { injectWorktreeArtifacts, filterArtifactsForLane } from "../worktree/artifact-injector.js";
5
- import { normalizeBranchName } from "../worktree/worktree.service.js";
5
+ import { hydratePriorPhaseArtifacts } from "../worktree/prior-phase-artifact-hydrator.js";
6
+ import { clearRunArtifactRoots, normalizeBranchName } from "../worktree/worktree.service.js";
6
7
  export class SandboxWorkspaceService {
7
8
  workspace;
8
9
  bundlePath;
@@ -23,7 +24,10 @@ export class SandboxWorkspaceService {
23
24
  const entries = await fs.readdir(this.workspace);
24
25
  if (entries.length > 0)
25
26
  throw new Error("Sandbox workspace must be empty before bundle import.");
26
- await this.git.run(path.dirname(this.workspace), ["clone", "--no-checkout", "--no-local", this.bundlePath, this.workspace]);
27
+ const bundleHeads = await this.git.run(path.dirname(this.workspace), ["bundle", "list-heads", this.bundlePath]);
28
+ const inputRef = sandboxInputRefFromBundle(bundleHeads, this.inputCommitSha);
29
+ await this.git.run(path.dirname(this.workspace), ["init", this.workspace]);
30
+ await this.git.run(this.workspace, ["fetch", "--no-tags", "--no-write-fetch-head", this.bundlePath, `${inputRef}:refs/forge/input`]);
27
31
  const actualSha = (await this.git.run(this.workspace, ["rev-parse", "--verify", `${this.inputCommitSha}^{commit}`])).trim();
28
32
  if (actualSha.toLowerCase() !== this.inputCommitSha.toLowerCase())
29
33
  throw new Error("Imported bundle does not contain the exact recorded input commit.");
@@ -32,18 +36,37 @@ export class SandboxWorkspaceService {
32
36
  await this.git.run(this.workspace, ["config", "user.email", "forge-agent@forge.local"]);
33
37
  await this.git.run(this.workspace, ["config", "user.name", "Forge Agent"]);
34
38
  await this.git.run(this.workspace, ["config", "core.hooksPath", "/dev/null"]);
39
+ await clearRunArtifactRoots(this.workspace, request);
35
40
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
36
- if (artifacts.length > 0)
37
- await injectWorktreeArtifacts(this.workspace, artifacts);
41
+ if (artifacts.length > 0) {
42
+ const trackedForgePaths = new Set((await this.git.run(this.workspace, ["ls-files", "-z", "--", ".forge"]))
43
+ .split("\0")
44
+ .filter(Boolean)
45
+ .map(value => value.replaceAll("\\", "/")));
46
+ await injectWorktreeArtifacts(this.workspace, artifacts, trackedForgePaths);
47
+ }
48
+ const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(this.workspace, request.priorPhaseArtifacts);
38
49
  return {
39
50
  repoPath: this.workspace,
40
51
  worktreePath: this.workspace,
41
52
  branchName,
42
53
  baseBranch: request.baseBranch,
43
- cleanupWorktrees: false
54
+ cleanupWorktrees: false,
55
+ hydratedPriorPhaseArtifactCount
44
56
  };
45
57
  }
46
58
  async release(_handle) {
47
59
  // The host supervisor owns the workspace volume lifecycle.
48
60
  }
49
61
  }
62
+ export function sandboxInputRefFromBundle(bundleHeads, inputCommitSha) {
63
+ const expectedSha = inputCommitSha.toLowerCase();
64
+ const matches = bundleHeads
65
+ .split(/\r?\n/u)
66
+ .map(line => /^([0-9a-f]{40}|[0-9a-f]{64}) (refs\/forge\/inputs\/[0-9a-f]{32})$/iu.exec(line.trim()))
67
+ .filter((match) => Boolean(match) && match[1].toLowerCase() === expectedSha);
68
+ if (matches.length !== 1) {
69
+ throw new Error("Sandbox input bundle does not expose the exact recorded input ref.");
70
+ }
71
+ return matches[0][2];
72
+ }
@@ -0,0 +1,12 @@
1
+ export declare const managedAttemptToolNames: readonly ["forge.run_managed_command", "run_managed_command", "forge.start_managed_command", "start_managed_command", "forge.get_managed_command", "get_managed_command", "forge.reset_generated_outputs", "reset_generated_outputs"];
2
+ export declare function isManagedAttemptTool(name: string): boolean;
3
+ export declare function managedAttemptToolDefinition(name: string): unknown;
4
+ export interface ManagedAttemptToolResult {
5
+ readonly content: readonly {
6
+ readonly type: "text";
7
+ readonly text: string;
8
+ }[];
9
+ readonly structuredContent: Readonly<Record<string, unknown>>;
10
+ readonly isError?: boolean;
11
+ }
12
+ export declare function callManagedAttemptTool(name: string, input: unknown, worktreePath: string): Promise<ManagedAttemptToolResult>;