@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba293ccf876b

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 (189) hide show
  1. package/README.md +127 -0
  2. package/dist/bridge/bridge.base.d.ts +8 -1
  3. package/dist/bridge/bridge.service.d.ts +98 -3
  4. package/dist/bridge/bridge.service.js +1425 -45
  5. package/dist/bridge/bridge.types.d.ts +205 -1
  6. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  7. package/dist/bridge/readiness-result-outbox.js +54 -0
  8. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  9. package/dist/bridge/readiness-result-worker.js +100 -0
  10. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  11. package/dist/bridge/runner-host-capabilities.js +93 -0
  12. package/dist/cleanup/cleanup.service.d.ts +14 -2
  13. package/dist/cleanup/cleanup.service.js +496 -3
  14. package/dist/cleanup/cleanup.types.d.ts +10 -0
  15. package/dist/cleanup/index.d.ts +1 -1
  16. package/dist/cleanup/index.js +1 -1
  17. package/dist/cleanup/internals.d.ts +2 -2
  18. package/dist/cleanup/internals.js +1 -1
  19. package/dist/cli/cli.help.js +47 -1
  20. package/dist/cli/cli.service.d.ts +45 -2
  21. package/dist/cli/cli.service.js +377 -27
  22. package/dist/cli/local-config.d.ts +5 -0
  23. package/dist/cli/local-config.js +45 -1
  24. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  25. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  26. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  27. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  28. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  29. package/dist/runtime/attempt-path-operation.js +94 -0
  30. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  31. package/dist/runtime/attempt-private-directory.js +54 -0
  32. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  33. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  34. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  35. package/dist/runtime/attempt-process-supervisor.js +386 -0
  36. package/dist/runtime/canonical-source-file.d.ts +3 -0
  37. package/dist/runtime/canonical-source-file.js +54 -0
  38. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  39. package/dist/runtime/docker-engine-probe.js +300 -0
  40. package/dist/runtime/generated-output-reset.d.ts +10 -0
  41. package/dist/runtime/generated-output-reset.js +103 -0
  42. package/dist/runtime/host-diagnostics.d.ts +4 -0
  43. package/dist/runtime/host-diagnostics.js +95 -0
  44. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  45. package/dist/runtime/host-listener-inspector.js +349 -0
  46. package/dist/runtime/host-port-broker.d.ts +76 -0
  47. package/dist/runtime/host-port-broker.js +333 -0
  48. package/dist/runtime/host-runtime-contract.d.ts +56 -0
  49. package/dist/runtime/host-runtime-contract.js +709 -0
  50. package/dist/runtime/host-runtime-lease.d.ts +201 -0
  51. package/dist/runtime/host-runtime-lease.js +2314 -0
  52. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  53. package/dist/runtime/host-runtime-promotion.js +206 -0
  54. package/dist/runtime/memory-capacity.d.ts +68 -0
  55. package/dist/runtime/memory-capacity.js +121 -0
  56. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  57. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  58. package/dist/runtime/pinned-directory-process.d.ts +4 -0
  59. package/dist/runtime/pinned-directory-process.js +20 -0
  60. package/dist/runtime/pinned-source-directory.d.ts +9 -0
  61. package/dist/runtime/pinned-source-directory.js +56 -0
  62. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  63. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  64. package/dist/runtime/prepared-runtime-identity.d.ts +3 -0
  65. package/dist/runtime/prepared-runtime-identity.js +191 -0
  66. package/dist/runtime/prepared-runtime-readiness.d.ts +22 -0
  67. package/dist/runtime/prepared-runtime-readiness.js +191 -0
  68. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  69. package/dist/runtime/runtime-isolation-lease.js +158 -0
  70. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  71. package/dist/runtime/workspace-resource-estimator.js +174 -0
  72. package/dist/sandbox/command-runner.d.ts +4 -0
  73. package/dist/sandbox/command-runner.js +47 -0
  74. package/dist/sandbox/contract.d.ts +5 -0
  75. package/dist/sandbox/contract.js +276 -0
  76. package/dist/sandbox/event-budget.d.ts +11 -0
  77. package/dist/sandbox/event-budget.js +27 -0
  78. package/dist/sandbox/image-policy.d.ts +35 -0
  79. package/dist/sandbox/image-policy.js +247 -0
  80. package/dist/sandbox/index.d.ts +5 -0
  81. package/dist/sandbox/index.js +5 -0
  82. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  83. package/dist/sandbox/lease-watchdog.js +67 -0
  84. package/dist/sandbox/preflight.d.ts +84 -0
  85. package/dist/sandbox/preflight.js +521 -0
  86. package/dist/sandbox/process.d.ts +20 -0
  87. package/dist/sandbox/process.js +209 -0
  88. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  89. package/dist/sandbox/provider-auth/codex.js +212 -0
  90. package/dist/sandbox/repository-lock.d.ts +5 -0
  91. package/dist/sandbox/repository-lock.js +121 -0
  92. package/dist/sandbox/runtime.d.ts +297 -0
  93. package/dist/sandbox/runtime.js +2338 -0
  94. package/dist/sandbox/sandbox.types.d.ts +93 -0
  95. package/dist/sandbox/sandbox.types.js +1 -0
  96. package/dist/sandbox/smoke.d.ts +76 -0
  97. package/dist/sandbox/smoke.js +380 -0
  98. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  99. package/dist/sandbox/worker-supervisor.js +36 -0
  100. package/dist/sandbox/worker.d.ts +1 -0
  101. package/dist/sandbox/worker.js +225 -0
  102. package/dist/sandbox/workspace.service.d.ts +13 -0
  103. package/dist/sandbox/workspace.service.js +72 -0
  104. package/dist/session/managed-attempt-tools.d.ts +21 -0
  105. package/dist/session/managed-attempt-tools.js +912 -0
  106. package/dist/session/managed-build-coordinator.d.ts +5 -0
  107. package/dist/session/managed-build-coordinator.js +105 -0
  108. package/dist/session/managed-build-receipt.d.ts +21 -0
  109. package/dist/session/managed-build-receipt.js +335 -0
  110. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  111. package/dist/session/phase-completion.artifacts.js +256 -26
  112. package/dist/session/phase-completion.d.ts +12 -2
  113. package/dist/session/phase-completion.helpers.d.ts +5 -0
  114. package/dist/session/phase-completion.helpers.js +51 -10
  115. package/dist/session/phase-completion.js +14 -3
  116. package/dist/session/phase-completion.prompts.d.ts +3 -3
  117. package/dist/session/phase-completion.prompts.js +24 -8
  118. package/dist/session/phase-completion.runtime.d.ts +13 -1
  119. package/dist/session/phase-completion.runtime.js +189 -8
  120. package/dist/session/phase-completion.schemas.js +117 -4
  121. package/dist/session/phase-completion.validation.d.ts +4 -1
  122. package/dist/session/phase-completion.validation.js +153 -22
  123. package/dist/session/pre-publication-validation.d.ts +47 -0
  124. package/dist/session/pre-publication-validation.js +249 -0
  125. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  126. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  127. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  128. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  129. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  130. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  131. package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
  132. package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
  133. package/dist/session/provider-cli/provider-cli.process.js +474 -23
  134. package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
  135. package/dist/session/provider-cli/provider-cli.prompts.js +147 -25
  136. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  137. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  138. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  139. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  140. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  141. package/dist/session/provider-cli/provider-cli.service.d.ts +25 -2
  142. package/dist/session/provider-cli/provider-cli.service.js +636 -84
  143. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  144. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  145. package/dist/session/provider-cli/provider-output-state.js +91 -16
  146. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  147. package/dist/session/provider-cli/provider-usage.js +28 -5
  148. package/dist/session/session-redaction.d.ts +3 -0
  149. package/dist/session/session-redaction.js +38 -0
  150. package/dist/session/session.base.d.ts +6 -1
  151. package/dist/session/session.service.d.ts +8 -2
  152. package/dist/session/session.service.js +531 -42
  153. package/dist/session/session.types.d.ts +115 -1
  154. package/dist/session/session.types.js +194 -3
  155. package/dist/shared/format/format-bytes.d.ts +1 -0
  156. package/dist/shared/format/format-bytes.js +9 -0
  157. package/dist/shared/git/git.js +2 -1
  158. package/dist/shared/path/path.d.ts +1 -0
  159. package/dist/shared/path/path.js +21 -1
  160. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  161. package/dist/shared/process/dotnet-toolchain.js +179 -0
  162. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  163. package/dist/shared/process/host-process-recovery.js +78 -0
  164. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  165. package/dist/shared/process/host-process-supervisor.js +330 -0
  166. package/dist/shared/process/process.d.ts +2 -0
  167. package/dist/shared/process/process.js +11 -6
  168. package/dist/shared/provider-capabilities.d.ts +1 -0
  169. package/dist/shared/provider-capabilities.js +3 -0
  170. package/dist/shared/redaction/redaction.js +12 -2
  171. package/dist/version.d.ts +1 -1
  172. package/dist/version.js +1 -1
  173. package/dist/worktree/artifact-injector.d.ts +3 -2
  174. package/dist/worktree/artifact-injector.js +43 -4
  175. package/dist/worktree/dependency-warmer.d.ts +28 -1
  176. package/dist/worktree/dependency-warmer.js +308 -9
  177. package/dist/worktree/index.d.ts +2 -1
  178. package/dist/worktree/index.js +1 -0
  179. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  180. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  181. package/dist/worktree/worktree-paths.d.ts +4 -0
  182. package/dist/worktree/worktree-paths.js +30 -0
  183. package/dist/worktree/worktree.base.d.ts +3 -0
  184. package/dist/worktree/worktree.service.d.ts +14 -2
  185. package/dist/worktree/worktree.service.js +296 -47
  186. package/dist/worktree/worktree.types.d.ts +5 -1
  187. package/package.json +8 -4
  188. package/runtime/windows-job-supervisor.ps1 +444 -0
  189. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,300 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BoundedCommandRunner } from "../sandbox/command-runner.js";
