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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) 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 +1562 -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-node-runtime-launcher.d.ts +2 -1
  30. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  31. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  32. package/dist/runtime/attempt-path-operation.js +94 -0
  33. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  34. package/dist/runtime/attempt-private-directory.js +54 -0
  35. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  36. package/dist/runtime/attempt-process-supervisor.js +67 -20
  37. package/dist/runtime/canonical-source-file.d.ts +3 -0
  38. package/dist/runtime/canonical-source-file.js +54 -0
  39. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  40. package/dist/runtime/docker-engine-probe.js +333 -0
  41. package/dist/runtime/generated-output-reset.d.ts +10 -0
  42. package/dist/runtime/generated-output-reset.js +62 -8
  43. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  44. package/dist/runtime/host-database-capability-boundary.js +62 -0
  45. package/dist/runtime/host-diagnostics.d.ts +11 -0
  46. package/dist/runtime/host-diagnostics.js +191 -0
  47. package/dist/runtime/host-execution-root.d.ts +4 -0
  48. package/dist/runtime/host-execution-root.js +39 -0
  49. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  50. package/dist/runtime/host-listener-inspector.js +121 -60
  51. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  52. package/dist/runtime/host-runtime-contract.js +107 -53
  53. package/dist/runtime/host-runtime-lease.d.ts +90 -8
  54. package/dist/runtime/host-runtime-lease.js +1097 -108
  55. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  56. package/dist/runtime/host-runtime-promotion.js +59 -12
  57. package/dist/runtime/memory-capacity.d.ts +70 -0
  58. package/dist/runtime/memory-capacity.js +123 -0
  59. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  60. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  61. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  62. package/dist/runtime/node-host-runtime-adapter.js +28 -11
  63. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  64. package/dist/runtime/pinned-directory-process.js +61 -0
  65. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  66. package/dist/runtime/pinned-source-directory.js +94 -0
  67. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  68. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  69. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  70. package/dist/runtime/prepared-runtime-identity.js +218 -0
  71. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  72. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  73. package/dist/runtime/runtime-isolation-lease.js +4 -1
  74. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  75. package/dist/runtime/workspace-resource-estimator.js +174 -0
  76. package/dist/sandbox/preflight.d.ts +31 -1
  77. package/dist/sandbox/preflight.js +237 -51
  78. package/dist/sandbox/repository-lock.d.ts +2 -1
  79. package/dist/sandbox/repository-lock.js +17 -8
  80. package/dist/sandbox/runtime.d.ts +6 -1
  81. package/dist/sandbox/runtime.js +132 -103
  82. package/dist/sandbox/sandbox.types.d.ts +7 -0
  83. package/dist/sandbox/workspace.service.js +2 -0
  84. package/dist/session/managed-attempt-tools.d.ts +17 -1
  85. package/dist/session/managed-attempt-tools.js +481 -31
  86. package/dist/session/managed-build-coordinator.d.ts +5 -0
  87. package/dist/session/managed-build-coordinator.js +105 -0
  88. package/dist/session/managed-build-receipt.d.ts +21 -0
  89. package/dist/session/managed-build-receipt.js +337 -0
  90. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  91. package/dist/session/managed-verification-timeouts.js +17 -0
  92. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  93. package/dist/session/phase-completion.artifacts.js +224 -22
  94. package/dist/session/phase-completion.d.ts +11 -1
  95. package/dist/session/phase-completion.helpers.d.ts +5 -0
  96. package/dist/session/phase-completion.helpers.js +30 -7
  97. package/dist/session/phase-completion.js +14 -3
  98. package/dist/session/phase-completion.prompts.js +17 -18
  99. package/dist/session/phase-completion.runtime.d.ts +3 -1
  100. package/dist/session/phase-completion.runtime.js +118 -16
  101. package/dist/session/phase-completion.schemas.js +49 -2
  102. package/dist/session/phase-completion.validation.d.ts +1 -0
  103. package/dist/session/phase-completion.validation.js +111 -21
  104. package/dist/session/pre-publication-validation.d.ts +75 -0
  105. package/dist/session/pre-publication-validation.js +657 -0
  106. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  107. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  108. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  109. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  110. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  111. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  112. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  113. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  114. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  115. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  116. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  117. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  118. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  119. package/dist/session/provider-cli/provider-cli.runtime.d.ts +8 -1
  120. package/dist/session/provider-cli/provider-cli.runtime.js +40 -3
  121. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  122. package/dist/session/provider-cli/provider-cli.service.js +771 -68
  123. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  124. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  125. package/dist/session/provider-cli/provider-output-state.js +361 -17
  126. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  127. package/dist/session/provider-cli/provider-usage.js +28 -5
  128. package/dist/session/session-redaction.js +434 -9
  129. package/dist/session/session.service.js +145 -23
  130. package/dist/session/session.types.d.ts +147 -2
  131. package/dist/session/session.types.js +587 -7
  132. package/dist/session/unreferenced-exports.d.ts +12 -0
  133. package/dist/session/unreferenced-exports.js +59 -0
  134. package/dist/shared/format/format-bytes.d.ts +1 -0
  135. package/dist/shared/format/format-bytes.js +9 -0
  136. package/dist/shared/git/git.js +2 -1
  137. package/dist/shared/path/path.d.ts +1 -0
  138. package/dist/shared/path/path.js +18 -0
  139. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  140. package/dist/shared/process/dotnet-toolchain.js +307 -0
  141. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  142. package/dist/shared/process/host-process-supervisor.js +57 -9
  143. package/dist/shared/process/process.d.ts +2 -0
  144. package/dist/shared/process/process.js +12 -7
  145. package/dist/shared/provider-capabilities.d.ts +3 -0
  146. package/dist/shared/provider-capabilities.js +85 -0
  147. package/dist/shared/redaction/redaction.d.ts +1 -0
  148. package/dist/shared/redaction/redaction.js +39 -3
  149. package/dist/version.d.ts +1 -1
  150. package/dist/version.js +1 -1
  151. package/dist/worktree/dependency-warmer.d.ts +41 -9
  152. package/dist/worktree/dependency-warmer.js +497 -22
  153. package/dist/worktree/index.d.ts +1 -1
  154. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  155. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  156. package/dist/worktree/worktree.service.d.ts +7 -0
  157. package/dist/worktree/worktree.service.js +253 -21
  158. package/dist/worktree/worktree.types.d.ts +7 -1
  159. package/package.json +7 -5
  160. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -0,0 +1,333 @@
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
+ requiredWorkRecoveryInFlight = null;
17
+ consecutiveFailures = 0;
18
+ lastRecoveryAttemptAtMs = null;
19
+ docker;
20
+ context;
21
+ platform;
22
+ dockerHost;
23
+ now;
24
+ wait;
25
+ recovery;
26
+ recoveryExecutor;
27
+ onStatus;
28
+ constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 15_000) {
29
+ this.commands = commands;
30
+ this.cacheMs = cacheMs;
31
+ this.docker = options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
32
+ this.context = options.dockerContext?.trim() || process.env.DOCKER_CONTEXT?.trim() || "default";
33
+ this.platform = options.platform ?? process.platform;
34
+ this.dockerHost = options.dockerHost ?? process.env.DOCKER_HOST?.trim() ?? "";
35
+ this.now = options.now ?? Date.now;
36
+ this.wait = options.wait ?? (delayMs => new Promise(resolve => setTimeout(resolve, delayMs)));
37
+ this.recovery = {
38
+ enabled: options.recovery?.enabled ?? false,
39
+ failureThreshold: positiveInteger(options.recovery?.failureThreshold, 3),
40
+ confirmationDelayMs: positiveInteger(options.recovery?.confirmationDelayMs, 30_000),
41
+ cooldownMs: positiveInteger(options.recovery?.cooldownMs, 10 * 60_000),
42
+ startupTimeoutMs: positiveInteger(options.recovery?.startupTimeoutMs, 2 * 60_000),
43
+ pollIntervalMs: positiveInteger(options.recovery?.pollIntervalMs, 2_000),
44
+ canRecover: options.recovery?.canRecover,
45
+ };
46
+ this.recoveryExecutor = options.recoveryExecutor ?? new WindowsDockerDesktopRecoveryExecutor(this.platform, commands);
47
+ this.onStatus = options.onStatus ?? (() => undefined);
48
+ }
49
+ current() {
50
+ return this.cached;
51
+ }
52
+ async refresh(force = false) {
53
+ if (!force && this.cached && this.now() - this.sampledAtMs < this.cacheMs) {
54
+ return this.cached;
55
+ }
56
+ if (this.inFlight)
57
+ return await this.inFlight;
58
+ const operation = this.inspectSafely()
59
+ .then(result => this.recoverIfEligible(result))
60
+ .then((result) => {
61
+ this.cached = result;
62
+ this.sampledAtMs = this.now();
63
+ return result;
64
+ })
65
+ .finally(() => {
66
+ if (this.inFlight === operation)
67
+ this.inFlight = null;
68
+ });
69
+ this.inFlight = operation;
70
+ return await operation;
71
+ }
72
+ /**
73
+ * Establishes a fresh daemon fence for work that declares Docker as a hard
74
+ * prerequisite. This remains bounded, cooldown protected and single-flight;
75
+ * it never contacts Docker unless a caller explicitly invokes this method.
76
+ */
77
+ async ensureAvailableForRequiredWork() {
78
+ const fresh = await this.refresh(true);
79
+ if (fresh.available || !this.recovery.enabled || !this.isLocalDockerDesktopContext()) {
80
+ return fresh;
81
+ }
82
+ if (this.requiredWorkRecoveryInFlight)
83
+ return await this.requiredWorkRecoveryInFlight;
84
+ const operation = this.recoverIfEligible(fresh, true)
85
+ .then((result) => {
86
+ this.cached = result;
87
+ this.sampledAtMs = this.now();
88
+ return result;
89
+ })
90
+ .finally(() => {
91
+ if (this.requiredWorkRecoveryInFlight === operation)
92
+ this.requiredWorkRecoveryInFlight = null;
93
+ });
94
+ this.requiredWorkRecoveryInFlight = operation;
95
+ return await operation;
96
+ }
97
+ async inspectSafely() {
98
+ return await this.inspect().catch((error) => unavailable(this.context, "docker_engine_unreachable", error instanceof Error ? error.message : String(error)));
99
+ }
100
+ async recoverIfEligible(snapshot, requiredWork = false) {
101
+ if (snapshot.available) {
102
+ this.consecutiveFailures = 0;
103
+ return snapshot;
104
+ }
105
+ this.consecutiveFailures += 1;
106
+ if (!this.recovery.enabled
107
+ || !this.isLocalDockerDesktopContext()
108
+ || (!requiredWork && this.consecutiveFailures < this.recovery.failureThreshold)
109
+ || (!requiredWork && this.recovery.canRecover?.() === false)) {
110
+ return snapshot;
111
+ }
112
+ const now = this.now();
113
+ if (this.lastRecoveryAttemptAtMs !== null
114
+ && now - this.lastRecoveryAttemptAtMs < this.recovery.cooldownMs) {
115
+ return snapshot;
116
+ }
117
+ this.onStatus(`Docker engine remained unavailable for ${this.consecutiveFailures} consecutive probes; waiting for one fresh confirmation before recovery.`);
118
+ await this.wait(this.recovery.confirmationDelayMs);
119
+ const confirmed = await this.inspectSafely();
120
+ if (confirmed.available) {
121
+ this.consecutiveFailures = 0;
122
+ this.onStatus("Docker engine recovered during the confirmation window; skipped Docker Desktop recovery.");
123
+ return confirmed;
124
+ }
125
+ if (!requiredWork && this.recovery.canRecover?.() === false) {
126
+ this.onStatus("Docker Desktop recovery was deferred because runner work became active during confirmation.");
127
+ return confirmed;
128
+ }
129
+ this.consecutiveFailures += 1;
130
+ this.lastRecoveryAttemptAtMs = this.now();
131
+ this.onStatus(`Docker engine remained unavailable after confirmation; starting one bounded Docker Desktop recovery.`);
132
+ const recovery = await this.recoveryExecutor.restart().catch((error) => ({
133
+ started: false,
134
+ reason: error instanceof Error ? error.message : String(error),
135
+ }));
136
+ if (!recovery.started) {
137
+ const reason = recovery.reason?.trim() || "Docker Desktop recovery could not be started.";
138
+ this.onStatus(`Docker Desktop recovery failed to start: ${singleLine(reason)}`);
139
+ return unavailable(this.context, "docker_engine_recovery_failed", reason);
140
+ }
141
+ const deadline = this.now() + this.recovery.startupTimeoutMs;
142
+ while (this.now() < deadline) {
143
+ await this.wait(Math.min(this.recovery.pollIntervalMs, Math.max(1, deadline - this.now())));
144
+ const verified = await this.inspectSafely();
145
+ if (verified.available) {
146
+ this.consecutiveFailures = 0;
147
+ this.onStatus("Docker Desktop recovery completed and the engine passed a fresh daemon probe.");
148
+ return verified;
149
+ }
150
+ }
151
+ this.onStatus("Docker Desktop recovery timed out before the engine became reachable; cooldown is active.");
152
+ return unavailable(this.context, "docker_engine_recovery_timeout", "Docker Desktop restart was requested, but the engine did not become reachable before the recovery timeout.");
153
+ }
154
+ isLocalDockerDesktopContext() {
155
+ return this.platform === "win32"
156
+ && !this.dockerHost
157
+ && (this.context === "default" || this.context === "desktop-linux");
158
+ }
159
+ async inspect() {
160
+ const dockerArgs = this.context === "default" ? [] : ["--context", this.context];
161
+ const result = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
162
+ if (result.exitCode !== 0) {
163
+ return unavailable(this.context, "docker_engine_unreachable", result.stderr || result.stdout || "Docker daemon is unreachable.");
164
+ }
165
+ let info;
166
+ try {
167
+ const parsed = JSON.parse(result.stdout.trim());
168
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
169
+ throw new Error("not an object");
170
+ info = parsed;
171
+ }
172
+ catch {
173
+ return unavailable(this.context, "docker_engine_invalid_response", "Docker returned an invalid info document.");
174
+ }
175
+ const version = clean(info.ServerVersion);
176
+ const operatingSystem = clean(info.OSType)?.toLowerCase() ?? null;
177
+ const serverErrors = Array.isArray(info.ServerErrors)
178
+ ? info.ServerErrors.map(clean).filter((value) => Boolean(value))
179
+ : [];
180
+ if (serverErrors.length > 0 || !version || !operatingSystem) {
181
+ return unavailable(this.context, "docker_engine_unavailable_response", serverErrors.join(" ") || "Docker returned an incomplete daemon identity.");
182
+ }
183
+ return {
184
+ available: true,
185
+ sampledAt: new Date().toISOString(),
186
+ runtimeVersion: `docker/${version}`,
187
+ operatingSystem,
188
+ context: this.context,
189
+ unavailableCode: null,
190
+ unavailableReason: null,
191
+ };
192
+ }
193
+ }
194
+ /**
195
+ * Restarts only the local Docker Desktop installation. It never targets a
196
+ * remote daemon and is used only behind the explicit bridge recovery policy.
197
+ */
198
+ export class WindowsDockerDesktopRecoveryExecutor {
199
+ platform;
200
+ commands;
201
+ programFiles;
202
+ wait;
203
+ constructor(platform = process.platform, commands = new BoundedCommandRunner(), programFiles = process.env.ProgramFiles ?? "C:\\Program Files", wait = delayMs => new Promise(resolve => setTimeout(resolve, delayMs))) {
204
+ this.platform = platform;
205
+ this.commands = commands;
206
+ this.programFiles = programFiles;
207
+ this.wait = wait;
208
+ }
209
+ async restart() {
210
+ if (this.platform !== "win32") {
211
+ return { started: false, reason: "Automatic Docker Desktop recovery is supported only on Windows." };
212
+ }
213
+ const installRoot = path.join(this.programFiles, "Docker", "Docker");
214
+ const desktopExecutable = path.join(installRoot, "Docker Desktop.exe");
215
+ if (!fs.existsSync(desktopExecutable)) {
216
+ return { started: false, reason: "Docker Desktop executable was not found in the standard installation directory." };
217
+ }
218
+ const processState = await this.readProcessState();
219
+ if (processState === "unknown") {
220
+ return { started: false, reason: "Docker Desktop process state could not be determined safely." };
221
+ }
222
+ if (processState === "stopped") {
223
+ if (!await this.cleanupOrphanedCompanions()) {
224
+ return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
225
+ }
226
+ await this.wait(1_000);
227
+ return await this.startDesktop(desktopExecutable);
228
+ }
229
+ const dockerCli = path.join(installRoot, "DockerCli.exe");
230
+ if (fs.existsSync(dockerCli)) {
231
+ await this.commands.run(dockerCli, ["-Shutdown"], {
232
+ timeoutMs: 15_000,
233
+ maxOutputBytes: 64 * 1024,
234
+ }).catch(() => undefined);
235
+ }
236
+ const stopScript = [
237
+ "$ErrorActionPreference = 'SilentlyContinue'",
238
+ "Get-Process | Where-Object { $_.ProcessName -in @('Docker Desktop', 'com.docker.backend') } | Stop-Process -Force",
239
+ ].join("; ");
240
+ await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", stopScript], {
241
+ timeoutMs: 15_000,
242
+ maxOutputBytes: 64 * 1024,
243
+ }).catch(() => undefined);
244
+ await this.commands.run("wsl.exe", ["--terminate", "docker-desktop"], {
245
+ timeoutMs: 15_000,
246
+ maxOutputBytes: 64 * 1024,
247
+ }).catch(() => undefined);
248
+ await this.wait(5_000);
249
+ const stoppedState = await this.readProcessState();
250
+ if (stoppedState !== "stopped") {
251
+ return {
252
+ started: false,
253
+ reason: stoppedState === "running"
254
+ ? "Docker Desktop processes did not stop before relaunch."
255
+ : "Docker Desktop shutdown state could not be verified safely.",
256
+ };
257
+ }
258
+ if (!await this.cleanupOrphanedCompanions()) {
259
+ return { started: false, reason: "Orphaned Docker Desktop companion processes could not be verified and cleaned safely." };
260
+ }
261
+ await this.wait(1_000);
262
+ return await this.startDesktop(desktopExecutable);
263
+ }
264
+ async readProcessState() {
265
+ const script = [
266
+ "$ErrorActionPreference = 'Stop'",
267
+ "$processes = @(Get-Process -Name 'Docker Desktop','com.docker.backend' -ErrorAction SilentlyContinue)",
268
+ "if ($processes.Count -gt 0) { Write-Output 'running' } else { Write-Output 'stopped' }",
269
+ ].join("; ");
270
+ const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
271
+ if (!result || result.exitCode !== 0)
272
+ return "unknown";
273
+ const state = result.stdout.trim().toLowerCase();
274
+ return state === "running" || state === "stopped" ? state : "unknown";
275
+ }
276
+ async cleanupOrphanedCompanions() {
277
+ const dockerRoot = path.join(this.programFiles, "Docker").replaceAll("'", "''");
278
+ const script = [
279
+ "$ErrorActionPreference = 'Stop'",
280
+ `$dockerRoot = [System.IO.Path]::GetFullPath('${dockerRoot}').TrimEnd('\\') + '\\'`,
281
+ "$names = @('com.docker.build.exe','docker-sandbox.exe','docker-agent.exe','local-sandboxesd.exe','docker-sandboxd.exe')",
282
+ "$candidates = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -in $names -and $_.ExecutablePath -and [System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($dockerRoot, [System.StringComparison]::OrdinalIgnoreCase) })",
283
+ "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 } }",
284
+ ].join("; ");
285
+ const result = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 10_000, maxOutputBytes: 32 * 1024 }).catch(() => null);
286
+ return result?.exitCode === 0;
287
+ }
288
+ async startDesktop(desktopExecutable) {
289
+ const quotedDesktop = desktopExecutable.replaceAll("'", "''");
290
+ let lastReason = "Docker Desktop failed to start.";
291
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
292
+ const startScript = [
293
+ "$ErrorActionPreference = 'Stop'",
294
+ `$desktop = Start-Process -FilePath '${quotedDesktop}' -WindowStyle Hidden -PassThru`,
295
+ "Start-Sleep -Seconds 3",
296
+ "if ($desktop.HasExited) { Write-Error ('Docker Desktop exited during startup with code ' + $desktop.ExitCode); exit 1 }",
297
+ ].join("; ");
298
+ const start = await this.commands.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", startScript], { timeoutMs: 15_000, maxOutputBytes: 64 * 1024 }).catch((error) => ({
299
+ exitCode: 1,
300
+ stdout: "",
301
+ stderr: error instanceof Error ? error.message : String(error),
302
+ }));
303
+ if (start.exitCode === 0)
304
+ return { started: true };
305
+ lastReason = start.stderr || start.stdout || lastReason;
306
+ if (attempt < 3)
307
+ await this.wait(5_000);
308
+ }
309
+ return { started: false, reason: singleLine(lastReason) };
310
+ }
311
+ }
312
+ function unavailable(context, code, reason) {
313
+ return {
314
+ available: false,
315
+ sampledAt: new Date().toISOString(),
316
+ runtimeVersion: null,
317
+ operatingSystem: null,
318
+ context,
319
+ unavailableCode: code,
320
+ unavailableReason: singleLine(reason),
321
+ };
322
+ }
323
+ function clean(value) {
324
+ return typeof value === "string" && value.trim() ? value.trim() : null;
325
+ }
326
+ function singleLine(value) {
327
+ return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
328
+ }
329
+ function positiveInteger(value, fallback) {
330
+ if (value === undefined || !Number.isFinite(value) || value <= 0)
331
+ return fallback;
332
+ return Math.floor(value);
333
+ }
@@ -1,10 +1,20 @@
1
+ export declare function isGeneratedOutputDirectoryName(name: string): boolean;
2
+ /**
3
+ * Returns the generated-output directory that contains a Git-relative path,
4
+ * provided the reset walker would reach it. Paths below discovery boundaries
5
+ * such as node_modules or .forge are intentionally not reset candidates.
6
+ */
7
+ export declare function generatedOutputRootForPath(relativePath: string): string | null;
1
8
  export interface GeneratedOutputResetResult {
2
9
  readonly quarantineDirectory: string;
3
10
  readonly quarantinedPaths: readonly string[];
11
+ readonly retainedPaths: readonly string[];
4
12
  }
