@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.3-dev.fbe15023cdfe

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 (150) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +356 -205
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +47 -3
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  24. package/dist/runtime/attempt-process-supervisor.js +350 -0
  25. package/dist/runtime/generated-output-reset.d.ts +10 -0
  26. package/dist/runtime/generated-output-reset.js +103 -0
  27. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  28. package/dist/runtime/host-listener-inspector.js +324 -0
  29. package/dist/runtime/host-port-broker.d.ts +76 -0
  30. package/dist/runtime/host-port-broker.js +333 -0
  31. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  32. package/dist/runtime/host-runtime-contract.js +700 -0
  33. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  34. package/dist/runtime/host-runtime-lease.js +1460 -0
  35. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  36. package/dist/runtime/host-runtime-promotion.js +159 -0
  37. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  38. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  40. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  41. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  42. package/dist/runtime/runtime-isolation-lease.js +158 -0
  43. package/dist/sandbox/command-runner.d.ts +4 -0
  44. package/dist/sandbox/command-runner.js +47 -0
  45. package/dist/sandbox/contract.d.ts +5 -0
  46. package/dist/sandbox/contract.js +276 -0
  47. package/dist/sandbox/event-budget.d.ts +11 -0
  48. package/dist/sandbox/event-budget.js +27 -0
  49. package/dist/sandbox/image-policy.d.ts +35 -0
  50. package/dist/sandbox/image-policy.js +247 -0
  51. package/dist/sandbox/index.d.ts +5 -0
  52. package/dist/sandbox/index.js +5 -0
  53. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  54. package/dist/sandbox/lease-watchdog.js +67 -0
  55. package/dist/sandbox/preflight.d.ts +54 -0
  56. package/dist/sandbox/preflight.js +335 -0
  57. package/dist/sandbox/process.d.ts +20 -0
  58. package/dist/sandbox/process.js +209 -0
  59. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  60. package/dist/sandbox/provider-auth/codex.js +212 -0
  61. package/dist/sandbox/repository-lock.d.ts +5 -0
  62. package/dist/sandbox/repository-lock.js +119 -0
  63. package/dist/sandbox/runtime.d.ts +297 -0
  64. package/dist/sandbox/runtime.js +2338 -0
  65. package/dist/sandbox/sandbox.types.d.ts +86 -0
  66. package/dist/sandbox/sandbox.types.js +1 -0
  67. package/dist/sandbox/smoke.d.ts +76 -0
  68. package/dist/sandbox/smoke.js +380 -0
  69. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  70. package/dist/sandbox/worker-supervisor.js +36 -0
  71. package/dist/sandbox/worker.d.ts +1 -0
  72. package/dist/sandbox/worker.js +225 -0
  73. package/dist/sandbox/workspace.service.d.ts +13 -0
  74. package/dist/sandbox/workspace.service.js +72 -0
  75. package/dist/session/managed-attempt-tools.d.ts +12 -0
  76. package/dist/session/managed-attempt-tools.js +561 -0
  77. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  78. package/dist/session/phase-completion.artifacts.js +627 -0
  79. package/dist/session/phase-completion.d.ts +43 -42
  80. package/dist/session/phase-completion.helpers.d.ts +112 -0
  81. package/dist/session/phase-completion.helpers.js +684 -0
  82. package/dist/session/phase-completion.js +114 -3007
  83. package/dist/session/phase-completion.prompts.d.ts +14 -0
  84. package/dist/session/phase-completion.prompts.js +234 -0
  85. package/dist/session/phase-completion.runtime.d.ts +47 -0
  86. package/dist/session/phase-completion.runtime.js +731 -0
  87. package/dist/session/phase-completion.schemas.d.ts +3 -0
  88. package/dist/session/phase-completion.schemas.js +812 -0
  89. package/dist/session/phase-completion.validation.d.ts +37 -0
  90. package/dist/session/phase-completion.validation.js +828 -0
  91. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  92. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  93. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  94. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  95. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  96. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  97. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  98. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  99. package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
  100. package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
  101. package/dist/session/provider-cli/provider-cli.process.js +474 -0
  102. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  103. package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
  104. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  105. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  106. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  107. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  108. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -53
  109. package/dist/session/provider-cli/provider-cli.service.js +587 -2530
  110. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  111. package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
  112. package/dist/session/provider-cli/provider-output-state.js +473 -0
  113. package/dist/session/session-redaction.d.ts +3 -0
  114. package/dist/session/session-redaction.js +23 -0
  115. package/dist/session/session.base.d.ts +6 -1
  116. package/dist/session/session.service.d.ts +8 -2
  117. package/dist/session/session.service.js +463 -58
  118. package/dist/session/session.types.d.ts +177 -1
  119. package/dist/session/session.types.js +352 -3
  120. package/dist/shared/path/path.js +3 -1
  121. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  122. package/dist/shared/process/host-process-recovery.js +78 -0
  123. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  124. package/dist/shared/process/host-process-supervisor.js +282 -0
  125. package/dist/shared/process/process.d.ts +1 -0
  126. package/dist/shared/process/process.js +1 -0
  127. package/dist/shared/process/windows-launch.d.ts +8 -0
  128. package/dist/shared/process/windows-launch.js +41 -0
  129. package/dist/shared/provider-capabilities.d.ts +1 -0
  130. package/dist/shared/provider-capabilities.js +3 -0
  131. package/dist/shared/redaction/redaction.js +10 -3
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/worktree/artifact-injector.d.ts +3 -2
  135. package/dist/worktree/artifact-injector.js +82 -10
  136. package/dist/worktree/dependency-warmer.d.ts +21 -1
  137. package/dist/worktree/dependency-warmer.js +138 -5
  138. package/dist/worktree/index.d.ts +1 -0
  139. package/dist/worktree/index.js +1 -0
  140. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  141. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  142. package/dist/worktree/worktree-paths.d.ts +4 -0
  143. package/dist/worktree/worktree-paths.js +30 -0
  144. package/dist/worktree/worktree.base.d.ts +3 -0
  145. package/dist/worktree/worktree.service.d.ts +9 -3
  146. package/dist/worktree/worktree.service.js +100 -52
  147. package/dist/worktree/worktree.types.d.ts +3 -1
  148. package/package.json +6 -3
  149. package/runtime/windows-job-supervisor.ps1 +356 -0
  150. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -5,23 +5,34 @@ 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 { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
10
+ import { redactText } from "../shared/redaction/redaction.js";
9
11
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
10
12
  import { forgeBridgeVersion } from "../version.js";
11
- import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
13
+ import { ManagedWorktreeCleanupService } from "../cleanup/cleanup.service.js";
14
+ import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, setSandboxPolicy, setServerUrl, writeLocalConfig } from "./local-config.js";
12
15
  import { BridgeSessionService } from "../session/session.service.js";
