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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +52 -3
  4. package/dist/bridge/bridge.service.js +776 -19
  5. package/dist/bridge/bridge.types.d.ts +131 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +46 -0
  14. package/dist/cli/cli.service.d.ts +42 -2
  15. package/dist/cli/cli.service.js +350 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +39 -0
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +50 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +74 -0
  23. package/dist/runtime/attempt-process-supervisor.js +290 -0
  24. package/dist/runtime/host-listener-inspector.d.ts +28 -0
  25. package/dist/runtime/host-listener-inspector.js +185 -0
  26. package/dist/runtime/host-port-broker.d.ts +76 -0
  27. package/dist/runtime/host-port-broker.js +333 -0
  28. package/dist/runtime/host-runtime-contract.d.ts +39 -0
  29. package/dist/runtime/host-runtime-contract.js +295 -0
  30. package/dist/runtime/host-runtime-lease.d.ts +122 -0
  31. package/dist/runtime/host-runtime-lease.js +1241 -0
  32. package/dist/runtime/host-runtime-promotion.d.ts +21 -0
  33. package/dist/runtime/host-runtime-promotion.js +128 -0
  34. package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
  35. package/dist/runtime/node-host-runtime-adapter.js +205 -0
  36. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  37. package/dist/runtime/playwright-host-runtime-adapter.js +136 -0
  38. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  39. package/dist/runtime/runtime-isolation-lease.js +158 -0
  40. package/dist/sandbox/command-runner.d.ts +4 -0
  41. package/dist/sandbox/command-runner.js +47 -0
  42. package/dist/sandbox/contract.d.ts +5 -0
  43. package/dist/sandbox/contract.js +276 -0
  44. package/dist/sandbox/event-budget.d.ts +11 -0
  45. package/dist/sandbox/event-budget.js +27 -0
  46. package/dist/sandbox/image-policy.d.ts +35 -0
  47. package/dist/sandbox/image-policy.js +247 -0
  48. package/dist/sandbox/index.d.ts +5 -0
  49. package/dist/sandbox/index.js +5 -0
  50. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  51. package/dist/sandbox/lease-watchdog.js +67 -0
  52. package/dist/sandbox/preflight.d.ts +45 -0
  53. package/dist/sandbox/preflight.js +300 -0
  54. package/dist/sandbox/process.d.ts +20 -0
  55. package/dist/sandbox/process.js +209 -0
  56. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  57. package/dist/sandbox/provider-auth/codex.js +212 -0
  58. package/dist/sandbox/repository-lock.d.ts +5 -0
  59. package/dist/sandbox/repository-lock.js +119 -0
  60. package/dist/sandbox/runtime.d.ts +297 -0
  61. package/dist/sandbox/runtime.js +2338 -0
  62. package/dist/sandbox/sandbox.types.d.ts +86 -0
  63. package/dist/sandbox/sandbox.types.js +1 -0
  64. package/dist/sandbox/smoke.d.ts +76 -0
  65. package/dist/sandbox/smoke.js +380 -0
  66. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  67. package/dist/sandbox/worker-supervisor.js +36 -0
  68. package/dist/sandbox/worker.d.ts +1 -0
  69. package/dist/sandbox/worker.js +225 -0
  70. package/dist/sandbox/workspace.service.d.ts +13 -0
  71. package/dist/sandbox/workspace.service.js +67 -0
  72. package/dist/session/phase-completion.artifacts.js +23 -2
  73. package/dist/session/phase-completion.helpers.js +6 -1
  74. package/dist/session/phase-completion.runtime.js +3 -1
  75. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  76. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  77. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  78. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  79. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  80. package/dist/session/provider-cli/provider-cli.process.d.ts +3 -0
  81. package/dist/session/provider-cli/provider-cli.process.js +65 -14
  82. package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
  83. package/dist/session/provider-cli/provider-cli.prompts.js +99 -15
  84. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  85. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  86. package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
  87. package/dist/session/provider-cli/provider-cli.service.js +167 -28
  88. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  89. package/dist/session/session-redaction.d.ts +3 -0
  90. package/dist/session/session-redaction.js +23 -0
  91. package/dist/session/session.base.d.ts +4 -0
  92. package/dist/session/session.service.d.ts +6 -1
  93. package/dist/session/session.service.js +184 -38
  94. package/dist/session/session.types.d.ts +58 -1
  95. package/dist/session/session.types.js +172 -2
  96. package/dist/shared/path/path.js +3 -1
  97. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  98. package/dist/shared/process/host-process-recovery.js +78 -0
  99. package/dist/shared/process/host-process-supervisor.d.ts +40 -0
  100. package/dist/shared/process/host-process-supervisor.js +195 -0
  101. package/dist/shared/provider-capabilities.d.ts +1 -0
  102. package/dist/shared/provider-capabilities.js +3 -0
  103. package/dist/shared/redaction/redaction.js +6 -1
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +6 -2
  114. package/dist/worktree/worktree.service.js +60 -43
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +6 -3
  117. package/runtime/windows-job-supervisor.ps1 +287 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -16,9 +16,11 @@ export function helpText() {
16
16
  " connect * Start the runner bridge and wait for Forge dispatches",
17
17
  " pair * Pair this machine with Forge and save runner credentials",
18
18
  " status * Show config, runner identity, repos, provider CLIs, and updates",
19
+ " server * Show or change the saved Forge server URL",
19
20
  " add-repo * Map a Forge repo label/name to a local checkout path",
20
21
  " remove-repo * Remove a saved repo mapping",
21
22
  " cleanup * Prune managed bridge worktrees",
23
+ " sandbox * Initialize, diagnose, smoke-test, or reap Docker sandboxes",
22
24
  " version * Print the installed bridge package version",
23
25
  " help * Display help for command",
24
26
  " run-once * Internal: run one dispatch packet from JSON",
@@ -29,6 +31,7 @@ export function helpText() {
29
31
  " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
30
32
  " forge-bridge connect",
31
33
  " forge-bridge status --check-updates",
34
+ " forge-bridge server set http://localhost:5208",
32
35
  " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
33
36
  " forge-bridge cleanup --stale",
34
37
  "",
@@ -99,6 +102,23 @@ export function commandHelpText(command) {
99
102
  " forge-bridge status --check-updates",
100
103
  "",
101
104
  ].join("\n");
105
+ case "server":
106
+ return [
107
+ "Forge Bridge — server",
108
+ "Show or change the saved Forge server URL.",
109
+ "",
110
+ "Usage:",
111
+ " forge-bridge server show",
112
+ " forge-bridge server set <url>",
113
+ "",
114
+ "The saved runner credentials are retained. Pair again when switching to a different Forge deployment.",
115
+ "FORGE_URL overrides the saved server when that environment variable is set.",
116
+ "",
117
+ "Examples:",
118
+ " forge-bridge server set http://localhost:5208",
119
+ " forge-bridge server set https://srv1493013.tail66073d.ts.net:8443",
120
+ "",
121
+ ].join("\n");
102
122
  case "add-repo":
103
123
  return [
104
124
  "Forge Bridge — add-repo",
@@ -133,6 +153,32 @@ export function commandHelpText(command) {
133
153
  " forge-bridge cleanup --all --force",
134
154
  "",
135
155
  ].join("\n");
156
+ case "sandbox":
157
+ return [
158
+ "Forge Bridge — sandbox",
159
+ "Configure local Docker sandbox placement or inspect a repository-owned sandbox contract.",
160
+ "",
161
+ "Usage:",
162
+ " forge-bridge sandbox on [--allow-open-public]",
163
+ " forge-bridge sandbox off",
164
+ " forge-bridge sandbox status",
165
+ " forge-bridge sandbox init [--repo <path>]",
166
+ " forge-bridge sandbox doctor [--repo <path>] [--require-pinned-base]",
167
+ " forge-bridge sandbox smoke [--repo <path>] [--report <path>] [--require-pinned-base] [--probe-loopback-port]",
168
+ " forge-bridge sandbox reap",
169
+ "",
170
+ "`sandbox on` keeps unrestricted public networking denied unless the explicit",
171
+ "developer-only --allow-open-public option is supplied. The local policy is",
172
+ "saved in bridge.json and takes effect after the connected bridge restarts.",
173
+ "Environment variables remain higher-priority operator overrides.",
174
+ "",
175
+ "The smoke command builds the repository Dockerfile, runs a disposable hardened",
176
+ "container, verifies exact-label teardown, and writes a redacted JSON report.",
177
+ "The optional port probe publishes 127.0.0.1::8080 only for the disposable fixture",
178
+ "and verifies Docker-assigned host-port discovery; normal sandboxes publish no ports.",
179
+ "Stop the connected bridge first so the Docker ownership lock can be acquired.",
180
+ "",
181
+ ].join("\n");
136
182
  case "run-once":
137
183
  return [
138
184
  "Forge Bridge — run-once",
@@ -1,12 +1,15 @@
1
1
  import type { BridgeService } from "../bridge/bridge.base.js";
2
2
  import type { BridgeServiceConfig } from "../bridge/bridge.types.js";
3
+ import type { CleanupMode } from "../cleanup/cleanup.types.js";
4
+ import type { CleanupService } from "../cleanup/cleanup.base.js";
3
5
  import type { SessionService } from "../session/session.base.js";
4
6
  import type { CliIo, CliService } from "./cli.base.js";
5
7
  export declare class ForgeBridgeCliService implements CliService {
6
8
  private readonly sessions;
7
9
  private readonly bridgeFactory;
8
10
  private readonly pairRunner;
9
- constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService, pairRunner?: PairRunnerClient);
11
+ private readonly cleanupFactory;
12
+ constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService, pairRunner?: PairRunnerClient, cleanupFactory?: CleanupServiceFactory);
10
13
  run(argv: readonly string[], io?: CliIo): Promise<number>;
11
14
  }
12
15
  export declare function parseArgs(argv: readonly string[]): {
@@ -34,6 +37,14 @@ export declare function parseArgs(argv: readonly string[]): {
34
37
  readonly ok: true;
35
38
  readonly command: "remove-repo";
36
39
  readonly label: string;
40
+ } | {
41
+ readonly ok: true;
42
+ readonly command: "server-show";
43
+ readonly config: BridgeServiceConfig;
44
+ } | {
45
+ readonly ok: true;
46
+ readonly command: "server-set";
47
+ readonly serverUrl: string;
37
48
  } | {
38
49
  readonly ok: true;
39
50
  readonly command: "status";
@@ -45,6 +56,35 @@ export declare function parseArgs(argv: readonly string[]): {
45
56
  readonly config: BridgeServiceConfig;
46
57
  readonly mode: CleanupMode;
47
58
  readonly force: boolean;
59
+ } | {
60
+ readonly ok: true;
61
+ readonly command: "sandbox-init";
62
+ readonly repoPath: string;
63
+ } | {
64
+ readonly ok: true;
65
+ readonly command: "sandbox-doctor";
66
+ readonly repoPath: string;
67
+ readonly requirePinnedBase: boolean;
68
+ } | {
69
+ readonly ok: true;
70
+ readonly command: "sandbox-smoke";
71
+ readonly repoPath: string;
72
+ readonly reportPath?: string;
73
+ readonly requirePinnedBase: boolean;
74
+ readonly probeLoopbackPort: boolean;
75
+ } | {
76
+ readonly ok: true;
77
+ readonly command: "sandbox-reap";
78
+ } | {
79
+ readonly ok: true;
80
+ readonly command: "sandbox-on";
81
+ readonly allowOpenPublic: boolean;
82
+ } | {
83
+ readonly ok: true;
84
+ readonly command: "sandbox-off";
85
+ } | {
86
+ readonly ok: true;
87
+ readonly command: "sandbox-status";
48
88
  } | {
49
89
  readonly ok: false;
50
90
  readonly error: string;
@@ -66,5 +106,5 @@ interface PairRunnerClaim {
66
106
  type PairRunnerClient = (args: PairArgs) => Promise<PairRunnerClaim>;
67
107
  export declare function parseCodexModelCatalog(raw: string): string[];
68
108
  export declare function parseCursorModelList(raw: string): string[];
69
- type CleanupMode = "stale" | "merged" | "all";
109
+ type CleanupServiceFactory = (stateRoot: string) => CleanupService;
70
110
  export {};
@@ -6,11 +6,18 @@ 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
8
  import { buildWindowsCmdLaunch } from "../shared/process/windows-launch.js";
9
- import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
9
+ import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
10
+ import { redactText } from "../shared/redaction/redaction.js";
10
11
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
11
12
  import { forgeBridgeVersion } from "../version.js";
12
- 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";
13
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";
14
21
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
15
22
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
16
23
  import { commandHelpText, helpText } from "./cli.help.js";
@@ -20,10 +27,12 @@ export class ForgeBridgeCliService {
20
27
  sessions;
21
28
  bridgeFactory;
22
29
  pairRunner;
23
- 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)) {
24
32
  this.sessions = sessions;
25
33
  this.bridgeFactory = bridgeFactory;
26
34
  this.pairRunner = pairRunner;
35
+ this.cleanupFactory = cleanupFactory;
27
36
  }
28
37
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
29
38
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
@@ -88,6 +97,7 @@ export class ForgeBridgeCliService {
88
97
  if (args.command === "pair") {
89
98
  try {
90
99
  const current = readLocalConfigSync();
100
+ io.stdout.write(`Pairing with server: ${args.serverUrl}\n`);
91
101
  const code = await resolvePairingCode(args.code);
92
102
  const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
93
103
  await fs.mkdir(repoRoot, { recursive: true });
@@ -117,12 +127,66 @@ export class ForgeBridgeCliService {
117
127
  return 2;
118
128
  }
119
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
+ }
120
156
  if (args.command === "status") {
121
157
  await writeStatus(args.config, args.checkUpdates, io);
122
158
  return 0;
123
159
  }
124
160
  if (args.command === "cleanup") {
125
- 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;
126
190
  }
127
191
  if (args.command === "mcp") {
128
192
  const parsed = parseCompletionMcpArgs(args.argv);
@@ -142,12 +206,12 @@ export class ForgeBridgeCliService {
142
206
  emit: async (event) => {
143
207
  const line = {
144
208
  type: "runner_event",
145
- event: redactSecrets(event, redactionSecrets)
209
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
146
210
  };
147
211
  io.stdout.write(`${JSON.stringify(line)}\n`);
148
212
  }
149
213
  });
150
- await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
214
+ await writeJsonAtomic(args.resultPath, redactRunOnceResultPacket(result, redactionSecrets));
151
215
  return result.exitCode;
152
216
  }
153
217
  catch (error) {
@@ -233,6 +297,15 @@ export function parseArgs(argv) {
233
297
  }
234
298
  return { ok: true, command: "remove-repo", label: argv[1] };
235
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
+ }
236
309
  if (argv[0] === "status") {
237
310
  let checkUpdates = false;
238
311
  for (let index = 1; index < argv.length; index += 1) {
@@ -252,6 +325,9 @@ export function parseArgs(argv) {
252
325
  if (argv[0] === "mcp") {
253
326
  return { ok: true, command: "mcp", argv };
254
327
  }
328
+ if (argv[0] === "sandbox") {
329
+ return parseSandboxArgs(argv);
330
+ }
255
331
  if (argv[0] !== "run-once") {
256
332
  return {
257
333
  ok: false,
@@ -282,6 +358,230 @@ export function parseArgs(argv) {
282
358
  resultPath: path.resolve(resultPath)
283
359
  };
284
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
+ }
285
585
  function parseConnectArgs(argv) {
286
586
  let serverUrl = null;
287
587
  let runnerId = null;
@@ -470,7 +770,7 @@ async function claimBridgeRunner(args) {
470
770
  })
471
771
  });
472
772
  if (!response.ok) {
473
- throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
773
+ throw new Error(formatPairingFailure(response.status, args.serverUrl, await response.text()));
474
774
  }
475
775
  const value = await response.json();
476
776
  if (!value.runnerId || !value.deviceToken) {
@@ -500,7 +800,7 @@ function pairedConfig(current, args, claim) {
500
800
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
501
801
  supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
502
802
  supportsDraftAttachments: current.supportsDraftAttachments ?? true,
503
- supportsDraftImageAttachments: current.supportsDraftImageAttachments ?? false,
803
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(args.cliProviders),
504
804
  repoMappings: current.repoMappings ?? []
505
805
  };
506
806
  }
@@ -545,6 +845,12 @@ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
545
845
  function normalizePairingCode(code) {
546
846
  return code?.replace(/[\s-]/g, "").trim() ?? "";
547
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
+ }
548
854
  function normalizeServerUrl(serverUrl) {
549
855
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
550
856
  }
@@ -563,11 +869,27 @@ function connectStartupText(config) {
563
869
  ` repo folder: ${config.defaultRepoRoot || "not configured"}`,
564
870
  ` providers: ${formatList(config.cliProviders)}`,
565
871
  ` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
872
+ ` open public sandbox networking: ${config.allowOpenPublicSandbox ? "enabled" : "disabled"}`,
566
873
  "",
567
874
  "Press Ctrl+C to stop.",
568
875
  "",
569
876
  ].join("\n");
570
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
+ }
571
893
  function formatList(values) {
572
894
  return values.length > 0 ? values.join(", ") : "none";
573
895
  }
@@ -604,7 +926,7 @@ async function writeStatus(config, checkUpdates, io) {
604
926
  io.stdout.write("Update check: no update channel configured.\n");
605
927
  }
606
928
  }
607
- async function cleanupRepos(config, mode, force, io) {
929
+ async function cleanupRepos(config, mode, force, cleanup, io) {
608
930
  const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
609
931
  if (repos.length === 0) {
610
932
  io.stdout.write("No repos configured.\n");
@@ -613,20 +935,28 @@ async function cleanupRepos(config, mode, force, io) {
613
935
  let failures = 0;
614
936
  for (const repo of repos) {
615
937
  const repoPath = path.resolve(repo.localPath);
616
- const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
617
- if (!force) {
618
- const action = mode === "all" ? "remove" : "prune";
619
- io.stdout.write(`[dry-run] Would ${action} ${worktreeRoot}\n`);
620
- continue;
621
- }
622
938
  try {
623
- if (mode === "all") {
624
- await fs.rm(worktreeRoot, { recursive: true, force: true });
625
- await runGit(repoPath, ["worktree", "prune"]);
626
- 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`);
627
958
  }
628
959
  else {
629
- await runGit(repoPath, ["worktree", "prune"]);
630
960
  io.stdout.write(`Pruned stale worktree metadata for ${repoPath}\n`);
631
961
  }
632
962
  }
@@ -824,6 +1154,3 @@ function uniqueProviders(providers) {
824
1154
  .filter((provider) => provider.length > 0)
825
1155
  .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
826
1156
  }
827
- async function runGit(cwd, args) {
828
- await execFileAsync("git", args, { cwd, timeout: 10_000 });
829
- }
@@ -7,6 +7,8 @@ export interface BridgeLocalConfig {
7
7
  readonly deviceName?: string;
8
8
  readonly defaultRepoRoot?: string;
9
9
  readonly cleanupWorktrees?: boolean;
10
+ readonly dockerEnabled?: boolean;
11
+ readonly allowOpenPublicSandbox?: boolean;
10
12
  readonly cliProviders?: readonly string[];
11
13
  readonly providerStatuses?: Readonly<Record<string, BridgeProviderStatus>>;
12
14
  readonly projectLabels?: readonly string[];
@@ -34,4 +36,6 @@ export declare function removeRepoMapping(label: string): Promise<{
34
36
  readonly config: BridgeLocalConfig;
35
37
  readonly removed: boolean;
36
38
  }>;
39
+ export declare function setServerUrl(serverUrl: string): Promise<BridgeLocalConfig>;
40
+ export declare function setSandboxPolicy(dockerEnabled: boolean, allowOpenPublicSandbox: boolean): Promise<BridgeLocalConfig>;
37
41
  export declare function providerStatusesFor(providers: readonly string[]): Readonly<Record<string, BridgeProviderStatus>>;