@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 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 (219) hide show
  1. package/README.md +165 -1
  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 +9 -1
  5. package/dist/bridge/bridge.service.d.ts +129 -3
  6. package/dist/bridge/bridge.service.js +2426 -70
  7. package/dist/bridge/bridge.types.d.ts +218 -2
  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 +14 -2
  21. package/dist/cleanup/cleanup.service.js +496 -3
  22. package/dist/cleanup/cleanup.types.d.ts +10 -0
  23. package/dist/cleanup/index.d.ts +1 -1
  24. package/dist/cleanup/index.js +1 -1
  25. package/dist/cleanup/internals.d.ts +2 -2
  26. package/dist/cleanup/internals.js +1 -1
  27. package/dist/cli/cli.help.d.ts +2 -0
  28. package/dist/cli/cli.help.js +217 -0
  29. package/dist/cli/cli.service.d.ts +55 -7
  30. package/dist/cli/cli.service.js +429 -219
  31. package/dist/cli/local-config.d.ts +7 -0
  32. package/dist/cli/local-config.js +51 -3
  33. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  34. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  35. package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
  36. package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
  37. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  38. package/dist/runtime/attempt-path-operation.js +94 -0
  39. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  40. package/dist/runtime/attempt-private-directory.js +54 -0
  41. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  42. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  43. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  44. package/dist/runtime/attempt-process-supervisor.js +397 -0
  45. package/dist/runtime/canonical-source-file.d.ts +3 -0
  46. package/dist/runtime/canonical-source-file.js +54 -0
  47. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  48. package/dist/runtime/docker-engine-probe.js +333 -0
  49. package/dist/runtime/generated-output-reset.d.ts +20 -0
  50. package/dist/runtime/generated-output-reset.js +157 -0
  51. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  52. package/dist/runtime/host-database-capability-boundary.js +62 -0
  53. package/dist/runtime/host-diagnostics.d.ts +11 -0
  54. package/dist/runtime/host-diagnostics.js +191 -0
  55. package/dist/runtime/host-execution-root.d.ts +4 -0
  56. package/dist/runtime/host-execution-root.js +39 -0
  57. package/dist/runtime/host-listener-inspector.d.ts +51 -0
  58. package/dist/runtime/host-listener-inspector.js +385 -0
  59. package/dist/runtime/host-port-broker.d.ts +76 -0
  60. package/dist/runtime/host-port-broker.js +333 -0
  61. package/dist/runtime/host-runtime-contract.d.ts +58 -0
  62. package/dist/runtime/host-runtime-contract.js +754 -0
  63. package/dist/runtime/host-runtime-lease.d.ts +215 -0
  64. package/dist/runtime/host-runtime-lease.js +2449 -0
  65. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  66. package/dist/runtime/host-runtime-promotion.js +206 -0
  67. package/dist/runtime/memory-capacity.d.ts +70 -0
  68. package/dist/runtime/memory-capacity.js +123 -0
  69. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  70. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  71. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  72. package/dist/runtime/node-host-runtime-adapter.js +285 -0
  73. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  74. package/dist/runtime/pinned-directory-process.js +61 -0
  75. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  76. package/dist/runtime/pinned-source-directory.js +94 -0
  77. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  78. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  79. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  80. package/dist/runtime/prepared-runtime-identity.js +218 -0
  81. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  82. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  83. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  84. package/dist/runtime/runtime-isolation-lease.js +161 -0
  85. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  86. package/dist/runtime/workspace-resource-estimator.js +174 -0
  87. package/dist/sandbox/command-runner.d.ts +4 -0
  88. package/dist/sandbox/command-runner.js +47 -0
  89. package/dist/sandbox/contract.d.ts +5 -0
  90. package/dist/sandbox/contract.js +276 -0
  91. package/dist/sandbox/event-budget.d.ts +11 -0
  92. package/dist/sandbox/event-budget.js +27 -0
  93. package/dist/sandbox/image-policy.d.ts +35 -0
  94. package/dist/sandbox/image-policy.js +247 -0
  95. package/dist/sandbox/index.d.ts +5 -0
  96. package/dist/sandbox/index.js +5 -0
  97. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  98. package/dist/sandbox/lease-watchdog.js +67 -0
  99. package/dist/sandbox/preflight.d.ts +84 -0
  100. package/dist/sandbox/preflight.js +521 -0
  101. package/dist/sandbox/process.d.ts +20 -0
  102. package/dist/sandbox/process.js +209 -0
  103. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  104. package/dist/sandbox/provider-auth/codex.js +212 -0
  105. package/dist/sandbox/repository-lock.d.ts +6 -0
  106. package/dist/sandbox/repository-lock.js +128 -0
  107. package/dist/sandbox/runtime.d.ts +302 -0
  108. package/dist/sandbox/runtime.js +2367 -0
  109. package/dist/sandbox/sandbox.types.d.ts +93 -0
  110. package/dist/sandbox/sandbox.types.js +1 -0
  111. package/dist/sandbox/smoke.d.ts +76 -0
  112. package/dist/sandbox/smoke.js +380 -0
  113. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  114. package/dist/sandbox/worker-supervisor.js +36 -0
  115. package/dist/sandbox/worker.d.ts +1 -0
  116. package/dist/sandbox/worker.js +225 -0
  117. package/dist/sandbox/workspace.service.d.ts +13 -0
  118. package/dist/sandbox/workspace.service.js +74 -0
  119. package/dist/session/managed-attempt-tools.d.ts +28 -0
  120. package/dist/session/managed-attempt-tools.js +1011 -0
  121. package/dist/session/managed-build-coordinator.d.ts +5 -0
  122. package/dist/session/managed-build-coordinator.js +105 -0
  123. package/dist/session/managed-build-receipt.d.ts +21 -0
  124. package/dist/session/managed-build-receipt.js +337 -0
  125. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  126. package/dist/session/managed-verification-timeouts.js +17 -0
  127. package/dist/session/phase-completion.artifacts.d.ts +27 -0
  128. package/dist/session/phase-completion.artifacts.js +829 -0
  129. package/dist/session/phase-completion.d.ts +53 -42
  130. package/dist/session/phase-completion.helpers.d.ts +117 -0
  131. package/dist/session/phase-completion.helpers.js +707 -0
  132. package/dist/session/phase-completion.js +125 -3007
  133. package/dist/session/phase-completion.prompts.d.ts +14 -0
  134. package/dist/session/phase-completion.prompts.js +233 -0
  135. package/dist/session/phase-completion.runtime.d.ts +49 -0
  136. package/dist/session/phase-completion.runtime.js +833 -0
  137. package/dist/session/phase-completion.schemas.d.ts +3 -0
  138. package/dist/session/phase-completion.schemas.js +859 -0
  139. package/dist/session/phase-completion.validation.d.ts +38 -0
  140. package/dist/session/phase-completion.validation.js +918 -0
  141. package/dist/session/pre-publication-validation.d.ts +75 -0
  142. package/dist/session/pre-publication-validation.js +657 -0
  143. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  144. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  145. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  146. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  147. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
  148. package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
  149. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  150. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  151. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  152. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  153. package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
  154. package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
  155. package/dist/session/provider-cli/provider-cli.process.js +874 -0
  156. package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
  157. package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
  158. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  159. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  160. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  161. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  162. package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
  163. package/dist/session/provider-cli/provider-cli.runtime.js +78 -0
  164. package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
  165. package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
  166. package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
  167. package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
  168. package/dist/session/provider-cli/provider-output-state.js +817 -0
  169. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  170. package/dist/session/provider-cli/provider-usage.js +28 -5
  171. package/dist/session/session-redaction.d.ts +3 -0
  172. package/dist/session/session-redaction.js +448 -0
  173. package/dist/session/session.base.d.ts +6 -1
  174. package/dist/session/session.service.d.ts +8 -2
  175. package/dist/session/session.service.js +586 -59
  176. package/dist/session/session.types.d.ts +324 -3
  177. package/dist/session/session.types.js +939 -10
  178. package/dist/session/unreferenced-exports.d.ts +12 -0
  179. package/dist/session/unreferenced-exports.js +59 -0
  180. package/dist/shared/format/format-bytes.d.ts +1 -0
  181. package/dist/shared/format/format-bytes.js +9 -0
  182. package/dist/shared/git/git.js +2 -1
  183. package/dist/shared/path/path.d.ts +1 -0
  184. package/dist/shared/path/path.js +21 -1
  185. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  186. package/dist/shared/process/dotnet-toolchain.js +307 -0
  187. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  188. package/dist/shared/process/host-process-recovery.js +78 -0
  189. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  190. package/dist/shared/process/host-process-supervisor.js +330 -0
  191. package/dist/shared/process/process.d.ts +3 -0
  192. package/dist/shared/process/process.js +13 -7
  193. package/dist/shared/process/windows-launch.d.ts +8 -0
  194. package/dist/shared/process/windows-launch.js +41 -0
  195. package/dist/shared/provider-capabilities.d.ts +4 -0
  196. package/dist/shared/provider-capabilities.js +88 -0
  197. package/dist/shared/redaction/redaction.d.ts +1 -0
  198. package/dist/shared/redaction/redaction.js +47 -4
  199. package/dist/version.d.ts +1 -1
  200. package/dist/version.js +1 -1
  201. package/dist/worktree/artifact-injector.d.ts +3 -2
  202. package/dist/worktree/artifact-injector.js +82 -10
  203. package/dist/worktree/dependency-warmer.d.ts +53 -1
  204. package/dist/worktree/dependency-warmer.js +619 -11
  205. package/dist/worktree/index.d.ts +2 -1
  206. package/dist/worktree/index.js +1 -0
  207. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  208. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  209. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  210. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  211. package/dist/worktree/worktree-paths.d.ts +4 -0
  212. package/dist/worktree/worktree-paths.js +30 -0
  213. package/dist/worktree/worktree.base.d.ts +3 -0
  214. package/dist/worktree/worktree.service.d.ts +16 -3
  215. package/dist/worktree/worktree.service.js +334 -54
  216. package/dist/worktree/worktree.types.d.ts +9 -1
  217. package/package.json +12 -7
  218. package/runtime/windows-job-supervisor.ps1 +722 -0
  219. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -5,23 +5,37 @@ import path from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { promisify } from "node:util";