16
+ import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
17
+ import { validateDockerfilePolicy, validateSandboxContract } from "../sandbox/contract.js";
18
+ import { DockerPreflight } from "../sandbox/preflight.js";
19
+ import { DockerSandboxRuntime } from "../sandbox/runtime.js";
20
+ import { DockerSandboxSmoke } from "../sandbox/smoke.js";
13
21
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
14
22
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
23
+ import { commandHelpText, helpText } from "./cli.help.js";
15
24
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
16
25
  const execFileAsync = promisify(execFile);
17
26
  export class ForgeBridgeCliService {
18
27
  sessions;
19
28
  bridgeFactory;
20
29
  pairRunner;
21
- constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
30
+ cleanupFactory;
31
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner, cleanupFactory = stateRoot => new ManagedWorktreeCleanupService(stateRoot)) {
22
32
  this.sessions = sessions;
23
33
  this.bridgeFactory = bridgeFactory;
24
34
  this.pairRunner = pairRunner;
35
+ this.cleanupFactory = cleanupFactory;
25
36
  }
26
37
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
38
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
@@ -86,6 +97,7 @@ export class ForgeBridgeCliService {
86
97
  if (args.command === "pair") {
87
98
  try {
88
99
  const current = readLocalConfigSync();
100
+ io.stdout.write(`Pairing with server: ${args.serverUrl}\n`);
89
101
  const code = await resolvePairingCode(args.code);
90
102
  const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
91
103
  await fs.mkdir(repoRoot, { recursive: true });
@@ -115,12 +127,66 @@ export class ForgeBridgeCliService {
115
127
  return 2;
116
128
  }
117
129
  }
130
+ if (args.command === "server-show") {
131
+ const saved = readLocalConfigSync().serverUrl ?? "not configured";
132
+ io.stdout.write(`Saved server: ${saved}\n`);
133
+ if (process.env.FORGE_URL?.trim()) {
134
+ io.stdout.write(`Effective server: ${process.env.FORGE_URL.trim()} (FORGE_URL override)\n`);
135
+ }
136
+ else {
137
+ io.stdout.write(`Effective server: ${args.config.serverUrl || "not configured"}\n`);
138
+ }
139
+ return 0;
140
+ }
141
+ if (args.command === "server-set") {
142
+ try {
143
+ const config = await setServerUrl(args.serverUrl);
144
+ io.stdout.write(`Saved Forge server: ${config.serverUrl}\n`);
145
+ io.stdout.write("Existing runner credentials were retained. Re-run `forge-bridge pair --server <url>` if this is a different Forge deployment.\n");
146
+ if (process.env.FORGE_URL?.trim()) {
147
+ io.stdout.write(`Warning: FORGE_URL is set to ${process.env.FORGE_URL.trim()} and overrides the saved server.\n`);
148
+ }
149
+ return 0;
150
+ }
151
+ catch (error) {
152
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
153
+ return 2;
154
+ }
155
+ }
118
156
  if (args.command === "status") {
119
157
  await writeStatus(args.config, args.checkUpdates, io);
120
158
  return 0;
121
159
  }
122
160
  if (args.command === "cleanup") {
123
- return await cleanupRepos(args.config, args.mode, args.force, io);
161
+ return await cleanupRepos(args.config, args.mode, args.force, this.cleanupFactory(args.config.sandboxStateRoot), io);
162
+ }
163
+ if (args.command === "sandbox-init") {
164
+ return await initializeSandbox(args.repoPath, io);
165
+ }
166
+ if (args.command === "sandbox-doctor") {
167
+ return await diagnoseSandbox(args.repoPath, args.requirePinnedBase, io);
168
+ }
169
+ if (args.command === "sandbox-smoke") {
170
+ return await smokeSandbox(args.repoPath, args.reportPath, args.requirePinnedBase, args.probeLoopbackPort, io);
171
+ }
172
+ if (args.command === "sandbox-reap") {
173
+ return await reapSandboxes(io);
174
+ }
175
+ if (args.command === "sandbox-on") {
176
+ const saved = await setSandboxPolicy(true, args.allowOpenPublic);
177
+ io.stdout.write("Saved local Docker sandbox policy. Restart the bridge to apply it.\n");
178
+ writeSandboxPolicyStatus(saved, io);
179
+ return 0;
180
+ }
181
+ if (args.command === "sandbox-off") {
182
+ const saved = await setSandboxPolicy(false, false);
183
+ io.stdout.write("Disabled local Docker sandbox placement. Restart the bridge to apply it.\n");
184
+ writeSandboxPolicyStatus(saved, io);
185
+ return 0;
186
+ }
187
+ if (args.command === "sandbox-status") {
188
+ writeSandboxPolicyStatus(readLocalConfigSync(), io);
189
+ return 0;
124
190
  }
125
191
  if (args.command === "mcp") {
126
192
  const parsed = parseCompletionMcpArgs(args.argv);
@@ -140,12 +206,12 @@ export class ForgeBridgeCliService {
140
206
  emit: async (event) => {
141
207
  const line = {
142
208
  type: "runner_event",
143
- event: redactSecrets(event, redactionSecrets)
209
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
144
210
  };
145
211
  io.stdout.write(`${JSON.stringify(line)}\n`);
146
212
  }
147
213
  });
148
- await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
214
+ await writeJsonAtomic(args.resultPath, redactRunOnceResultPacket(result, redactionSecrets));
149
215
  return result.exitCode;
150
216
  }
151
217
  catch (error) {
@@ -231,6 +297,15 @@ export function parseArgs(argv) {
231
297
  }
232
298
  return { ok: true, command: "remove-repo", label: argv[1] };
233
299
  }
300
+ if (argv[0] === "server") {
301
+ if (argv.length === 1 || (argv[1] === "show" && argv.length === 2)) {
302
+ return { ok: true, command: "server-show", config: configuredBridgeServiceConfig() };
303
+ }
304
+ if (argv[1] === "set" && argv[2] && argv.length === 3) {
305
+ return { ok: true, command: "server-set", serverUrl: argv[2] };
306
+ }
307
+ return { ok: false, error: "Usage: forge-bridge server <show|set <url>>" };
308
+ }
234
309
  if (argv[0] === "status") {
235
310
  let checkUpdates = false;
236
311
  for (let index = 1; index < argv.length; index += 1) {
@@ -250,10 +325,13 @@ export function parseArgs(argv) {
250
325
  if (argv[0] === "mcp") {
251
326
  return { ok: true, command: "mcp", argv };
252
327
  }
328
+ if (argv[0] === "sandbox") {
329
+ return parseSandboxArgs(argv);
330
+ }
253
331
  if (argv[0] !== "run-once") {
254
332
  return {
255
333
  ok: false,
256
- error: usage()
334
+ error: helpText()
257
335
  };
258
336
  }
259
337
  let dispatchPath = null;
@@ -280,6 +358,230 @@ export function parseArgs(argv) {
280
358
  resultPath: path.resolve(resultPath)
281
359
  };
282
360
  }
361
+ function parseSandboxArgs(argv) {
362
+ const action = argv[1];
363
+ if (action === "on") {
364
+ let allowOpenPublic = false;
365
+ for (let index = 2; index < argv.length; index += 1) {
366
+ if (argv[index] === "--allow-open-public") {
367
+ allowOpenPublic = true;
368
+ }
369
+ else {
370
+ return { ok: false, error: `Unknown sandbox argument: ${argv[index]}` };
371
+ }
372
+ }
373
+ return { ok: true, command: "sandbox-on", allowOpenPublic };
374
+ }
375
+ if (action === "off" && argv.length === 2)
376
+ return { ok: true, command: "sandbox-off" };
377
+ if (action === "status" && argv.length === 2)
378
+ return { ok: true, command: "sandbox-status" };
379
+ let repoPath = process.cwd();
380
+ let reportPath;
381
+ let requirePinnedBase = false;
382
+ let probeLoopbackPort = false;
383
+ for (let index = 2; index < argv.length; index += 1) {
384
+ const arg = argv[index];
385
+ if (arg === "--repo") {
386
+ repoPath = argv[++index] ?? "";
387
+ if (!repoPath)
388
+ return { ok: false, error: "--repo requires a path." };
389
+ }
390
+ else if (arg === "--report" && action === "smoke") {
391
+ reportPath = argv[++index];
392
+ if (!reportPath)
393
+ return { ok: false, error: "--report requires a path." };
394
+ }
395
+ else if (arg === "--require-pinned-base" && (action === "doctor" || action === "smoke")) {
396
+ requirePinnedBase = true;
397
+ }
398
+ else if (arg === "--probe-loopback-port" && action === "smoke") {
399
+ probeLoopbackPort = true;
400
+ }
401
+ else {
402
+ return { ok: false, error: `Unknown sandbox argument: ${arg}` };
403
+ }
404
+ }
405
+ if (action === "init")
406
+ return { ok: true, command: "sandbox-init", repoPath: path.resolve(repoPath) };
407
+ if (action === "doctor")
408
+ return { ok: true, command: "sandbox-doctor", repoPath: path.resolve(repoPath), requirePinnedBase };
409
+ if (action === "smoke")
410
+ return {
411
+ ok: true,
412
+ command: "sandbox-smoke",
413
+ repoPath: path.resolve(repoPath),
414
+ reportPath: reportPath ? path.resolve(reportPath) : undefined,
415
+ requirePinnedBase,
416
+ probeLoopbackPort,
417
+ };
418
+ if (action === "reap" && argv.length === 2)
419
+ return { ok: true, command: "sandbox-reap" };
420
+ 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]" };
421
+ }
422
+ const sandboxContractTemplate = `schemaVersion: 1
423
+ workingDirectory: /workspace
424
+ commands:
425
+ install: npm ci
426
+ test: npm test
427
+ caches:
428
+ - name: npm
429
+ mode: readonly
430
+ services: []
431
+ environment: []
432
+ artifacts: []
433
+ logs: []
434
+ browser: false
435
+ `;
436
+ const sandboxDockerfileTemplate = `# Pin this base by digest before enabling sandbox_required.
437
+ FROM node:22.17.0-bookworm-slim
438
+ WORKDIR /workspace
439
+ `;
440
+ async function initializeSandbox(repoPath, io) {
441
+ const forgePath = path.join(repoPath, ".forge");
442
+ const files = [
443
+ ["forge.sandbox.yml", sandboxContractTemplate],
444
+ ["Dockerfile", sandboxDockerfileTemplate],
445
+ ["forge.env.example", "# List non-secret variable names in forge.sandbox.yml. Never commit secret values.\n"]
446
+ ];
447
+ await fs.mkdir(forgePath, { recursive: true });
448
+ let created = 0;
449
+ for (const [name, contents] of files) {
450
+ const target = path.join(forgePath, name);
451
+ try {
452
+ const handle = await fs.open(target, "wx", 0o600);
453
+ await handle.writeFile(contents);
454
+ await handle.close();
455
+ io.stdout.write(`Created ${path.relative(repoPath, target)}\n`);
456
+ created += 1;
457
+ }
458
+ catch (error) {
459
+ if (error.code !== "EEXIST")
460
+ throw error;
461
+ io.stdout.write(`Kept existing ${path.relative(repoPath, target)}\n`);
462
+ }
463
+ }
464
+ io.stdout.write(created > 0 ? "Sandbox scaffold is ready. Run `forge-bridge sandbox doctor`.\n" : "Sandbox scaffold already exists.\n");
465
+ return 0;
466
+ }
467
+ async function diagnoseSandbox(repoPath, requirePinnedBase, io) {
468
+ try {
469
+ const forgePath = path.join(repoPath, ".forge");
470
+ const contractSource = await fs.readFile(path.join(forgePath, "forge.sandbox.yml"), "utf8");
471
+ const dockerfile = await fs.readFile(path.join(forgePath, "Dockerfile"), "utf8");
472
+ const contract = validateSandboxContract(contractSource);
473
+ const warnings = validateDockerfilePolicy(dockerfile, requirePinnedBase);
474
+ io.stdout.write(`Contract: valid (${contract.digest})\n`);
475
+ for (const warning of warnings)
476
+ io.stdout.write(`Warning: ${warning}\n`);
477
+ const config = defaultBridgeServiceConfig();
478
+ const preflight = new DockerPreflight({
479
+ dockerExecutable: config.dockerExecutable,
480
+ dockerContext: config.dockerContext,
481
+ stateRoot: config.sandboxStateRoot,
482
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
483
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
484
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
485
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
486
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
487
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
488
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
489
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
490
+ requireRootless: config.requireRootlessDocker
491
+ });
492
+ try {
493
+ const result = await preflight.refresh();
494
+ if (!result.healthy) {
495
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
496
+ return 1;
497
+ }
498
+ io.stdout.write(`Docker: healthy (${result.runtimeVersion}; capacity ${result.maxConcurrentSandboxes})\n`);
499
+ return 0;
500
+ }
501
+ finally {
502
+ await preflight.release();
503
+ }
504
+ }
505
+ catch (error) {
506
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
507
+ return 1;
508
+ }
509
+ }
510
+ async function smokeSandbox(repoPath, reportPath, requirePinnedBase, probeLoopbackPort, io) {
511
+ try {
512
+ const result = await new DockerSandboxSmoke(defaultBridgeServiceConfig()).run({
513
+ repoPath,
514
+ reportPath,
515
+ requirePinnedBase,
516
+ probeLoopbackPort,
517
+ onProgress: (stage) => io.stdout.write(`Smoke: ${stage}...\n`),
518
+ });
519
+ const { report } = result;
520
+ if (report.repository.contractDigest)
521
+ io.stdout.write(`Contract: valid (${report.repository.contractDigest})\n`);
522
+ for (const warning of report.repository.policyWarnings)
523
+ io.stdout.write(`Warning: ${warning}\n`);
524
+ if (report.docker.runtimeVersion) {
525
+ io.stdout.write(`Docker: healthy (${report.docker.runtimeVersion}; capacity ${report.docker.capacity})\n`);
526
+ }
527
+ io.stdout.write(`Attempt: ${report.dispatchAttemptId}\n`);
528
+ io.stdout.write(`Resource lease: ${report.resourceLeaseId}\n`);
529
+ if (report.publishedPortProbe) {
530
+ io.stdout.write(`Loopback port: ${report.publishedPortProbe.hostIp}:${report.publishedPortProbe.hostPort} -> ${report.publishedPortProbe.containerPort}/tcp\n`);
531
+ }
532
+ io.stdout.write(`Cleanup: ${report.cleanup.verified ? "verified (zero exact-label resources)" : "failed"}\n`);
533
+ io.stdout.write(`Report: ${result.reportPath}\n`);
534
+ if (report.status === "passed") {
535
+ io.stdout.write("Sandbox smoke: PASS\n");
536
+ return 0;
537
+ }
538
+ io.stderr.write(`Sandbox smoke: FAIL (${report.failure?.code ?? "sandbox_smoke_failed"} at ${report.failure?.stage ?? "unknown"}): ${report.failure?.message ?? "unknown failure"}\n`);
539
+ return 1;
540
+ }
541
+ catch (error) {
542
+ io.stderr.write(`Sandbox smoke could not write its report: ${error instanceof Error ? error.message : String(error)}\n`);
543
+ return 1;
544
+ }
545
+ }
546
+ async function reapSandboxes(io) {
547
+ const config = defaultBridgeServiceConfig();
548
+ if (!/^[0-9a-f-]{32,36}$/iu.test(config.runnerId)) {
549
+ io.stderr.write("FORGE_RUNNER_ID must identify the runner whose exact-labeled resources may be reaped.\n");
550
+ return 2;
551
+ }
552
+ const preflight = new DockerPreflight({
553
+ dockerExecutable: config.dockerExecutable,
554
+ dockerContext: config.dockerContext,
555
+ stateRoot: config.sandboxStateRoot,
556
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
557
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
558
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
559
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
560
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
561
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
562
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
563
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
564
+ requireRootless: config.requireRootlessDocker
565
+ });
566
+ try {
567
+ const result = await preflight.refresh();
568
+ if (!result.healthy && result.unavailableCode !== "docker_disk_low") {
569
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
570
+ return 1;
571
+ }
572
+ const runtime = new DockerSandboxRuntime(config);
573
+ await runtime.reap({ diskLow: result.diskLow });
574
+ io.stdout.write(`Sandbox reaper completed for runner ${config.runnerId}.\n`);
575
+ return 0;
576
+ }
577
+ catch (error) {
578
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
579
+ return 1;
580
+ }
581
+ finally {
582
+ await preflight.release();
583
+ }
584
+ }
283
585
  function parseConnectArgs(argv) {
284
586
  let serverUrl = null;
285
587
  let runnerId = null;
@@ -468,7 +770,7 @@ async function claimBridgeRunner(args) {
468
770
  })
469
771
  });
