@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

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 +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -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 +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -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 +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  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 +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -16,10 +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",
22
- " sandbox * Initialize, diagnose, or reap Docker sandboxes",
23
+ " sandbox * Initialize, diagnose, smoke-test, or reap Docker sandboxes",
23
24
  " version * Print the installed bridge package version",
24
25
  " help * Display help for command",
25
26
  " run-once * Internal: run one dispatch packet from JSON",
@@ -30,6 +31,7 @@ export function helpText() {
30
31
  " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
31
32
  " forge-bridge connect",
32
33
  " forge-bridge status --check-updates",
34
+ " forge-bridge server set http://localhost:5208",
33
35
  " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
34
36
  " forge-bridge cleanup --stale",
35
37
  "",
@@ -100,6 +102,23 @@ export function commandHelpText(command) {
100
102
  " forge-bridge status --check-updates",
101
103
  "",
102
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");
103
122
  case "add-repo":
104
123
  return [
105
124
  "Forge Bridge — add-repo",
@@ -137,13 +156,28 @@ export function commandHelpText(command) {
137
156
  case "sandbox":
138
157
  return [
139
158
  "Forge Bridge — sandbox",
140
- "Initialize or diagnose a repository-owned Forge sandbox contract.",
159
+ "Configure local Docker sandbox placement or inspect a repository-owned sandbox contract.",
141
160
  "",
142
161
  "Usage:",
162
+ " forge-bridge sandbox on [--allow-open-public]",
163
+ " forge-bridge sandbox off",
164
+ " forge-bridge sandbox status",
143
165
  " forge-bridge sandbox init [--repo <path>]",
144
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]",
145
168
  " forge-bridge sandbox reap",
146
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
+ "",
147
181
  ].join("\n");
148
182
  case "run-once":
149
183
  return [
@@ -160,7 +194,7 @@ export function commandHelpText(command) {
160
194
  "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
161
195
  "",
162
196
  "Usage:",
163
- " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
197
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase> # includes ci-repair",
164
198
  "",
165
199
  ].join("\n");
166
200
  case "version":
@@ -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";
@@ -54,9 +65,26 @@ export declare function parseArgs(argv: readonly string[]): {
54
65
  readonly command: "sandbox-doctor";
55
66
  readonly repoPath: string;
56
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;
57
75
  } | {
58
76
  readonly ok: true;
59
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";
60
88
  } | {
61
89
  readonly ok: false;
62
90
  readonly error: string;
@@ -78,5 +106,5 @@ interface PairRunnerClaim {
78
106
  type PairRunnerClient = (args: PairArgs) => Promise<PairRunnerClaim>;
79
107
  export declare function parseCodexModelCatalog(raw: string): string[];
80
108
  export declare function parseCursorModelList(raw: string): string[];
81
- type CleanupMode = "stale" | "merged" | "all";
109
+ type CleanupServiceFactory = (stateRoot: string) => CleanupService;
82
110
  export {};
@@ -7,14 +7,17 @@ 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
9
  import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
10
- import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
10
+ import { redactText } from "../shared/redaction/redaction.js";
11
11
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
12
12
  import { forgeBridgeVersion } from "../version.js";
13
- 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";
14
15
  import { BridgeSessionService } from "../session/session.service.js";
16
+ import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
15
17
  import { validateDockerfilePolicy, validateSandboxContract } from "../sandbox/contract.js";
16
18
  import { DockerPreflight } from "../sandbox/preflight.js";
17
19
  import { DockerSandboxRuntime } from "../sandbox/runtime.js";
20
+ import { DockerSandboxSmoke } from "../sandbox/smoke.js";
18
21
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
19
22
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
20
23
  import { commandHelpText, helpText } from "./cli.help.js";
@@ -24,10 +27,12 @@ export class ForgeBridgeCliService {
24
27
  sessions;
25
28
  bridgeFactory;
26
29
  pairRunner;
27
- 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)) {
28
32
  this.sessions = sessions;
29
33
  this.bridgeFactory = bridgeFactory;
30
34
  this.pairRunner = pairRunner;
35
+ this.cleanupFactory = cleanupFactory;
31
36
  }
32
37
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
33
38
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
@@ -122,12 +127,38 @@ export class ForgeBridgeCliService {
122
127
  return 2;
123
128
  }
124
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
+ }
125
156
  if (args.command === "status") {
126
157
  await writeStatus(args.config, args.checkUpdates, io);
127
158
  return 0;
128
159
  }
129
160
  if (args.command === "cleanup") {
130
- 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);
131
162
  }
132
163
  if (args.command === "sandbox-init") {
133
164
  return await initializeSandbox(args.repoPath, io);
@@ -135,9 +166,28 @@ export class ForgeBridgeCliService {
135
166
  if (args.command === "sandbox-doctor") {
136
167
  return await diagnoseSandbox(args.repoPath, args.requirePinnedBase, io);
137
168
  }
169
+ if (args.command === "sandbox-smoke") {
170
+ return await smokeSandbox(args.repoPath, args.reportPath, args.requirePinnedBase, args.probeLoopbackPort, io);
171
+ }
138
172
  if (args.command === "sandbox-reap") {
139
173
  return await reapSandboxes(io);
140
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;
190
+ }
141
191
  if (args.command === "mcp") {
142
192
  const parsed = parseCompletionMcpArgs(args.argv);
143
193
  if (!parsed.ok) {
@@ -156,12 +206,12 @@ export class ForgeBridgeCliService {
156
206
  emit: async (event) => {
157
207
  const line = {
158
208
  type: "runner_event",
159
- event: redactSecrets(event, redactionSecrets)
209
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
160
210
  };
161
211
  io.stdout.write(`${JSON.stringify(line)}\n`);
162
212
  }
163
213
  });
164
- await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
214
+ await writeJsonAtomic(args.resultPath, redactRunOnceResultPacket(result, redactionSecrets));
165
215
  return result.exitCode;
166
216
  }
167
217
  catch (error) {
@@ -247,6 +297,15 @@ export function parseArgs(argv) {
247
297
  }
248
298
  return { ok: true, command: "remove-repo", label: argv[1] };
249
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
+ }
250
309
  if (argv[0] === "status") {
251
310
  let checkUpdates = false;
252
311
  for (let index = 1; index < argv.length; index += 1) {
@@ -301,8 +360,26 @@ export function parseArgs(argv) {
301
360
  }
302
361
  function parseSandboxArgs(argv) {
303
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" };
304
379
  let repoPath = process.cwd();
380
+ let reportPath;
305
381
  let requirePinnedBase = false;
382
+ let probeLoopbackPort = false;
306
383
  for (let index = 2; index < argv.length; index += 1) {
307
384
  const arg = argv[index];
308
385
  if (arg === "--repo") {
@@ -310,9 +387,17 @@ function parseSandboxArgs(argv) {
310
387
  if (!repoPath)
311
388
  return { ok: false, error: "--repo requires a path." };
312
389
  }
313
- else if (arg === "--require-pinned-base" && action === "doctor") {
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")) {
314
396
  requirePinnedBase = true;
315
397
  }
398
+ else if (arg === "--probe-loopback-port" && action === "smoke") {
399
+ probeLoopbackPort = true;
400
+ }
316
401
  else {
317
402
  return { ok: false, error: `Unknown sandbox argument: ${arg}` };
318
403
  }
@@ -321,9 +406,18 @@ function parseSandboxArgs(argv) {
321
406
  return { ok: true, command: "sandbox-init", repoPath: path.resolve(repoPath) };
322
407
  if (action === "doctor")
323
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
+ };
324
418
  if (action === "reap" && argv.length === 2)
325
419
  return { ok: true, command: "sandbox-reap" };
326
- return { ok: false, error: "Usage: forge-bridge sandbox <init|doctor|reap> [--repo <path>] [--require-pinned-base]" };
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]" };
327
421
  }
328
422
  const sandboxContractTemplate = `schemaVersion: 1
329
423
  workingDirectory: /workspace
@@ -389,6 +483,10 @@ async function diagnoseSandbox(repoPath, requirePinnedBase, io) {
389
483
  diskHighWatermarkPercent: config.diskHighWatermarkPercent,
390
484
  diskLowWatermarkPercent: config.diskLowWatermarkPercent,
391
485
  minimumWorkspaceBytes: config.minimumWorkspaceBytes,
486
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
487
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
488
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
489
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
392
490
  requireRootless: config.requireRootlessDocker
393
491
  });
394
492
  try {
@@ -409,6 +507,42 @@ async function diagnoseSandbox(repoPath, requirePinnedBase, io) {
409
507
  return 1;
410
508
  }
411
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
+ }
412
546
  async function reapSandboxes(io) {
413
547
  const config = defaultBridgeServiceConfig();
414
548
  if (!/^[0-9a-f-]{32,36}$/iu.test(config.runnerId)) {
@@ -423,6 +557,10 @@ async function reapSandboxes(io) {
423
557
  diskHighWatermarkPercent: config.diskHighWatermarkPercent,
424
558
  diskLowWatermarkPercent: config.diskLowWatermarkPercent,
425
559
  minimumWorkspaceBytes: config.minimumWorkspaceBytes,
560
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
561
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
562
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
563
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
426
564
  requireRootless: config.requireRootlessDocker
427
565
  });
428
566
  try {
@@ -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
  }
@@ -992,6 +1154,3 @@ function uniqueProviders(providers) {
992
1154
  .filter((provider) => provider.length > 0)
993
1155
  .filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
994
1156
  }
995
- async function runGit(cwd, args) {
996
- await execFileAsync("git", args, { cwd, timeout: 10_000 });
997
- }
@@ -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>>;
@@ -45,6 +45,12 @@ export function applyLocalConfigDefaults(config, local) {
45
45
  deviceName: process.env.FORGE_DEVICE_NAME ?? local.deviceName ?? config.deviceName,
46
46
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? local.defaultRepoRoot ?? config.defaultRepoRoot,
47
47
  cleanupWorktrees: local.cleanupWorktrees ?? config.cleanupWorktrees,
48
+ dockerEnabled: process.env.FORGE_DOCKER_SANDBOX === undefined
49
+ ? local.dockerEnabled ?? config.dockerEnabled
50
+ : config.dockerEnabled,
51
+ allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === undefined
52
+ ? local.allowOpenPublicSandbox ?? config.allowOpenPublicSandbox
53
+ : config.allowOpenPublicSandbox,
48
54
  cliProviders,
49
55
  providerStatuses: normalizeProviderStatuses(local.providerStatuses) ?? providerStatusesFor(cliProviders),
50
56
  projectLabels: local.projectLabels ?? config.projectLabels,
@@ -88,6 +94,19 @@ export async function removeRepoMapping(label) {
88
94
  await writeLocalConfig(next);
89
95
  return { config: next, removed: after.length !== before.length };
90
96
  }
97
+ export async function setServerUrl(serverUrl) {
98
+ const normalized = normalizeServerUrl(serverUrl);
99
+ const current = await readLocalConfig();
100
+ const next = normalizeLocalConfig({ ...current, serverUrl: normalized });
101
+ await writeLocalConfig(next);
102
+ return next;
103
+ }
104
+ export async function setSandboxPolicy(dockerEnabled, allowOpenPublicSandbox) {
105
+ const current = await readLocalConfig();
106
+ const next = normalizeLocalConfig({ ...current, dockerEnabled, allowOpenPublicSandbox });
107
+ await writeLocalConfig(next);
108
+ return next;
109
+ }
91
110
  export function providerStatusesFor(providers) {
92
111
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
93
112
  }
@@ -106,6 +125,8 @@ function normalizeLocalConfig(config) {
106
125
  deviceName: cleanString(config.deviceName),
107
126
  defaultRepoRoot: cleanString(config.defaultRepoRoot),
108
127
  cleanupWorktrees: config.cleanupWorktrees,
128
+ dockerEnabled: config.dockerEnabled,
129
+ allowOpenPublicSandbox: config.allowOpenPublicSandbox,
109
130
  cliProviders: cleanList(config.cliProviders),
110
131
  providerStatuses: normalizeProviderStatuses(config.providerStatuses),
111
132
  projectLabels: cleanList(config.projectLabels),
@@ -200,10 +221,11 @@ function normalizeQaAppLaunchTarget(value) {
200
221
  command: cleanString(value.command),
201
222
  working_directory: cleanString(value.working_directory),
202
223
  ready_url: cleanString(value.ready_url),
224
+ managed_runtime_port: cleanString(value.managed_runtime_port),
203
225
  routes,
204
226
  notes: cleanString(value.notes)
205
227
  };
206
- return normalized.url || normalized.command || normalized.ready_url || routes?.length
228
+ return normalized.url || normalized.command || normalized.ready_url || normalized.managed_runtime_port
207
229
  ? normalized
208
230
  : undefined;
209
231
  }
@@ -211,6 +233,24 @@ function cleanString(value) {
211
233
  const clean = value?.trim();
212
234
  return clean ? clean : undefined;
213
235
  }
236
+ function normalizeServerUrl(value) {
237
+ const clean = value.trim();
238
+ let parsed;
239
+ try {
240
+ parsed = new URL(clean);
241
+ }
242
+ catch {
243
+ throw new Error(`Invalid Forge server URL: ${value}`);
244
+ }
245
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
246
+ throw new Error("Forge server URL must use http or https.");
247
+ }
248
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
249
+ throw new Error("Forge server URL cannot include credentials, a query string, or a fragment.");
250
+ }
251
+ parsed.pathname = parsed.pathname.replace(/\/+$/u, "") || "/";
252
+ return parsed.toString().replace(/\/$/u, "");
253
+ }
214
254
  function cleanList(values) {
215
255
  const clean = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
216
256
  return clean.length > 0 ? clean : undefined;
@@ -0,0 +1,17 @@
1
+ import type { AttemptProcessChildHandle, AttemptProcessSupervisor } from "./attempt-process-supervisor.js";
2
+ import type { HostPortLeaseOwner } from "./host-port-broker.js";
3
+ import { HostListenerInspector } from "./host-listener-inspector.js";
4
+ import type { HostListenerOwnership, NodeRuntimeCommandLauncher, NodeRuntimeLaunchRequest, NodeRuntimeReadinessDiagnostics } from "./node-host-runtime-adapter.js";
5
+ /** Launches one explicitly configured HTTP project beneath the attempt coordinator. */
6
+ export declare class AttemptNodeRuntimeLauncher implements NodeRuntimeCommandLauncher<AttemptProcessChildHandle> {
7
+ private readonly supervisor;
8
+ private readonly owner;
9
+ private readonly logDirectory;
10
+ private readonly listenerInspector;
11
+ private readonly diagnosticsByChild;
12
+ constructor(supervisor: AttemptProcessSupervisor, owner: HostPortLeaseOwner, logDirectory: string, listenerInspector?: HostListenerInspector);
13
+ launch(request: NodeRuntimeLaunchRequest, signal?: AbortSignal): Promise<AttemptProcessChildHandle>;
14
+ inspectListener(child: AttemptProcessChildHandle, request: NodeRuntimeLaunchRequest): Promise<HostListenerOwnership>;
15
+ stop(child: AttemptProcessChildHandle): Promise<void>;
16
+ readinessDiagnostics(child: AttemptProcessChildHandle): Promise<NodeRuntimeReadinessDiagnostics>;
17
+ }