5
13
  /**
6
14
  * Moves only known, untracked test-output directories out of a worktree before
7
15
  * a managed attempt starts. The quarantine lives in the attempt-owned output
8
16
  * directory, so cleanup is recoverable and cannot cross the attempt boundary.
17
+ * A directory with tracked files is committed source, such as recorded test
18
+ * evidence, so it is left untouched and reported instead of being moved.
9
19
  */
10
20
  export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
@@ -22,10 +22,31 @@ const skippedDirectoryNames = new Set([
22
22
  "target",
23
23
  "vendor",
24
24
  ]);
25
+ export function isGeneratedOutputDirectoryName(name) {
26
+ return generatedDirectoryNames.has(name);
27
+ }
28
+ /**
29
+ * Returns the generated-output directory that contains a Git-relative path,
30
+ * provided the reset walker would reach it. Paths below discovery boundaries
31
+ * such as node_modules or .forge are intentionally not reset candidates.
32
+ */
33
+ export function generatedOutputRootForPath(relativePath) {
34
+ const segments = relativePath.replaceAll("\\", "/").split("/").filter(Boolean);
35
+ for (let index = 0; index < segments.length; index += 1) {
36
+ const segment = segments[index];
37
+ if (skippedDirectoryNames.has(segment))
38
+ return null;
39
+ if (isGeneratedOutputDirectoryName(segment))
40
+ return segments.slice(0, index + 1).join("/");
41
+ }
42
+ return null;
43
+ }
25
44
  /**
26
45
  * Moves only known, untracked test-output directories out of a worktree before
27
46
  * a managed attempt starts. The quarantine lives in the attempt-owned output
28
47
  * directory, so cleanup is recoverable and cannot cross the attempt boundary.
48
+ * A directory with tracked files is committed source, such as recorded test
49
+ * evidence, so it is left untouched and reported instead of being moved.
29
50
  */