470
772
  if (!response.ok) {
471
- throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
773
+ throw new Error(formatPairingFailure(response.status, args.serverUrl, await response.text()));
472
774
  }
473
775
  const value = await response.json();
474
776
  if (!value.runnerId || !value.deviceToken) {
@@ -497,6 +799,8 @@ function pairedConfig(current, args, claim) {
497
799
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
498
800
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
499
801
  supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
802
+ supportsDraftAttachments: current.supportsDraftAttachments ?? true,
803
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(args.cliProviders),
500
804
  repoMappings: current.repoMappings ?? []
501
805
  };
502
806
  }
@@ -541,181 +845,15 @@ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
541
845
  function normalizePairingCode(code) {
542
846
  return code?.replace(/[\s-]/g, "").trim() ?? "";
543
847
  }
848
+ function formatPairingFailure(status, serverUrl, body) {
849
+ const hint = body.includes("runner.invalid_pairing_code")
850
+ ? " Make sure the code was generated by the same Forge server shown above."
851
+ : "";
852
+ return `Pairing failed (${status}) against ${serverUrl}: ${body}${hint}`;
853
+ }
544
854
  function normalizeServerUrl(serverUrl) {
545
855
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
546
856
  }
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
857
  function connectStartupText(config) {
720
858
  const runner = config.runnerId
721
859
  ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
@@ -731,11 +869,27 @@ function connectStartupText(config) {
731
869
  ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
732
870
  ` providers: ${formatList(config.cliProviders)}`,
733
871
  ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
872
+ ` open public sandbox networking: ${config.allowOpenPublicSandbox ? "enabled" : "disabled"}`,
734
873
  "",
735
874
  "Press Ctrl+C to stop.",
736
875
  "",
737
876
  ].join("\n");
738
877
  }