7
7
  import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
8
- import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
8
+ import { buildWindowsCmdLaunch } from "../shared/process/windows-launch.js";
9
+ import { configureDotNetToolchain } from "../shared/process/dotnet-toolchain.js";
10
+ import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
11
+ import { redactText } from "../shared/redaction/redaction.js";
9
12
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
10
13
  import { forgeBridgeVersion } from "../version.js";
11
- import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
14
+ import { ManagedWorktreeCleanupService } from "../cleanup/cleanup.service.js";
15
+ import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, setSandboxPolicy, setServerUrl, writeLocalConfig } from "./local-config.js";
12
16
  import { BridgeSessionService } from "../session/session.service.js";
17
+ import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
18
+ import { validateDockerfilePolicy, validateSandboxContract } from "../sandbox/contract.js";
19
+ import { DockerPreflight } from "../sandbox/preflight.js";
20
+ import { DockerSandboxRuntime } from "../sandbox/runtime.js";
21
+ import { DockerSandboxSmoke } from "../sandbox/smoke.js";
13
22
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
14
- import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
23
+ import { failureFactsFromError, failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
24
+ import { commandHelpText, helpText } from "./cli.help.js";
15
25
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
16
26
  const execFileAsync = promisify(execFile);
17
27
  export class ForgeBridgeCliService {
18
28
  sessions;
19
29
  bridgeFactory;
20
30
  pairRunner;
21
- constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
31
+ cleanupFactory;
32
+ dotNetToolchainConfigurator;
33
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner, cleanupFactory = stateRoot => new ManagedWorktreeCleanupService(stateRoot), dotNetToolchainConfigurator = configureDotNetToolchain) {
22
34
  this.sessions = sessions;
23
35
  this.bridgeFactory = bridgeFactory;
24
36
  this.pairRunner = pairRunner;
37
+ this.cleanupFactory = cleanupFactory;
38
+ this.dotNetToolchainConfigurator = dotNetToolchainConfigurator;
25
39
  }
26
40
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
41
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
@@ -56,11 +70,26 @@ export class ForgeBridgeCliService {
56
70
  io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
57
71
  return 2;
58
72
  }
73
+ let dotNetToolchain;
74
+ try {
75
+ dotNetToolchain = await this.dotNetToolchainConfigurator(await dotNetPreflightRoots(config, args.implicitCloneDestination), { cloneFolders: [config.defaultRepoRoot.trim()].filter(Boolean) });
76
+ }
77
+ catch (error) {
78
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
79
+ return 1;
80
+ }
81
+ if (dotNetToolchain) {
82
+ io.stdout.write(`Using .NET SDK ${dotNetToolchain.sdkVersion}: ${dotNetToolchain.executablePath}\n`);
83
+ }
59
84
  const controller = new AbortController();
60
85
  const stop = () => controller.abort("signal received");
61
86
  process.once("SIGINT", stop);
62
87
  process.once("SIGTERM", stop);
63
88
  try {
89
+ if (config.cleanupWorktrees && config.repoMappings.some(mapping => mapping.localPath?.trim())) {
90
+ io.stdout.write("Cleaning stale managed worktrees before accepting dispatches.\n");
91
+ await cleanupRepos(config, "stale", true, this.cleanupFactory(config.sandboxStateRoot), io);
92
+ }
64
93
  io.stdout.write(connectStartupText(config));
65
94
  await this.bridgeFactory(config).run(controller.signal);
66
95
  return 0;
@@ -86,6 +115,7 @@ export class ForgeBridgeCliService {
86
115
  if (args.command === "pair") {
87
116
  try {
88
117
  const current = readLocalConfigSync();
118
+ io.stdout.write(`Pairing with server: ${args.serverUrl}\n`);
89
119
  const code = await resolvePairingCode(args.code);
90
120
  const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
91
121
  await fs.mkdir(repoRoot, { recursive: true });
@@ -115,12 +145,66 @@ export class ForgeBridgeCliService {
115
145
  return 2;
116
146
  }
117
147
  }
148
+ if (args.command === "server-show") {
149
+ const saved = readLocalConfigSync().serverUrl ?? "not configured";
150
+ io.stdout.write(`Saved server: ${saved}\n`);
151
+ if (process.env.FORGE_URL?.trim()) {
152
+ io.stdout.write(`Effective server: ${process.env.FORGE_URL.trim()} (FORGE_URL override)\n`);
153
+ }
154
+ else {
155
+ io.stdout.write(`Effective server: ${args.config.serverUrl || "not configured"}\n`);
156
+ }
157
+ return 0;
158
+ }
159
+ if (args.command === "server-set") {
160
+ try {
161
+ const config = await setServerUrl(args.serverUrl);
162
+ io.stdout.write(`Saved Forge server: ${config.serverUrl}\n`);
163
+ io.stdout.write("Existing runner credentials were retained. Re-run `forge-bridge pair --server <url>` if this is a different Forge deployment.\n");
164
+ if (process.env.FORGE_URL?.trim()) {
165
+ io.stdout.write(`Warning: FORGE_URL is set to ${process.env.FORGE_URL.trim()} and overrides the saved server.\n`);
166
+ }
167
+ return 0;
168
+ }
169
+ catch (error) {
170
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
171
+ return 2;
172
+ }
173
+ }
118
174
  if (args.command === "status") {
119
175
  await writeStatus(args.config, args.checkUpdates, io);
120
176
  return 0;
121
177
  }
122
178
  if (args.command === "cleanup") {
123
- return await cleanupRepos(args.config, args.mode, args.force, io);
179
+ return await cleanupRepos(args.config, args.mode, args.force, this.cleanupFactory(args.config.sandboxStateRoot), io);
180
+ }
181
+ if (args.command === "sandbox-init") {
182
+ return await initializeSandbox(args.repoPath, io);
183
+ }
184
+ if (args.command === "sandbox-doctor") {
185
+ return await diagnoseSandbox(args.repoPath, args.requirePinnedBase, io);
186
+ }
187
+ if (args.command === "sandbox-smoke") {
188
+ return await smokeSandbox(args.repoPath, args.reportPath, args.requirePinnedBase, args.probeLoopbackPort, io);
189
+ }
190
+ if (args.command === "sandbox-reap") {
191
+ return await reapSandboxes(io);
192
+ }
193
+ if (args.command === "sandbox-on") {
194
+ const saved = await setSandboxPolicy(true, args.allowOpenPublic);
195
+ io.stdout.write("Saved local Docker sandbox policy. Restart the bridge to apply it.\n");
196
+ writeSandboxPolicyStatus(saved, io);
197
+ return 0;
198
+ }
199
+ if (args.command === "sandbox-off") {
200
+ const saved = await setSandboxPolicy(false, false);
201
+ io.stdout.write("Disabled local Docker sandbox placement. Restart the bridge to apply it.\n");
202
+ writeSandboxPolicyStatus(saved, io);
203
+ return 0;
204
+ }
205
+ if (args.command === "sandbox-status") {
206
+ writeSandboxPolicyStatus(readLocalConfigSync(), io);
207
+ return 0;
124
208
  }
125
209
  if (args.command === "mcp") {
126
210
  const parsed = parseCompletionMcpArgs(args.argv);
@@ -140,17 +224,17 @@ export class ForgeBridgeCliService {
140
224
  emit: async (event) => {
141
225
  const line = {
142
226
  type: "runner_event",
143
- event: redactSecrets(event, redactionSecrets)
227
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
144
228
  };
145
229
  io.stdout.write(`${JSON.stringify(line)}\n`);
146
230
  }
147
231
  });
148
- await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
232
+ await writeJsonAtomic(args.resultPath, redactRunOnceResultPacket(result, redactionSecrets));
149
233
  return result.exitCode;
150
234
  }
151
235
  catch (error) {
152
236
  const summary = redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
153
- await writeJsonAtomic(args.resultPath, failureResult(summary)).catch(() => undefined);
237
+ await writeJsonAtomic(args.resultPath, redactRunOnceResultPacket(failureResult(summary, [], failureFactsFromError(error, { source: "bridge" })), redactionSecrets)).catch(() => undefined);
154
238
  io.stderr.write(`${summary}\n`);
155
239
  return 1;
156
240
  }
@@ -231,6 +315,15 @@ export function parseArgs(argv) {
231
315
  }
232
316
  return { ok: true, command: "remove-repo", label: argv[1] };
233
317
  }
318
+ if (argv[0] === "server") {
319
+ if (argv.length === 1 || (argv[1] === "show" && argv.length === 2)) {
320
+ return { ok: true, command: "server-show", config: configuredBridgeServiceConfig() };
321
+ }
322
+ if (argv[1] === "set" && argv[2] && argv.length === 3) {
323
+ return { ok: true, command: "server-set", serverUrl: argv[2] };
324
+ }
325
+ return { ok: false, error: "Usage: forge-bridge server <show|set <url>>" };
326
+ }
234
327
  if (argv[0] === "status") {
235
328
  let checkUpdates = false;
236
329
  for (let index = 1; index < argv.length; index += 1) {
@@ -250,10 +343,13 @@ export function parseArgs(argv) {
250
343
  if (argv[0] === "mcp") {
251
344
  return { ok: true, command: "mcp", argv };
252
345
  }
346
+ if (argv[0] === "sandbox") {
347
+ return parseSandboxArgs(argv);
348
+ }
253
349
  if (argv[0] !== "run-once") {
254
350
  return {
255
351
  ok: false,
256
- error: usage()
352
+ error: helpText()
257
353
  };
258
354
  }
259
355
  let dispatchPath = null;
@@ -280,6 +376,252 @@ export function parseArgs(argv) {
280
376
  resultPath: path.resolve(resultPath)
281
377
  };
282
378
  }
379
+ function parseSandboxArgs(argv) {
380
+ const action = argv[1];
381
+ if (action === "on") {
382
+ let allowOpenPublic = false;
383
+ for (let index = 2; index < argv.length; index += 1) {
384
+ if (argv[index] === "--allow-open-public") {
385
+ allowOpenPublic = true;
386
+ }
387
+ else {
388
+ return { ok: false, error: `Unknown sandbox argument: ${argv[index]}` };
389
+ }
390
+ }
391
+ return { ok: true, command: "sandbox-on", allowOpenPublic };
392
+ }
393
+ if (action === "off" && argv.length === 2)
394
+ return { ok: true, command: "sandbox-off" };
395
+ if (action === "status" && argv.length === 2)
396
+ return { ok: true, command: "sandbox-status" };
397
+ let repoPath = process.cwd();
398
+ let reportPath;
399
+ let requirePinnedBase = false;
400
+ let probeLoopbackPort = false;
401
+ for (let index = 2; index < argv.length; index += 1) {
402
+ const arg = argv[index];
403
+ if (arg === "--repo") {
404
+ repoPath = argv[++index] ?? "";
405
+ if (!repoPath)
406
+ return { ok: false, error: "--repo requires a path." };
407
+ }
408
+ else if (arg === "--report" && action === "smoke") {
409
+ reportPath = argv[++index];
410
+ if (!reportPath)
411
+ return { ok: false, error: "--report requires a path." };
412
+ }
413
+ else if (arg === "--require-pinned-base" && (action === "doctor" || action === "smoke")) {
414
+ requirePinnedBase = true;
415
+ }
416
+ else if (arg === "--probe-loopback-port" && action === "smoke") {
417
+ probeLoopbackPort = true;
418
+ }
419
+ else {
420
+ return { ok: false, error: `Unknown sandbox argument: ${arg}` };
421
+ }
422
+ }
423
+ if (action === "init")
424
+ return { ok: true, command: "sandbox-init", repoPath: path.resolve(repoPath) };
425
+ if (action === "doctor")
426
+ return { ok: true, command: "sandbox-doctor", repoPath: path.resolve(repoPath), requirePinnedBase };
427
+ if (action === "smoke")
428
+ return {
429
+ ok: true,
430
+ command: "sandbox-smoke",
431
+ repoPath: path.resolve(repoPath),
432
+ reportPath: reportPath ? path.resolve(reportPath) : undefined,
433
+ requirePinnedBase,
434
+ probeLoopbackPort,
435
+ };
436
+ if (action === "reap" && argv.length === 2)
437
+ return { ok: true, command: "sandbox-reap" };
438
+ return { ok: false, error: "Usage: forge-bridge sandbox <on|off|status|init|doctor|smoke|reap> [--allow-open-public] [--repo <path>] [--report <path>] [--require-pinned-base] [--probe-loopback-port]" };
439
+ }
440
+ const sandboxContractTemplate = `schemaVersion: 1
441
+ workingDirectory: /workspace
442
+ commands:
443
+ install: npm ci
444
+ test: npm test
445
+ caches:
446
+ - name: npm
447
+ mode: readonly
448
+ services: []
449
+ environment: []
450
+ artifacts: []
451
+ logs: []
452
+ browser: false
453
+ `;
454
+ const sandboxDockerfileTemplate = `# Pin this base by digest before enabling sandbox_required.
455
+ FROM node:22.17.0-bookworm-slim
456
+ WORKDIR /workspace
457
+ `;
458
+ async function initializeSandbox(repoPath, io) {
459
+ const forgePath = path.join(repoPath, ".forge");
460
+ const files = [
461
+ ["forge.sandbox.yml", sandboxContractTemplate],
462
+ ["Dockerfile", sandboxDockerfileTemplate],
463
+ ["forge.env.example", "# List non-secret variable names in forge.sandbox.yml. Never commit secret values.\n"]
464
+ ];
465
+ await fs.mkdir(forgePath, { recursive: true });
466
+ let created = 0;
467
+ for (const [name, contents] of files) {
468
+ const target = path.join(forgePath, name);
469
+ try {
470
+ const handle = await fs.open(target, "wx", 0o600);
471
+ await handle.writeFile(contents);
472
+ await handle.close();
473
+ io.stdout.write(`Created ${path.relative(repoPath, target)}\n`);
474
+ created += 1;
475
+ }
476
+ catch (error) {
477
+ if (error.code !== "EEXIST")
478
+ throw error;
479
+ io.stdout.write(`Kept existing ${path.relative(repoPath, target)}\n`);
480
+ }
481
+ }
482
+ io.stdout.write(created > 0 ? "Sandbox scaffold is ready. Run `forge-bridge sandbox doctor`.\n" : "Sandbox scaffold already exists.\n");
483
+ return 0;
484
+ }
485
+ async function diagnoseSandbox(repoPath, requirePinnedBase, io) {
486
+ try {
487
+ const forgePath = path.join(repoPath, ".forge");
488
+ const contractSource = await fs.readFile(path.join(forgePath, "forge.sandbox.yml"), "utf8");
489
+ const dockerfile = await fs.readFile(path.join(forgePath, "Dockerfile"), "utf8");
490
+ const contract = validateSandboxContract(contractSource);
491
+ const warnings = validateDockerfilePolicy(dockerfile, requirePinnedBase);
492
+ io.stdout.write(`Contract: valid (${contract.digest})\n`);
493
+ for (const warning of warnings)
494
+ io.stdout.write(`Warning: ${warning}\n`);
495
+ const config = defaultBridgeServiceConfig();
496
+ const preflight = new DockerPreflight({
497
+ dockerExecutable: config.dockerExecutable,
498
+ dockerContext: config.dockerContext,
499
+ stateRoot: config.sandboxStateRoot,
500
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
501
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
502
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
503
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
504
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
505
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
506
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
507
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
508
+ requireRootless: config.requireRootlessDocker
509
+ });
510
+ try {
511
+ const result = await preflight.refresh();
512
+ if (!result.healthy) {
513
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
514
+ return 1;
515
+ }
516
+ io.stdout.write(`Docker: healthy (${result.runtimeVersion}; capacity ${result.maxConcurrentSandboxes})\n`);
517
+ return 0;
518
+ }
519
+ finally {
520
+ await preflight.release();
521
+ }
522
+ }
523
+ catch (error) {
524
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
525
+ return 1;
526
+ }
527
+ }
528
+ async function smokeSandbox(repoPath, reportPath, requirePinnedBase, probeLoopbackPort, io) {
529
+ try {
530
+ const result = await new DockerSandboxSmoke(defaultBridgeServiceConfig()).run({
531
+ repoPath,
532
+ reportPath,
533
+ requirePinnedBase,
534
+ probeLoopbackPort,
535
+ onProgress: (stage) => io.stdout.write(`Smoke: ${stage}...\n`),
536
+ });
537
+ const { report } = result;
538
+ if (report.repository.contractDigest)
539
+ io.stdout.write(`Contract: valid (${report.repository.contractDigest})\n`);
540
+ for (const warning of report.repository.policyWarnings)
541
+ io.stdout.write(`Warning: ${warning}\n`);
542
+ if (report.docker.runtimeVersion) {
543
+ io.stdout.write(`Docker: healthy (${report.docker.runtimeVersion}; capacity ${report.docker.capacity})\n`);
544
+ }
545
+ io.stdout.write(`Attempt: ${report.dispatchAttemptId}\n`);
546
+ io.stdout.write(`Resource lease: ${report.resourceLeaseId}\n`);
547
+ if (report.publishedPortProbe) {
548
+ io.stdout.write(`Loopback port: ${report.publishedPortProbe.hostIp}:${report.publishedPortProbe.hostPort} -> ${report.publishedPortProbe.containerPort}/tcp\n`);
549
+ }
550
+ io.stdout.write(`Cleanup: ${report.cleanup.verified ? "verified (zero exact-label resources)" : "failed"}\n`);
551
+ io.stdout.write(`Report: ${result.reportPath}\n`);
552
+ if (report.status === "passed") {
553
+ io.stdout.write("Sandbox smoke: PASS\n");
554
+ return 0;
555
+ }
556
+ io.stderr.write(`Sandbox smoke: FAIL (${report.failure?.code ?? "sandbox_smoke_failed"} at ${report.failure?.stage ?? "unknown"}): ${report.failure?.message ?? "unknown failure"}\n`);
557
+ return 1;
558
+ }
559
+ catch (error) {
560
+ io.stderr.write(`Sandbox smoke could not write its report: ${error instanceof Error ? error.message : String(error)}\n`);
561
+ return 1;
562
+ }
563
+ }
564
+ async function reapSandboxes(io) {
565
+ const config = defaultBridgeServiceConfig();
566
+ if (!/^[0-9a-f-]{32,36}$/iu.test(config.runnerId)) {
567
+ io.stderr.write("FORGE_RUNNER_ID must identify the runner whose exact-labeled resources may be reaped.\n");
568
+ return 2;
569
+ }
570
+ const preflight = new DockerPreflight({
571
+ dockerExecutable: config.dockerExecutable,
572
+ dockerContext: config.dockerContext,
573
+ stateRoot: config.sandboxStateRoot,
574
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
575
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
576
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
577
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
578
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
579
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
580
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
581
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
582
+ requireRootless: config.requireRootlessDocker
583
+ });
584
+ try {
585
+ const result = await preflight.refresh();
586
+ if (!result.healthy && result.unavailableCode !== "docker_disk_low") {
587
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
588
+ return 1;
589
+ }
590
+ const runtime = new DockerSandboxRuntime(config);
591
+ await runtime.reap({ diskLow: result.diskLow });
592
+ io.stdout.write(`Sandbox reaper completed for runner ${config.runnerId}.\n`);
593
+ return 0;
594
+ }
595
+ catch (error) {
596
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
597
+ return 1;
598
+ }
599
+ finally {
600
+ await preflight.release();
601
+ }
602
+ }
603
+ async function dotNetPreflightRoots(config, implicitCloneDestination) {
604
+ const roots = config.repoMappings.map((mapping) => mapping.localPath?.trim())
605
+ .filter((repoRoot) => Boolean(repoRoot));
606
+ const defaultRoot = config.defaultRepoRoot.trim();
607
+ if (!defaultRoot)
608
+ return roots;
609
+ if (implicitCloneDestination) {
610
+ try {
611
+ // The built-in clone destination need not exist on first connect. lstat
612
+ // deliberately retains dangling links for the strict SDK scanner to reject.
613
+ // Explicit mappings (including this same path) remain required inputs.
614
+ await fs.lstat(defaultRoot);
615
+ }
616
+ catch (error) {
617
+ if (error.code === "ENOENT")
618
+ return roots;
619
+ throw error;
620
+ }
621
+ }
622
+ roots.push(defaultRoot);
623
+ return roots;
624
+ }
283
625
  function parseConnectArgs(argv) {
284
626
  let serverUrl = null;
285
627
  let runnerId = null;
@@ -349,7 +691,10 @@ function parseConnectArgs(argv) {
349
691
  return {
350
692
  ok: true,
351
693
  command: "connect",
352
- config: configuredBridgeServiceConfig(overrides, localConfig)
694
+ config: configuredBridgeServiceConfig(overrides, localConfig),
695
+ implicitCloneDestination: repoRoot === null
696
+ && process.env.FORGE_REPO_ROOT === undefined
697
+ && localConfig.defaultRepoRoot === undefined,
353
698
  };
354
699
  }
355
700
  function parsePairArgs(argv) {
@@ -412,7 +757,7 @@ function parsePairArgs(argv) {
412
757
  name: name?.trim() || defaultName,
413
758
  fingerprint: fingerprint?.trim() || `local-bridge:${os.hostname()}:${user}`,
414
759
  repoRoot: repoRoot?.trim() ? path.resolve(repoRoot) : undefined,
415
- cleanupWorktrees: cleanupWorktrees ?? localConfig.cleanupWorktrees ?? false,
760
+ cleanupWorktrees: cleanupWorktrees ?? defaultConfig.cleanupWorktrees,
416
761
  cliProviders: cliProviders.length > 0 ? cliProviders : localConfig.cliProviders && localConfig.cliProviders.length > 0 ? [...localConfig.cliProviders] : ["codex"],
417
762
  projectLabels: projectLabels.length > 0 ? projectLabels : localConfig.projectLabels ? [...localConfig.projectLabels] : []
418
763
  };
@@ -468,7 +813,7 @@ async function claimBridgeRunner(args) {
468
813
  })
469
814
  });
470
815
  if (!response.ok) {
471
- throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
816
+ throw new Error(formatPairingFailure(response.status, args.serverUrl, await response.text()));
472
817
  }
473
818
  const value = await response.json();
474
819
  if (!value.runnerId || !value.deviceToken) {
@@ -497,6 +842,8 @@ function pairedConfig(current, args, claim) {
497
842
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
498
843
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
499
844
  supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
845
+ supportsDraftAttachments: current.supportsDraftAttachments ?? true,
846
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(args.cliProviders),
500
847
  repoMappings: current.repoMappings ?? []
501
848
  };
502
849
  }
@@ -541,181 +888,15 @@ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
541
888
  function normalizePairingCode(code) {
542
889
  return code?.replace(/[\s-]/g, "").trim() ?? "";
543
890
  }
891
+ function formatPairingFailure(status, serverUrl, body) {
892
+ const hint = body.includes("runner.invalid_pairing_code")
893
+ ? " Make sure the code was generated by the same Forge server shown above."
894
+ : "";
895
+ return `Pairing failed (${status}) against ${serverUrl}: ${body}${hint}`;
896
+ }
544
897
  function normalizeServerUrl(serverUrl) {
545
898
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
546
899
  }
547
- function usage() {
548
- return helpText();
549
- }
550
- function helpText() {
551
- const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
552
- return [
553
- `🛠 Forge Bridge ${version} — Local provider runners for Forge.`,
554
- "",
555
- "Usage: forge-bridge [options] [command]",
556
- "",
557
- "Options:",
558
- " -h, --help Display help for command",
559
- " -v, --version Output the version number",
560
- "",
561
- "Commands:",
562
- " Hint: commands suffixed with * have detailed help. Run <command> --help for details.",
563
- " connect * Start the runner bridge and wait for Forge dispatches",
564
- " pair * Pair this machine with Forge and save runner credentials",
565
- " status * Show config, runner identity, repos, provider CLIs, and updates",
566
- " add-repo * Map a Forge repo label/name to a local checkout path",
567
- " remove-repo * Remove a saved repo mapping",
568
- " cleanup * Prune managed bridge worktrees",
569
- " version * Print the installed bridge package version",
570
- " help * Display help for command",
571
- " run-once * Internal: run one dispatch packet from JSON",
572
- " mcp * Internal: run the phase-completion MCP server",
573
- "",
574
- "Examples:",
575
- " npm install -g @loucompanion/forge-bridge@next",
576
- " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
577
- " forge-bridge connect",
578
- " forge-bridge status --check-updates",
579
- " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
580
- " forge-bridge cleanup --stale",
581
- "",
582
- "Troubleshooting:",
583
- " If Forge says the bridge is old: npm install -g @loucompanion/forge-bridge@next",
584
- " If connect shows 502/500: leave it open; it reconnects with backoff while dev restarts.",
585
- " If a provider is missing: run forge-bridge status --check-updates and verify CLI login.",
586
- "",
587
- "Docs:",
588
- " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
589
- "",
590
- ].join("\n");
591
- }
592
- function commandHelpText(command) {
593
- switch (command) {
594
- case "pair":
595
- return [
596
- "Forge Bridge — pair",
597
- "Pair this machine with Forge and save runner credentials.",
598
- "",
599
- "Usage:",
600
- " forge-bridge pair [code] [options]",
601
- "",
602
- "Options:",
603
- " --server <url> Forge server URL.",
604
- " --repo-root <path> Folder where bridge-managed repos/worktrees live.",
605
- " --name <name> Display name for this runner.",
606
- " --fingerprint <value> Stable device fingerprint override.",
607
- " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
608
- " --cleanup-worktrees Enable managed worktree cleanup.",
609
- " --no-cleanup-worktrees Disable managed worktree cleanup.",
610
- "",
611
- "Example:",
612
- " forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
613
- "",
614
- ].join("\n");
615
- case "connect":
616
- return [
617
- "Forge Bridge — connect",
618
- "Start the runner bridge and keep it connected for Forge dispatches.",
619
- "",
620
- "Usage:",
621
- " forge-bridge connect [options]",
622
- "",
623
- "Options:",
624
- " --server <url> Forge server URL; saved config is used when omitted.",
625
- " --runner-id <id> Runner id; saved config is used when omitted.",
626
- " --device-token <token> Device token; saved config is used when omitted.",
627
- " --repo-root <path> Override repo/worktree folder for this run.",
628
- " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
629
- " --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
630
- " --cleanup-worktrees Enable managed worktree cleanup for this run.",
631
- " --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
632
- "",
633
- "Example:",
634
- " forge-bridge connect",
635
- "",
636
- ].join("\n");
637
- case "status":
638
- return [
639
- "Forge Bridge — status",
640
- "Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
641
- "",
642
- "Usage:",
643
- " forge-bridge status [--check-updates]",
644
- "",
645
- "Example:",
646
- " forge-bridge status --check-updates",
647
- "",
648
- ].join("\n");
649
- case "add-repo":
650
- return [
651
- "Forge Bridge — add-repo",
652
- "Map a Forge repo label/name to an existing local checkout.",
653
- "",
654
- "Usage:",
655
- " forge-bridge add-repo <label> <path>",
656
- "",
657
- "Example:",
658
- " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
659
- "",
660
- ].join("\n");
661
- case "remove-repo":
662
- return [
663
- "Forge Bridge — remove-repo",
664
- "Remove a saved local repo mapping.",
665
- "",
666
- "Usage:",
667
- " forge-bridge remove-repo <label>",
668
- "",
669
- ].join("\n");
670
- case "cleanup":
671
- return [
672
- "Forge Bridge — cleanup",
673
- "Prune managed bridge worktrees under the configured repo folder.",
674
- "",
675
- "Usage:",
676
- " forge-bridge cleanup [--stale|--merged|--all] [--force]",
677
- "",
678
- "Examples:",
679
- " forge-bridge cleanup --stale",
680
- " forge-bridge cleanup --all --force",
681
- "",
682
- ].join("\n");
683
- case "run-once":
684
- return [
685
- "Forge Bridge — run-once",
686
- "Internal command used by Forge to run a single dispatch packet.",
687
- "",
688
- "Usage:",
689
- " forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
690
- "",
691
- ].join("\n");
692
- case "mcp":
693
- return [
694
- "Forge Bridge — mcp",
695
- "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
696
- "",
697
- "Usage:",
698
- " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
699
- "",
700
- ].join("\n");
701
- case "version":
702
- return [
703
- "Forge Bridge — version",
704
- "Print the installed bridge package version.",
705
- "",
706
- "Usage:",
707
- " forge-bridge version",
708
- "",
709
- ].join("\n");
710
- default:
711
- return [
712
- `Unknown forge-bridge command: ${command}`,
713
- "",
714
- "Run `forge-bridge help` to see available commands.",
715
- "",
716
- ].join("\n");
717
- }
718
- }
719
900
  function connectStartupText(config) {
720
901
  const runner = config.runnerId
721
902
  ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
@@ -731,11 +912,28 @@ function connectStartupText(config) {
731
912
  ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
732
913
  ` providers: ${formatList(config.cliProviders)}`,
733
914
  ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
915
+ ` Docker Desktop auto-recovery: ${config.dockerDesktopRecoveryEnabled ? "enabled" : "disabled"}`,
916
+ ` open public sandbox networking: ${config.allowOpenPublicSandbox ? "enabled" : "disabled"}`,
734
917
  "",
735
918
  "Press Ctrl+C to stop.",
736
919
  "",
737
920
  ].join("\n");
738
921
  }
922
+ function writeSandboxPolicyStatus(local, io) {
923
+ const effective = configuredBridgeServiceConfig({}, local);
924
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
925
+ io.stdout.write(`Docker sandbox placement: ${effective.dockerEnabled ? "on" : "off"}\n`);
926
+ io.stdout.write(`Open public sandbox networking: ${effective.allowOpenPublicSandbox ? "allowed" : "denied"}\n`);
927
+ if (process.env.FORGE_DOCKER_SANDBOX !== undefined) {
928
+ io.stdout.write("Docker setting source: FORGE_DOCKER_SANDBOX override\n");
929
+ }
930
+ if (process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== undefined) {
931
+ io.stdout.write("Open public setting source: FORGE_ALLOW_OPEN_PUBLIC_SANDBOX override\n");
932
+ }
933
+ if (effective.allowOpenPublicSandbox) {
934
+ io.stdout.write("Warning: open_public permits unrestricted outbound network access from developer sandboxes.\n");
935
+ }
936
+ }
739
937
  function formatList(values) {
740
938
  return values.length > 0 ? values.join(", ") : "none";
741
939
  }
@@ -746,7 +944,7 @@ async function writeJsonAtomic(outputPath, value) {
746
944
  await fs.rename(tempPath, outputPath);
747
945
  }
748
946
  async function writeStatus(config, checkUpdates, io) {
749
- const detected = await detectCliProviders([...new Set([...config.cliProviders, "codex", "cursor"])]);
947
+ const detected = await detectCliProviders([...new Set([...config.cliProviders, "codex", "cursor", "claude"])]);
750
948
  const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
751
949
  io.stdout.write(`Forge bridge ${config.version}\n`);
752
950
  io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
@@ -772,7 +970,7 @@ async function writeStatus(config, checkUpdates, io) {
772
970
  io.stdout.write("Update check: no update channel configured.\n");
773
971
  }
774
972
  }
775
- async function cleanupRepos(config, mode, force, io) {
973
+ async function cleanupRepos(config, mode, force, cleanup, io) {
776
974
  const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
777
975
  if (repos.length === 0) {
778
976
  io.stdout.write("No repos configured.\n");
@@ -781,20 +979,28 @@ async function cleanupRepos(config, mode, force, io) {
781
979
  let failures = 0;
782
980
  for (const repo of repos) {
783
981
  const repoPath = path.resolve(repo.localPath);
784
- const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
785
- if (!force) {
786
- const action = mode === "all" ? "remove" : "prune";
787
- io.stdout.write(`[dry-run] Would ${action} ${worktreeRoot}\n`);
788
- continue;
789
- }
790
982
  try {
791
- if (mode === "all") {
792
- await fs.rm(worktreeRoot, { recursive: true, force: true });
793
- await runGit(repoPath, ["worktree", "prune"]);
794
- io.stdout.write(`Removed ${worktreeRoot}\n`);
983
+ const result = await cleanup.run({
984
+ repoRoot: repoPath,
985
+ allowedRepoRoot: config.defaultRepoRoot || undefined,
986
+ mode,
987
+ force,
988
+ });
989
+ if (!force) {
990
+ const action = mode === "all"
991
+ ? "remove"
992
+ : mode === "stale"
993
+ ? `remove ${result.candidateWorktrees} stale managed ${result.candidateWorktrees === 1 ? "worktree" : "worktrees"} from`
994
+ : "prune";
995
+ io.stdout.write(`[dry-run] Would ${action} ${result.worktreeRoot}\n`);
996
+ }
997
+ else if (mode === "all") {
998
+ io.stdout.write(`Removed ${result.worktreeRoot}\n`);
999
+ }
1000
+ else if (mode === "stale") {
1001
+ io.stdout.write(`Removed ${result.removedWorktrees} stale managed ${result.removedWorktrees === 1 ? "worktree" : "worktrees"} from ${result.worktreeRoot}\n`);
795
1002
  }
796
1003
  else {
797
- await runGit(repoPath, ["worktree", "prune"]);
798
1004
  io.stdout.write(`Pruned stale worktree metadata for ${repoPath}\n`);
799
1005
  }
800
1006
  }
@@ -811,7 +1017,7 @@ async function detectCliProviders(providers) {
811
1017
  let detected = null;
812
1018
  for (const probe of cliProbeCommandsFor(provider)) {
813
1019
  try {
814
- detected = await execVersionCommand(probe);
1020
+ detected = await execVersionCommand(probe, probe.timeoutMs);
815
1021
  break;
816
1022
  }
817
1023
  catch {
@@ -835,6 +1041,13 @@ function cliProbeCommandsFor(provider) {
835
1041
  { command: "codex", args: ["--version"] }
836
1042
  ];
837
1043
  }
1044
+ if (normalized === "claude") {
1045
+ return [
1046
+ // Claude Code's Node startup can exceed the default 2s probe on a cold Windows host.
1047
+ { command: "claude", args: ["--version"], timeoutMs: 8000 },
1048
+ { command: "claude", args: ["-v"], timeoutMs: 8000 }
1049
+ ];
1050
+ }
838
1051
  if (normalized === "cursor") {
839
1052
  return [
840
1053
  { command: "cursor-agent", args: ["-v"] },
@@ -848,31 +1061,23 @@ function cliProbeCommandsFor(provider) {
848
1061
  { command: provider, args: ["-V"] }
849
1062
  ];
850
1063
  }
851
- async function execVersionCommand(probe, timeout = 2000) {
1064
+ async function execVersionCommand(probe, timeout = 2000, maxBuffer) {
852
1065
  const directLaunch = process.platform === "win32"
853
1066
  ? await resolveCursorAgentDirectLaunch(probe.command, probe.args)
854
1067
  : null;
855
1068
  if (directLaunch) {
856
- return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout });
1069
+ return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout, maxBuffer });
857
1070
  }
858
1071
  try {
859
- return await execFileAsync(probe.command, probe.args, { timeout });
1072
+ return await execFileAsync(probe.command, probe.args, { timeout, maxBuffer });
860
1073
  }
861
1074
  catch (error) {
862
1075
  if (process.platform !== "win32") {
863
1076
  throw error;
864
1077
  }
865
- return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
866
- }
867
- }
868
- function windowsCommandLine(probe) {
869
- return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
870
- }
871
- function quoteWindowsCommandLineArg(value) {
872
- if (value.length > 0 && !/[\s"]/.test(value)) {
873
- return value;
1078
+ const launch = buildWindowsCmdLaunch(probe.command, probe.args);
1079
+ return await execFileAsync(launch.command, [...launch.args], { timeout, maxBuffer });
874
1080
  }
875
- return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
876
1081
  }
877
1082
  function versionLine(output) {
878
1083
  const lines = output
@@ -886,19 +1091,26 @@ async function detectCliModels(provider) {
886
1091
  const normalized = provider.toLowerCase();
887
1092
  try {
888
1093
  if (normalized === "codex") {
889
- const result = await execFileAsync("codex", ["debug", "models"], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
1094
+ // Windows installs codex as a .cmd shim, which execFile cannot launch directly.
1095
+ const result = await execVersionCommand({ command: "codex", args: ["debug", "models"] }, 10_000, 10 * 1024 * 1024);
890
1096
  return parseCodexModelCatalog(result.stdout);
891
1097
  }
892
1098
  if (normalized === "cursor") {
893
1099
  const result = await execVersionCommand({ command: "cursor-agent", args: ["models"] }, 10_000);
894
1100
  return parseCursorModelList(`${result.stdout || result.stderr}`);
895
1101
  }
1102
+ if (normalized === "claude") {
1103
+ // Claude Code has no model-listing command; it accepts these aliases and full model IDs.
1104
+ const configured = splitCsv(process.env.FORGE_CLAUDE_MODELS ?? "");
1105
+ return configured.length > 0 ? configured : [...claudeDefaultModels];
1106
+ }
896
1107
  }
897
1108
  catch {
898
1109
  return [];
899
1110
  }
900
1111
  return [];
901
1112
  }
1113
+ export const claudeDefaultModels = ["sonnet", "opus", "haiku"];
902
1114
  export function parseCodexModelCatalog(raw) {
903
1115
  try {
904
1116
  const parsed = JSON.parse(raw);
@@ -951,8 +1163,8 @@ function uniqueModels(models) {
951
1163
  function isRecord(value) {
952
1164
  return typeof value === "object" && value !== null;
953
1165
  }
954
- async function withDetectedCliProviders(config) {
955
- const detected = await detectCliProviders(configuredProviderCandidates(config.cliProviders));
1166
+ async function withDetectedCliProviders(config, candidates = config.cliProviders) {
1167
+ const detected = await detectCliProviders(uniqueProviders(candidates));
956
1168
  const found = detected.filter((result) => result.found);
957
1169
  if (found.length === 0) {
958
1170
  return config;
@@ -976,6 +1188,7 @@ async function withDetectedCliProviders(config) {
976
1188
  };
977
1189
  }
978
1190
  async function withDetectedPairCliProviders(args) {
1191
+ const configured = configuredProviderCandidates(args.cliProviders);
979
1192
  const detectedConfig = await withDetectedCliProviders({
980
1193
  ...defaultBridgeServiceConfig({
981
1194
  serverUrl: args.serverUrl,
@@ -985,14 +1198,14 @@ async function withDetectedPairCliProviders(args) {
985
1198
  cleanupWorktrees: args.cleanupWorktrees,
986
1199
  defaultRepoRoot: args.repoRoot ?? defaultBridgeServiceConfig().defaultRepoRoot
987
1200
  })
988
- });
1201
+ }, configured);
989
1202
  return {
990
1203
  ...args,
991
1204
  cliProviders: detectedConfig.cliProviders
992
1205
  };
993
1206
  }
994
1207
  function configuredProviderCandidates(providers) {
995
- return uniqueProviders([...providers, "codex", "cursor"]);
1208
+ return uniqueProviders([...providers, "codex", "cursor", "claude"]);
996
1209
  }
997
1210
  function uniqueProviders(providers) {
998
1211
  return providers
@@ -1000,6 +1213,3 @@ function uniqueProviders(providers) {
1000
1213
  .filter((provider) => provider.length > 0)
1001
1214
  .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
1002
1215
  }
1003
- async function runGit(cwd, args) {
1004
- await execFileAsync("git", args, { cwd, timeout: 10_000 });
1005
- }