30
51
  export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
31
52
  const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
@@ -36,17 +57,21 @@ export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
36
57
  const candidates = await findGeneratedDirectories(worktreeRoot);
37
58
  const quarantineDirectory = path.join(outputRoot, "preflight-reset");
38
59
  const quarantinedPaths = [];
60
+ const retainedPaths = [];
39
61
  for (const candidate of candidates) {
40
62
  const relativePath = path.relative(worktreeRoot, candidate);
41
63
  if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
42
64
  throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
43
65
  }
44
- await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
66
+ if (await containsTrackedFiles(worktreeRoot, relativePath)) {
67
+ retainedPaths.push(relativePath.split(path.sep).join("/"));
68
+ continue;
69
+ }
45
70
  await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
46
71
  const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
47
72
  const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
48
73
  try {
49
- await fs.rename(candidate, destination);
74
+ await quarantineGeneratedDirectory(candidate, destination);
50
75
  }
51
76
  catch (error) {
52
77
  const code = error.code ?? "unknown";
@@ -57,7 +82,38 @@ export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
57
82
  if (quarantinedPaths.length > 0) {
58
83
  await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
59
84
  }
60
- return { quarantineDirectory, quarantinedPaths };
85
+ return { quarantineDirectory, quarantinedPaths, retainedPaths };
86
+ }
87
+ async function quarantineGeneratedDirectory(source, destination) {
88
+ try {
89
+ await fs.rename(source, destination);
90
+ return;
91
+ }
92
+ catch (error) {
93
+ if (error.code !== "EXDEV")
94
+ throw error;
95
+ }
96
+ // Windows installations commonly keep repositories on a data volume while
97
+ // the short per-user attempt root remains on the system volume. rename(2)
98
+ // cannot cross that boundary, so stage a complete copy on the destination
99
+ // volume, publish it there atomically, and only then remove the untracked
100
+ // source directory. A failed copy leaves the source untouched.
101
+ const staging = `${destination}.copying`;
102
+ try {
103
+ await fs.cp(source, staging, {
104
+ recursive: true,
105
+ force: false,
106
+ errorOnExist: true,
107
+ dereference: false,
108
+ verbatimSymlinks: true,
109
+ });
110
+ await fs.rename(staging, destination);
111
+ await fs.rm(source, { recursive: true, force: false });
112
+ }
113
+ catch (error) {
114
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
115
+ throw error;
116
+ }
61
117
  }