878
+ function writeSandboxPolicyStatus(local, io) {
879
+ const effective = configuredBridgeServiceConfig({}, local);
880
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
881
+ io.stdout.write(`Docker sandbox placement: ${effective.dockerEnabled ? "on" : "off"}\n`);
882
+ io.stdout.write(`Open public sandbox networking: ${effective.allowOpenPublicSandbox ? "allowed" : "denied"}\n`);
883
+ if (process.env.FORGE_DOCKER_SANDBOX !== undefined) {
884
+ io.stdout.write("Docker setting source: FORGE_DOCKER_SANDBOX override\n");
885
+ }
886
+ if (process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== undefined) {
887
+ io.stdout.write("Open public setting source: FORGE_ALLOW_OPEN_PUBLIC_SANDBOX override\n");
888
+ }
889
+ if (effective.allowOpenPublicSandbox) {
890
+ io.stdout.write("Warning: open_public permits unrestricted outbound network access from developer sandboxes.\n");
891
+ }
892
+ }
739
893
  function formatList(values) {
740
894
  return values.length > 0 ? values.join(", ") : "none";
741
895
  }
@@ -772,7 +926,7 @@ async function writeStatus(config, checkUpdates, io) {
772
926
  io.stdout.write("Update check: no update channel configured.\n");
773
927
  }