4
+ /**
5
+ * Probes ordinary Docker-daemon access for host-run Testcontainers and project
6
+ * services. This is deliberately independent from the stricter Forge sandbox
7
+ * preflight: a usable Docker Desktop engine may satisfy docker_engine even
8
+ * when it is not approved to host an agent sandbox.
9
+ */
10
+ export class DockerEngineProbe {
11
+ commands;
12
+ cacheMs;
13
+ cached = null;
14
+ sampledAtMs = 0;
15
+ inFlight = null;
16
+ consecutiveFailures = 0;
17
+ lastRecoveryAttemptAtMs = null;
18
+ docker;
19
+ context;
20
+ platform;
21
+ dockerHost;
22
+ now;
23
+ wait;
24
+ recovery;
25
+ recoveryExecutor;
26
+ onStatus;
27
+ constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 15_000) {
28
+ this.commands = commands;
29
+ this.cacheMs = cacheMs;
30
+ this.docker = options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
31
+ this.context = options.dockerContext?.trim() || process.env.DOCKER_CONTEXT?.trim() || "default";
32
+ this.platform = options.platform ?? process.platform;
33
+ this.dockerHost = options.dockerHost ?? process.env.DOCKER_HOST?.trim() ?? "";
34
+ this.now = options.now ?? Date.now;
35
+ this.wait = options.wait ?? (delayMs => new Promise(resolve => setTimeout(resolve, delayMs)));
36
+ this.recovery = {
37
+ enabled: options.recovery?.enabled ?? false,
38
+ failureThreshold: positiveInteger(options.recovery?.failureThreshold, 3),
39
+ confirmationDelayMs: positiveInteger(options.recovery?.confirmationDelayMs, 30_000),
40
+ cooldownMs: positiveInteger(options.recovery?.cooldownMs, 10 * 60_000),
41
+ startupTimeoutMs: positiveInteger(options.recovery?.startupTimeoutMs, 2 * 60_000),
42
+ pollIntervalMs: positiveInteger(options.recovery?.pollIntervalMs, 2_000),
43
+ canRecover: options.recovery?.canRecover,
44
+ };
45
+ this.recoveryExecutor = options.recoveryExecutor ?? new WindowsDockerDesktopRecoveryExecutor(this.platform, commands);
46
+ this.onStatus = options.onStatus ?? (() => undefined);
47
+ }
48
+ current() {
49
+ return this.cached;
50
+ }
51
+ async refresh(force = false) {
52
+ if (!force && this.cached && this.now() - this.sampledAtMs < this.cacheMs) {
53
+ return this.cached;
54
+ }
55
+ if (this.inFlight)
56
+ return await this.inFlight;
57
+ const operation = this.inspectSafely()
58
+ .then(result => this.recoverIfEligible(result))
59
+ .then((result) => {
60
+ this.cached = result;
61
+ this.sampledAtMs = this.now();
62
+ return result;
63
+ })
64
+ .finally(() => {
65
+ if (this.inFlight === operation)
66
+ this.inFlight = null;
67
+ });
68
+ this.inFlight = operation;
69
+ return await operation;
70
+ }
71
+ async inspectSafely() {
72
+ return await this.inspect().catch((error) => unavailable(this.context, "docker_engine_unreachable", error instanceof Error ? error.message : String(error)));
73
+ }
74
+ async recoverIfEligible(snapshot) {
75
+ if (snapshot.available) {
76
+ this.consecutiveFailures = 0;
77
+ return snapshot;
78
+ }
79
+ this.consecutiveFailures += 1;
80
+ if (!this.recovery.enabled
81
+ || !this.isLocalDockerDesktopContext()
82
+ || this.consecutiveFailures < this.recovery.failureThreshold
83
+ || this.recovery.canRecover?.() === false) {
84
+ return snapshot;
85
+ }
86
+ const now = this.now();
87
+ if (this.lastRecoveryAttemptAtMs !== null
88
+ && now - this.lastRecoveryAttemptAtMs < this.recovery.cooldownMs) {
89
+ return snapshot;
90
+ }
91
+ this.onStatus(`Docker engine remained unavailable for ${this.consecutiveFailures} consecutive probes; waiting for one fresh confirmation before recovery.`);
92
+ await this.wait(this.recovery.confirmationDelayMs);
93
+ const confirmed = await this.inspectSafely();
94
+ if (confirmed.available) {
95
+ this.consecutiveFailures = 0;
96
+ this.onStatus("Docker engine recovered during the confirmation window; skipped Docker Desktop recovery.");
97
+ return confirmed;
98
+ }
99
+ if (this.recovery.canRecover?.() === false) {
100
+ this.onStatus("Docker Desktop recovery was deferred because runner work became active during confirmation.");
101
+ return confirmed;
102
+ }
103
+ this.consecutiveFailures += 1;
104
+ this.lastRecoveryAttemptAtMs = this.now();
105
+ this.onStatus(`Docker engine remained unavailable after confirmation; starting one bounded Docker Desktop recovery.`);
106
+ const recovery = await this.recoveryExecutor.restart().catch((error) => ({
107
+ started: false,
108
+ reason: error instanceof Error ? error.message : String(error),
109
+ }));
110
+ if (!recovery.started) {
111
+ const reason = recovery.reason?.trim() || "Docker Desktop recovery could not be started.";
112
+ this.onStatus(`Docker Desktop recovery failed to start: ${singleLine(reason)}`);
113
+ return unavailable(this.context, "docker_engine_recovery_failed", reason);
114
+ }
115
+ const deadline = this.now() + this.recovery.startupTimeoutMs;
116
+ while (this.now() < deadline) {
117
+ await this.wait(Math.min(this.recovery.pollIntervalMs, Math.max(1, deadline - this.now())));
118
+ const verified = await this.inspectSafely();
119
+ if (verified.available) {
120
+ this.consecutiveFailures = 0;
121
+ this.onStatus("Docker Desktop recovery completed and the engine passed a fresh daemon probe.");
122
+ return verified;
123
+ }
124
+ }
125
+ this.onStatus("Docker Desktop recovery timed out before the engine became reachable; cooldown is active.");
126
+ return unavailable(this.context, "docker_engine_recovery_timeout", "Docker Desktop restart was requested, but the engine did not become reachable before the recovery timeout.");
127
+ }
128
+ isLocalDockerDesktopContext() {
129
+ return this.platform === "win32"
130
+ && !this.dockerHost
131
+ && (this.context === "default" || this.context === "desktop-linux");
132
+ }
133
+ async inspect() {
134
+ const dockerArgs = this.context === "default" ? [] : ["--context", this.context];
135
+ const result = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
136
+ if (result.exitCode !== 0) {
137
+ return unavailable(this.context, "docker_engine_unreachable", result.stderr || result.stdout || "Docker daemon is unreachable.");
138
+ }
139
+ let info;
140
+ try {
141
+ const parsed = JSON.parse(result.stdout.trim());
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
143
+ throw new Error("not an object");
144
+ info = parsed;
145
+ }
146
+ catch {
147
+ return unavailable(this.context, "docker_engine_invalid_response", "Docker returned an invalid info document.");
148
+ }
149
+ const version = clean(info.ServerVersion);
150
+ return {
151
+ available: true,
152
+ sampledAt: new Date().toISOString(),
153
+ runtimeVersion: version ? `docker/${version}` : "docker/unknown",
154
+ operatingSystem: clean(info.OSType)?.toLowerCase() ?? null,
155
+ context: this.context,
156
+ unavailableCode: null,
157
+ unavailableReason: null,
158
+ };
159
+ }
160
+ }
161
+ /**
162
+ * Restarts only the local Docker Desktop installation. It never targets a
163
+ * remote daemon and is used only behind the explicit bridge recovery policy.
164
+ */
165
+ export class WindowsDockerDesktopRecoveryExecutor {
166
+ platform;
167
+ commands;
168
+ programFiles;
169
+ wait;
170
+ constructor(platform = process.platform, commands = new BoundedCommandRunner(), programFiles = process.env.ProgramFiles ?? "C:\\Program Files", wait = delayMs => new Promise(resolve => setTimeout(resolve, delayMs))) {
171
+ this.platform = platform;
172
+ this.commands = commands;
173
+ this.programFiles = programFiles;
174
+ this.wait = wait;
175
+ }
176
+ async restart() {
177
+ if (this.platform !== "win32") {
178
+ return { started: false, reason: "Automatic Docker Desktop recovery is supported only on Windows." };
179
+ }
180
+ const installRoot = path.join(this.programFiles, "Docker", "Docker");
181
+ const desktopExecutable = path.join(installRoot, "Docker Desktop.exe");
182
+ if (!fs.existsSync(desktopExecutable)) {
183
+ return { started: false, reason: "Docker Desktop executable was not found in the standard installation directory." };
184
+ }
185
+ const processState = await this.readProcessState();
186
+ if (processState === "unknown") {
187
+ return { started: false, reason: "Docker Desktop process state could not be determined safely." };
188
+ }
189
+ if (processState === "stopped") {
190
+ if (!await this.cleanupOrphanedCompanions()) {
191
+ return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
192
+ }
193
+ await this.wait(1_000);
194
+ return await this.startDesktop(desktopExecutable);
195
+ }
196
+ const dockerCli = path.join(installRoot, "DockerCli.exe");
197
+ if (fs.existsSync(dockerCli)) {
198
+ await this.commands.run(dockerCli, ["-Shutdown"], {
199
+ timeoutMs: 15_000,
200
+ maxOutputBytes: 64 * 1024,
201
+ }).catch(() => undefined);
202
+ }
203
+ const stopScript = [
204
+ "$ErrorActionPreference = 'SilentlyContinue'",
205
+ "Get-Process | Where-Object { $_.ProcessName -in @('Docker Desktop', 'com.docker.backend') } | Stop-Process -Force",
206
+ ].join("; ");
207
+ await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", stopScript], {
208
+ timeoutMs: 15_000,
209
+ maxOutputBytes: 64 * 1024,
210
+ }).catch(() => undefined);
211
+ await this.commands.run("wsl.exe", ["--terminate", "docker-desktop"], {
212
+ timeoutMs: 15_000,
213
+ maxOutputBytes: 64 * 1024,
214
+ }).catch(() => undefined);
215
+ await this.wait(5_000);
216
+ const stoppedState = await this.readProcessState();
217
+ if (stoppedState !== "stopped") {
218
+ return {
219
+ started: false,
220
+ reason: stoppedState === "running"
221
+ ? "Docker Desktop processes did not stop before relaunch."
222
+ : "Docker Desktop shutdown state could not be verified safely.",
223
+ };
224
+ }
225
+ if (!await this.cleanupOrphanedCompanions()) {
226
+ return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
227
+ }
228
+ await this.wait(1_000);
229
+ return await this.startDesktop(desktopExecutable);
230
+ }
231
+ async readProcessState() {
232
+ const script = [
233
+ "$ErrorActionPreference = 'Stop'",
234
+ "$processes = @(Get-Process -Name 'Docker Desktop','com.docker.backend' -ErrorAction SilentlyContinue)",
235
+ "if ($processes.Count -gt 0) { Write-Output 'running' } else { Write-Output 'stopped' }",
236
+ ].join("; ");
237
+ const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
238
+ if (!result || result.exitCode !== 0)
239
+ return "unknown";
240
+ const state = result.stdout.trim().toLowerCase();
241
+ return state === "running" || state === "stopped" ? state : "unknown";
242
+ }
243
+ async cleanupOrphanedCompanions() {
244
+ const dockerRoot = path.join(this.programFiles, "Docker").replaceAll("'", "''");
245
+ const script = [
246
+ "$ErrorActionPreference = 'Stop'",
247
+ `$dockerRoot = [System.IO.Path]::GetFullPath('${dockerRoot}').TrimEnd('\\') + '\\'`,
248
+ "$names = @('com.docker.build.exe','docker-sandbox.exe','docker-agent.exe','local-sandboxesd.exe','docker-sandboxd.exe')",
249
+ "$candidates = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -in $names -and $_.ExecutablePath -and [System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($dockerRoot, [System.StringComparison]::OrdinalIgnoreCase) })",
250
+ "foreach ($candidate in $candidates) { $parent = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $candidate.ParentProcessId) -ErrorAction SilentlyContinue; $parentIsDockerBackend = $null -ne $parent -and $parent.Name -eq 'com.docker.backend.exe' -and $parent.ExecutablePath -and [System.IO.Path]::GetFullPath($parent.ExecutablePath).StartsWith($dockerRoot, [System.StringComparison]::OrdinalIgnoreCase); if (-not $parentIsDockerBackend) { Stop-Process -Id $candidate.ProcessId -Force -ErrorAction Stop } }",
251
+ ].join("; ");
252
+ const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 10_000, maxOutputBytes: 32 * 1024 }).catch(() => null);
253
+ return result?.exitCode === 0;
254
+ }
255
+ async startDesktop(desktopExecutable) {
256
+ const quotedDesktop = desktopExecutable.replaceAll("'", "''");
257
+ let lastReason = "Docker Desktop failed to start.";
258
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
259
+ const startScript = [
260
+ "$ErrorActionPreference = 'Stop'",
261
+ `$desktop = Start-Process -FilePath '${quotedDesktop}' -WindowStyle Hidden -PassThru`,
262
+ "Start-Sleep -Seconds 3",
263
+ "if ($desktop.HasExited) { Write-Error ('Docker Desktop exited during startup with code ' + $desktop.ExitCode); exit 1 }",
264
+ ].join("; ");
265
+ const start = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", startScript], { timeoutMs: 15_000, maxOutputBytes: 64 * 1024 }).catch((error) => ({
266
+ exitCode: 1,
267
+ stdout: "",
268
+ stderr: error instanceof Error ? error.message : String(error),
269
+ }));
270
+ if (start.exitCode === 0)
271
+ return { started: true };
272
+ lastReason = start.stderr || start.stdout || lastReason;
273
+ if (attempt < 3)
274
+ await this.wait(5_000);
275
+ }
276
+ return { started: false, reason: singleLine(lastReason) };
277
+ }
278
+ }
279
+ function unavailable(context, code, reason) {
280
+ return {
281
+ available: false,
282
+ sampledAt: new Date().toISOString(),
283
+ runtimeVersion: null,
284
+ operatingSystem: null,
285
+ context,
286
+ unavailableCode: code,
287
+ unavailableReason: singleLine(reason),
288
+ };
289
+ }
290
+ function clean(value) {
291
+ return typeof value === "string" && value.trim() ? value.trim() : null;
292
+ }
293
+ function singleLine(value) {
294
+ return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
295
+ }
296
+ function positiveInteger(value, fallback) {
297
+ if (value === undefined || !Number.isFinite(value) || value <= 0)
298
+ return fallback;
299
+ return Math.floor(value);
300
+ }
@@ -0,0 +1,10 @@
1
+ export interface GeneratedOutputResetResult {
2
+ readonly quarantineDirectory: string;
3
+ readonly quarantinedPaths: readonly string[];
4
+ }
5
+ /**
6
+ * Moves only known, untracked test-output directories out of a worktree before
7
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
8
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
9
+ */
10
+ export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
@@ -0,0 +1,103 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const generatedDirectoryNames = new Set(["TestResults", "test-results", "playwright-report"]);
8
+ const skippedDirectoryNames = new Set([
9
+ ".git",
10
+ ".forge",
11
+ ".idea",
12
+ ".next",
13
+ ".pnpm",
14
+ ".vs",
15
+ ".vscode",
16
+ "bin",
17
+ "build",
18
+ "coverage",
19
+ "dist",
20
+ "node_modules",
21
+ "obj",
22
+ "target",
23
+ "vendor",
24
+ ]);
25
+ /**
26
+ * Moves only known, untracked test-output directories out of a worktree before
27
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
28
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
29
+ */
30
+ export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
31
+ const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
32
+ const outputRoot = await fs.realpath(path.resolve(testOutputDirectory));
33
+ if (isContainedPath(worktreeRoot, outputRoot)) {
34
+ throw new Error("generated_output_reset_invalid: attempt output must be outside the source worktree.");
35
+ }
36
+ const candidates = await findGeneratedDirectories(worktreeRoot);
37
+ const quarantineDirectory = path.join(outputRoot, "preflight-reset");
38
+ const quarantinedPaths = [];
39
+ for (const candidate of candidates) {
40
+ const relativePath = path.relative(worktreeRoot, candidate);
41
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
42
+ throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
43
+ }
44
+ await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
45
+ await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
46
+ const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
47
+ const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
48
+ try {
49
+ await fs.rename(candidate, destination);
50
+ }
51
+ catch (error) {
52
+ const code = error.code ?? "unknown";
53
+ throw new Error(`generated_output_reset_failed: could not quarantine ${relativePath} (${code}).`, { cause: error });
54
+ }
55
+ quarantinedPaths.push(relativePath.split(path.sep).join("/"));
56
+ }
57
+ if (quarantinedPaths.length > 0) {
58
+ await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
59
+ }
60
+ return { quarantineDirectory, quarantinedPaths };
61
+ }
62
+ async function findGeneratedDirectories(root) {
63
+ const candidates = [];
64
+ const pending = [root];
65
+ while (pending.length > 0) {
66
+ const current = pending.pop();
67
+ const entries = await fs.readdir(current, { withFileTypes: true });
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory() || entry.isSymbolicLink())
70
+ continue;
71
+ if (skippedDirectoryNames.has(entry.name))
72
+ continue;
73
+ const candidate = path.join(current, entry.name);
74
+ if (generatedDirectoryNames.has(entry.name)) {
75
+ const realCandidate = await fs.realpath(candidate);
76
+ if (!isContainedPath(root, realCandidate)) {
77
+ throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
78
+ }
79
+ candidates.push(realCandidate);
80
+ continue;
81
+ }
82
+ pending.push(candidate);
83
+ }
84
+ }
85
+ return candidates.sort((left, right) => left.localeCompare(right));
86
+ }
87
+ async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
88
+ const pathspec = relativePath.split(path.sep).join("/");
89
+ let stdout;
90
+ try {
91
+ ({ stdout } = await execFileAsync("git", ["-C", worktreeRoot, "ls-files", "-z", "--", pathspec], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 }));
92
+ }
93
+ catch (error) {
94
+ throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
95
+ }
96
+ if (stdout.length > 0) {
97
+ throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
98
+ }
99
+ }
100
+ function isContainedPath(root, candidate) {
101
+ const relative = path.relative(root, candidate);
102
+ return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
103
+ }
@@ -0,0 +1,4 @@
1
+ import type { PinnedDirectoryIdentity } from "./pinned-directory-process.js";
2
+ /** Runner-local evidence, never web-served; browser-profile, cache and temporary directories are excluded. */
3
+ export declare function retainHostDiagnostics(stateRoot: string, attemptRoot: string, attemptId: string, expectedAttemptRootIdentity: PinnedDirectoryIdentity | null): Promise<string>;
4
+ export declare function assertHostAttemptRootIdentity(attemptRoot: string, expected: PinnedDirectoryIdentity): Promise<void>;
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { readCanonicalSourceFile } from "./canonical-source-file.js";
5
+ import { assertCanonicalDirectory, createCanonicalDirectory } from "./attempt-private-directory.js";
6
+ import { runAttemptArchiveOperation } from "./attempt-path-operation.js";
7
+ import { readPinnedSourceDirectory } from "./pinned-source-directory.js";
8
+ const maximumEvidenceBytes = 200 * 1024 * 1024;
9
+ /** Runner-local evidence, never web-served; browser-profile, cache and temporary directories are excluded. */
10
+ export async function retainHostDiagnostics(stateRoot, attemptRoot, attemptId, expectedAttemptRootIdentity) {
11
+ const identity = attemptId.replaceAll("-", "").toLowerCase();
12
+ if (!/^[0-9a-f]{32}$/u.test(identity)
13
+ || path.resolve(attemptRoot) !== path.resolve(stateRoot, "host-attempts", identity)) {
14
+ throw new Error("host_diagnostics_invalid_owner: refusing an unowned attempt path.");
15
+ }
16
+ const attemptStat = await fs.lstat(attemptRoot).catch(error => {
17
+ if (error.code === "ENOENT")
18
+ return null;
19
+ throw error;
20
+ });
21
+ if (attemptStat) {
22
+ if (!expectedAttemptRootIdentity) {
23
+ throw new Error("host_diagnostics_invalid_owner: original attempt-root identity is unavailable.");
24
+ }
25
+ await assertHostAttemptRootIdentity(attemptRoot, expectedAttemptRootIdentity);
26
+ }
27
+ const target = path.join(stateRoot, "diagnostics", "host", identity);
28
+ const manifestPath = path.join(target, "manifest.json");
29
+ await assertCanonicalDirectory(stateRoot);
30
+ await createCanonicalDirectory(target);
31
+ const priorManifest = await fs.lstat(manifestPath).catch(error => {
32
+ if (error.code === "ENOENT")
33
+ return null;
34
+ throw error;
35
+ });
36
+ if (priorManifest && (!priorManifest.isFile() || priorManifest.isSymbolicLink()))
37
+ throw new Error("host_diagnostics_unsafe_path: manifest is not an ordinary file.");
38
+ if (priorManifest && !attemptStat) {
39
+ const saved = JSON.parse((await readCanonicalSourceFile(manifestPath, maximumEvidenceBytes)).toString("utf8"));
40
+ if (saved.attemptId !== attemptId)
41
+ throw new Error("host_diagnostics_invalid_owner: archive identity mismatch.");
42
+ return target;
43
+ }
44
+ const files = [];
45
+ let bytes = 0;
46
+ let entries = 0;
47
+ const walk = async (relative, entry) => {
48
+ if (++entries > 10000)
49
+ throw new Error("host_diagnostics_budget: evidence inventory exceeds retention budget.");
50
+ const source = path.join(attemptRoot, relative);
51
+ if (entry.kind === "symlink")
52
+ throw new Error("host_diagnostics_unsafe_path: evidence contains a symlink.");
53
+ if (entry.kind === "directory") {
54
+ await createCanonicalDirectory(path.join(target, relative));
55
+ for (const child of await readPinnedSourceDirectory(source, entry.identity)) {
56
+ await walk(path.join(relative, child.name), child);
57
+ }
58
+ return;
59
+ }
60
+ if (entry.kind !== "file")
61
+ throw new Error("host_diagnostics_unsafe_path: evidence is not an ordinary file.");
62
+ if (files.length >= 5000 || entry.size > BigInt(maximumEvidenceBytes - bytes))
63
+ throw new Error("host_diagnostics_budget: evidence exceeds retention budget; original attempt retained for recovery.");
64
+ const destination = path.join(target, relative);
65
+ const contents = await readCanonicalSourceFile(source, maximumEvidenceBytes - bytes, entry.identity);
66
+ bytes += contents.length;
67
+ const sha256 = createHash("sha256").update(contents).digest("hex");
68
+ await writeArchiveFile(destination, contents, sha256);
69
+ files.push({ path: relative, bytes: contents.length, sha256 });
70
+ };
71
+ if (attemptStat) {
72
+ await assertHostAttemptRootIdentity(attemptRoot, expectedAttemptRootIdentity);
73
+ const rootEntries = await readPinnedSourceDirectory(attemptRoot, expectedAttemptRootIdentity);
74
+ for (const directory of ["logs", "test-output"]) {
75
+ const entry = rootEntries.find(candidate => candidate.name === directory);
76
+ if (entry)
77
+ await walk(directory, entry);
78
+ }
79
+ }
80
+ // Only owned relative evidence paths and hashes go into the public-facing manifest.
81
+ const manifest = Buffer.from(JSON.stringify({ schemaVersion: 1, attemptId, retainedAt: new Date().toISOString(), files }, null, 2));
82
+ await writeArchiveFile(manifestPath, manifest, createHash("sha256").update(manifest).digest("hex"));
83
+ return target;
84
+ }
85
+ export async function assertHostAttemptRootIdentity(attemptRoot, expected) {
86
+ const current = await assertCanonicalDirectory(attemptRoot);
87
+ if (!current || current.dev !== expected.dev || current.ino !== expected.ino) {
88
+ throw new Error("host_diagnostics_invalid_owner: attempt-root identity changed.");
89
+ }
90
+ }
91
+ async function writeArchiveFile(target, bytes, sha256) {
92
+ const parent = path.dirname(path.resolve(target));
93
+ const identity = (await assertCanonicalDirectory(parent));
94
+ await runAttemptArchiveOperation(parent, identity, { name: path.basename(target), bytes, sha256 });
95
+ }
@@ -0,0 +1,46 @@
1
+ import type { AttemptProcessChildHandle } from "./attempt-process-supervisor.js";
2
+ import type { HostPortLeaseOwner, HostPortReservation } from "./host-port-broker.js";
3
+ import type { HostListenerOwnership } from "./node-host-runtime-adapter.js";
4
+ export interface HostInspectionCommandResult {
5
+ readonly exitCode: number;
6
+ readonly stdout: string;
7
+ readonly stderr: string;
8
+ readonly errorCode?: string;
9
+ }
10
+ export interface HostInspectionCommandRunner {
11
+ run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
12
+ }
13
+ /** Proves that a loopback listener belongs to one exact supervised child. */
14
+ export declare class HostListenerInspector {
15
+ private readonly commands;
16
+ private readonly platform;
17
+ constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
18
+ inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
19
+ /** Returns loopback TCP ports opened by one exact supervised child process tree. */
20
+ inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
21
+ /** Snapshots loopback TCP ports that predate a supervised child launch. */
22
+ inspectLoopbackListeners(): Promise<readonly number[]>;
23
+ private inspectWindows;
24
+ private inspectAllWindows;
25
+ private inspectUnix;
26
+ private inspectAllUnix;
27
+ }
28
+ export declare function parseWindowsLoopbackListeners(output: string): readonly {
29
+ pid: number;
30
+ port: number;
31
+ }[];
32
+ export declare function parseLsofLoopbackListeners(output: string): readonly {
33
+ pid: number;
34
+ port: number;
35
+ }[];
36
+ export declare function parseSsLoopbackListeners(output: string): readonly {
37
+ pid: number;
38
+ port: number;
39
+ }[];
40
+ export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
41
+ run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
42
+ }
43
+ export declare function parseWindowsListenerPids(output: string, port: number): readonly number[];
44
+ export declare function parseLsofPids(output: string): readonly number[];
45
+ export declare function parseSsPids(output: string, port: number): readonly number[];
46
+ export declare function parseProcessGroups(output: string): ReadonlyMap<number, number>;