62
118
  async function findGeneratedDirectories(root) {
63
119
  const candidates = [];
@@ -71,7 +127,7 @@ async function findGeneratedDirectories(root) {
71
127
  if (skippedDirectoryNames.has(entry.name))
72
128
  continue;
73
129
  const candidate = path.join(current, entry.name);
74
- if (generatedDirectoryNames.has(entry.name)) {
130
+ if (isGeneratedOutputDirectoryName(entry.name)) {
75
131
  const realCandidate = await fs.realpath(candidate);
76
132
  if (!isContainedPath(root, realCandidate)) {
77
133
  throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
@@ -84,7 +140,7 @@ async function findGeneratedDirectories(root) {
84
140
  }
85
141
  return candidates.sort((left, right) => left.localeCompare(right));
86
142
  }
87
- async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
143
+ async function containsTrackedFiles(worktreeRoot, relativePath) {
88
144
  const pathspec = relativePath.split(path.sep).join("/");
89
145
  let stdout;
90
146
  try {
@@ -93,9 +149,7 @@ async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
93
149
  catch (error) {
94
150
  throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
95
151
  }
96
- if (stdout.length > 0) {
97
- throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
98
- }
152
+ return stdout.length > 0;
99
153
  }
100
154
  function isContainedPath(root, candidate) {
101
155
  const relative = path.relative(root, candidate);
@@ -0,0 +1,16 @@
1
+ export declare const hostDatabaseCapabilityEnvironmentNames: readonly ["FORGE_TEST_POSTGRES_ADMIN_CONNECTION_STRING", "FORGE_TEST_POSTGRES_CONNECTION_STRING"];
2
+ export interface HostDatabaseCapabilityBoundaryOptions {
3
+ readonly platform?: NodeJS.Platform;
4
+ readonly processEnvironment?: NodeJS.ProcessEnv;
5
+ readonly inspectWindowsUserEnvironment?: boolean;
6
+ readonly windowsUserCapabilityPresent?: () => boolean;
7
+ }
8
+ /**
9
+ * Fails before untrusted host work can start when a PostgreSQL administrator
10
+ * capability is reachable through either the process or Windows User scope.
11
+ * The default Windows probe returns only presence; it never reads the value
12
+ * into JavaScript or emits it in diagnostics.
13
+ */
14
+ export declare function assertHostDatabaseCapabilityBoundary(options?: HostDatabaseCapabilityBoundaryOptions): void;
15
+ /** Establishes the production Bridge boundary and enables per-launch rechecks. */
16
+ export declare function establishHostDatabaseCapabilityBoundary(): void;
@@ -0,0 +1,62 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ export const hostDatabaseCapabilityEnvironmentNames = [
4
+ "FORGE_TEST_POSTGRES_ADMIN_CONNECTION_STRING",
5
+ "FORGE_TEST_POSTGRES_CONNECTION_STRING",
6
+ ];
7
+ const boundaryAttestationEnvironment = "FORGE_HOST_DATABASE_CAPABILITY_BOUNDARY";
8
+ const boundaryAttestationValue = "user_store_clear";
9
+ /**
10
+ * Fails before untrusted host work can start when a PostgreSQL administrator
11
+ * capability is reachable through either the process or Windows User scope.
12
+ * The default Windows probe returns only presence; it never reads the value
13
+ * into JavaScript or emits it in diagnostics.
14
+ */
15
+ export function assertHostDatabaseCapabilityBoundary(options = {}) {
16
+ const processEnvironment = options.processEnvironment ?? process.env;
17
+ const reserved = new Set(hostDatabaseCapabilityEnvironmentNames.map(name => name.toLowerCase()));
18
+ const processCapabilityPresent = Object.entries(processEnvironment).some(([name, value]) => reserved.has(name.toLowerCase()) && typeof value === "string" && value.trim().length > 0);
19
+ if (processCapabilityPresent) {
20
+ throw boundaryError("process");
21
+ }
22
+ if ((options.platform ?? process.platform) !== "win32")
23
+ return;
24
+ const inspectWindowsUserEnvironment = options.inspectWindowsUserEnvironment
25
+ ?? processEnvironment[boundaryAttestationEnvironment] === boundaryAttestationValue;
26
+ if (!inspectWindowsUserEnvironment)
27
+ return;
28
+ const windowsUserCapabilityPresent = options.windowsUserCapabilityPresent
29
+ ?? inspectWindowsUserCapabilityPresence;
30
+ if (windowsUserCapabilityPresent()) {
31
+ throw boundaryError("Windows User");
32
+ }
33
+ }
34
+ /** Establishes the production Bridge boundary and enables per-launch rechecks. */
35
+ export function establishHostDatabaseCapabilityBoundary() {
36
+ assertHostDatabaseCapabilityBoundary({ inspectWindowsUserEnvironment: true });
37
+ process.env[boundaryAttestationEnvironment] = boundaryAttestationValue;
38
+ }
39
+ function inspectWindowsUserCapabilityPresence() {
40
+ const systemRoot = process.env.SystemRoot?.trim() || process.env.WINDIR?.trim();
41
+ const executable = systemRoot
42
+ ? path.win32.join(systemRoot, "System32", "reg.exe")
43
+ : "reg.exe";
44
+ for (const name of hostDatabaseCapabilityEnvironmentNames) {
45
+ const result = spawnSync(executable, ["query", "HKCU\\Environment", "/v", name], {
46
+ stdio: "ignore",
47
+ timeout: 10_000,
48
+ windowsHide: true,
49
+ });
50
+ if (result.status === 0)
51
+ return true;
52
+ // reg.exe uses exit code 1 for a missing value. Any other result is an
53
+ // unverifiable boundary and must fail closed without exposing registry data.
54
+ if (result.status !== 1 || result.signal || result.error) {
55
+ throw new Error("host_database_capability_boundary_unverified: the Windows User environment could not be inspected safely.", { cause: result.error });
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ function boundaryError(scope) {
61
+ return new Error(`host_database_capability_forbidden: a PostgreSQL administrative capability is present in ${scope} scope; Forge-managed host execution is container-only until an isolated provisioning broker is available.`);
62
+ }
@@ -0,0 +1,11 @@
1
+ import type { PinnedDirectoryIdentity } from "./pinned-directory-process.js";
2
+ export interface HostDiagnosticsRetentionOptions {
3
+ /** Test-only shortening; production callers cannot extend the safety deadline. */
4
+ readonly deadlineMs?: number;
5
+ }
6
+ export declare class HostDiagnosticsBudgetError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Runner-local evidence, never web-served; browser-profile, cache and temporary directories are excluded. */
10
+ export declare function retainHostDiagnostics(stateRoot: string, attemptRoot: string, attemptId: string, expectedAttemptRootIdentity: PinnedDirectoryIdentity | null, attemptOwnerRoot?: string, options?: HostDiagnosticsRetentionOptions): Promise<string>;
11
+ export declare function assertHostAttemptRootIdentity(attemptRoot: string, expected: PinnedDirectoryIdentity): Promise<void>;