774
928
  }
775
- async function cleanupRepos(config, mode, force, io) {
929
+ async function cleanupRepos(config, mode, force, cleanup, io) {
776
930
  const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
777
931
  if (repos.length === 0) {
778
932
  io.stdout.write("No repos configured.\n");
@@ -781,20 +935,28 @@ async function cleanupRepos(config, mode, force, io) {
781
935
  let failures = 0;
782
936
  for (const repo of repos) {
783
937
  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
938
  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`);
939
+ const result = await cleanup.run({
940
+ repoRoot: repoPath,
941
+ allowedRepoRoot: config.defaultRepoRoot || undefined,
942
+ mode,
943
+ force,
944
+ });
945
+ if (!force) {
946
+ const action = mode === "all"
947
+ ? "remove"
948
+ : mode === "stale"
949
+ ? `remove ${result.candidateWorktrees} stale managed ${result.candidateWorktrees === 1 ? "worktree" : "worktrees"} from`
950
+ : "prune";
951
+ io.stdout.write(`[dry-run] Would ${action} ${result.worktreeRoot}\n`);
952
+ }
953
+ else if (mode === "all") {
954
+ io.stdout.write(`Removed ${result.worktreeRoot}\n`);
955
+ }
956
+ else if (mode === "stale") {
957
+ io.stdout.write(`Removed ${result.removedWorktrees} stale managed ${result.removedWorktrees === 1 ? "worktree" : "worktrees"} from ${result.worktreeRoot}\n`);
795
958
  }
796
959
  else {
797
- await runGit(repoPath, ["worktree", "prune"]);
798
960
  io.stdout.write(`Pruned stale worktree metadata for ${repoPath}\n`);
799
961
  }
800
962
  }
@@ -862,18 +1024,10 @@ async function execVersionCommand(probe, timeout = 2000) {
862
1024
  if (process.platform !== "win32") {
863
1025
  throw error;
864
1026
  }
865
- return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
1027
+ const launch = buildWindowsCmdLaunch(probe.command, probe.args);
1028
+ return await execFileAsync(launch.command, [...launch.args], { timeout });
866
1029
  }
867
1030
  }
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;
874
- }
875
- return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
876
- }
877
1031
  function versionLine(output) {
878
1032
  const lines = output
879
1033
  .trim()
@@ -1000,6 +1154,3 @@ function uniqueProviders(providers) {
1000
1154
  .filter((provider) => provider.length > 0)
1001
1155
  .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
1002
1156
  }
1003
- async function runGit(cwd, args) {
1004
- await execFileAsync("git", args, { cwd, timeout: 10_000 });
1005
- }