@loucompanion/forge-bridge 0.1.3-dev.bd0c67bbf352 → 0.1.3-dev.bf78fdcaa623

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 (88) hide show
  1. package/README.md +44 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +36 -2
  4. package/dist/bridge/bridge.service.js +588 -18
  5. package/dist/bridge/bridge.types.d.ts +114 -1
  6. package/dist/cli/cli.help.d.ts +2 -0
  7. package/dist/cli/cli.help.js +183 -0
  8. package/dist/cli/cli.service.d.ts +12 -0
  9. package/dist/cli/cli.service.js +176 -184
  10. package/dist/cli/local-config.d.ts +2 -0
  11. package/dist/cli/local-config.js +6 -2
  12. package/dist/sandbox/command-runner.d.ts +4 -0
  13. package/dist/sandbox/command-runner.js +47 -0
  14. package/dist/sandbox/contract.d.ts +5 -0
  15. package/dist/sandbox/contract.js +276 -0
  16. package/dist/sandbox/event-budget.d.ts +11 -0
  17. package/dist/sandbox/event-budget.js +27 -0
  18. package/dist/sandbox/image-policy.d.ts +33 -0
  19. package/dist/sandbox/image-policy.js +245 -0
  20. package/dist/sandbox/index.d.ts +5 -0
  21. package/dist/sandbox/index.js +5 -0
  22. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  23. package/dist/sandbox/lease-watchdog.js +67 -0
  24. package/dist/sandbox/preflight.d.ts +36 -0
  25. package/dist/sandbox/preflight.js +239 -0
  26. package/dist/sandbox/process.d.ts +20 -0
  27. package/dist/sandbox/process.js +124 -0
  28. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  29. package/dist/sandbox/provider-auth/codex.js +212 -0
  30. package/dist/sandbox/repository-lock.d.ts +5 -0
  31. package/dist/sandbox/repository-lock.js +83 -0
  32. package/dist/sandbox/runtime.d.ts +216 -0
  33. package/dist/sandbox/runtime.js +1658 -0
  34. package/dist/sandbox/sandbox.types.d.ts +83 -0
  35. package/dist/sandbox/sandbox.types.js +1 -0
  36. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  37. package/dist/sandbox/worker-supervisor.js +36 -0
  38. package/dist/sandbox/worker.d.ts +1 -0
  39. package/dist/sandbox/worker.js +228 -0
  40. package/dist/sandbox/workspace.service.d.ts +12 -0
  41. package/dist/sandbox/workspace.service.js +49 -0
  42. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  43. package/dist/session/phase-completion.artifacts.js +620 -0
  44. package/dist/session/phase-completion.d.ts +32 -44
  45. package/dist/session/phase-completion.helpers.d.ts +112 -0
  46. package/dist/session/phase-completion.helpers.js +660 -0
  47. package/dist/session/phase-completion.js +28 -3501
  48. package/dist/session/phase-completion.prompts.d.ts +14 -0
  49. package/dist/session/phase-completion.prompts.js +220 -0
  50. package/dist/session/phase-completion.runtime.d.ts +36 -0
  51. package/dist/session/phase-completion.runtime.js +609 -0
  52. package/dist/session/phase-completion.schemas.d.ts +3 -0
  53. package/dist/session/phase-completion.schemas.js +707 -0
  54. package/dist/session/phase-completion.validation.d.ts +34 -0
  55. package/dist/session/phase-completion.validation.js +724 -0
  56. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  57. package/dist/session/provider-cli/provider-cli.artifacts.js +754 -0
  58. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  59. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  60. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  61. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  62. package/dist/session/provider-cli/provider-cli.deterministic.js +329 -0
  63. package/dist/session/provider-cli/provider-cli.process.d.ts +32 -0
  64. package/dist/session/provider-cli/provider-cli.process.js +287 -0
  65. package/dist/session/provider-cli/provider-cli.prompts.d.ts +44 -0
  66. package/dist/session/provider-cli/provider-cli.prompts.js +528 -0
  67. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  68. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +65 -0
  69. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  70. package/dist/session/provider-cli/provider-cli.runtime.js +40 -0
  71. package/dist/session/provider-cli/provider-cli.service.d.ts +9 -53
  72. package/dist/session/provider-cli/provider-cli.service.js +161 -2561
  73. package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
  74. package/dist/session/provider-cli/provider-output-state.d.ts +20 -0
  75. package/dist/session/provider-cli/provider-output-state.js +445 -0
  76. package/dist/session/session.service.js +34 -24
  77. package/dist/session/session.types.d.ts +89 -1
  78. package/dist/session/session.types.js +209 -3
  79. package/dist/shared/process/windows-launch.d.ts +8 -0
  80. package/dist/shared/process/windows-launch.js +41 -0
  81. package/dist/shared/provider-capabilities.d.ts +1 -0
  82. package/dist/shared/provider-capabilities.js +3 -0
  83. package/dist/shared/redaction/redaction.js +4 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/dist/worktree/dependency-warmer.d.ts +11 -0
  87. package/dist/worktree/dependency-warmer.js +45 -1
  88. package/package.json +4 -2
@@ -5,13 +5,19 @@ 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 { buildWindowsCmdLaunch } from "../shared/process/windows-launch.js";
9
+ import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
8
10
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
9
11
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
10
12
  import { forgeBridgeVersion } from "../version.js";
11
13
  import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
12
14
  import { BridgeSessionService } from "../session/session.service.js";
15
+ import { validateDockerfilePolicy, validateSandboxContract } from "../sandbox/contract.js";
16
+ import { DockerPreflight } from "../sandbox/preflight.js";
17
+ import { DockerSandboxRuntime } from "../sandbox/runtime.js";
13
18
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
14
19
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
20
+ import { commandHelpText, helpText } from "./cli.help.js";
15
21
  const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
16
22
  const execFileAsync = promisify(execFile);
17
23
  export class ForgeBridgeCliService {
@@ -86,6 +92,7 @@ export class ForgeBridgeCliService {
86
92
  if (args.command === "pair") {
87
93
  try {
88
94
  const current = readLocalConfigSync();
95
+ io.stdout.write(`Pairing with server: ${args.serverUrl}\n`);
89
96
  const code = await resolvePairingCode(args.code);
90
97
  const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
91
98
  await fs.mkdir(repoRoot, { recursive: true });
@@ -122,6 +129,15 @@ export class ForgeBridgeCliService {
122
129
  if (args.command === "cleanup") {
123
130
  return await cleanupRepos(args.config, args.mode, args.force, io);
124
131
  }
132
+ if (args.command === "sandbox-init") {
133
+ return await initializeSandbox(args.repoPath, io);
134
+ }
135
+ if (args.command === "sandbox-doctor") {
136
+ return await diagnoseSandbox(args.repoPath, args.requirePinnedBase, io);
137
+ }
138
+ if (args.command === "sandbox-reap") {
139
+ return await reapSandboxes(io);
140
+ }
125
141
  if (args.command === "mcp") {
126
142
  const parsed = parseCompletionMcpArgs(args.argv);
127
143
  if (!parsed.ok) {
@@ -250,10 +266,13 @@ export function parseArgs(argv) {
250
266
  if (argv[0] === "mcp") {
251
267
  return { ok: true, command: "mcp", argv };
252
268
  }
269
+ if (argv[0] === "sandbox") {
270
+ return parseSandboxArgs(argv);
271
+ }
253
272
  if (argv[0] !== "run-once") {
254
273
  return {
255
274
  ok: false,
256
- error: usage()
275
+ error: helpText()
257
276
  };
258
277
  }
259
278
  let dispatchPath = null;
@@ -280,6 +299,151 @@ export function parseArgs(argv) {
280
299
  resultPath: path.resolve(resultPath)
281
300
  };
282
301
  }
302
+ function parseSandboxArgs(argv) {
303
+ const action = argv[1];
304
+ let repoPath = process.cwd();
305
+ let requirePinnedBase = false;
306
+ for (let index = 2; index < argv.length; index += 1) {
307
+ const arg = argv[index];
308
+ if (arg === "--repo") {
309
+ repoPath = argv[++index] ?? "";
310
+ if (!repoPath)
311
+ return { ok: false, error: "--repo requires a path." };
312
+ }
313
+ else if (arg === "--require-pinned-base" && action === "doctor") {
314
+ requirePinnedBase = true;
315
+ }
316
+ else {
317
+ return { ok: false, error: `Unknown sandbox argument: ${arg}` };
318
+ }
319
+ }
320
+ if (action === "init")
321
+ return { ok: true, command: "sandbox-init", repoPath: path.resolve(repoPath) };
322
+ if (action === "doctor")
323
+ return { ok: true, command: "sandbox-doctor", repoPath: path.resolve(repoPath), requirePinnedBase };
324
+ if (action === "reap" && argv.length === 2)
325
+ return { ok: true, command: "sandbox-reap" };
326
+ return { ok: false, error: "Usage: forge-bridge sandbox <init|doctor|reap> [--repo <path>] [--require-pinned-base]" };
327
+ }
328
+ const sandboxContractTemplate = `schemaVersion: 1
329
+ workingDirectory: /workspace
330
+ commands:
331
+ install: npm ci
332
+ test: npm test
333
+ caches:
334
+ - name: npm
335
+ mode: readonly
336
+ services: []
337
+ environment: []
338
+ artifacts: []
339
+ logs: []
340
+ browser: false
341
+ `;
342
+ const sandboxDockerfileTemplate = `# Pin this base by digest before enabling sandbox_required.
343
+ FROM node:22.17.0-bookworm-slim
344
+ WORKDIR /workspace
345
+ `;
346
+ async function initializeSandbox(repoPath, io) {
347
+ const forgePath = path.join(repoPath, ".forge");
348
+ const files = [
349
+ ["forge.sandbox.yml", sandboxContractTemplate],
350
+ ["Dockerfile", sandboxDockerfileTemplate],
351
+ ["forge.env.example", "# List non-secret variable names in forge.sandbox.yml. Never commit secret values.\n"]
352
+ ];
353
+ await fs.mkdir(forgePath, { recursive: true });
354
+ let created = 0;
355
+ for (const [name, contents] of files) {
356
+ const target = path.join(forgePath, name);
357
+ try {
358
+ const handle = await fs.open(target, "wx", 0o600);
359
+ await handle.writeFile(contents);
360
+ await handle.close();
361
+ io.stdout.write(`Created ${path.relative(repoPath, target)}\n`);
362
+ created += 1;
363
+ }
364
+ catch (error) {
365
+ if (error.code !== "EEXIST")
366
+ throw error;
367
+ io.stdout.write(`Kept existing ${path.relative(repoPath, target)}\n`);
368
+ }
369
+ }
370
+ io.stdout.write(created > 0 ? "Sandbox scaffold is ready. Run `forge-bridge sandbox doctor`.\n" : "Sandbox scaffold already exists.\n");
371
+ return 0;
372
+ }
373
+ async function diagnoseSandbox(repoPath, requirePinnedBase, io) {
374
+ try {
375
+ const forgePath = path.join(repoPath, ".forge");
376
+ const contractSource = await fs.readFile(path.join(forgePath, "forge.sandbox.yml"), "utf8");
377
+ const dockerfile = await fs.readFile(path.join(forgePath, "Dockerfile"), "utf8");
378
+ const contract = validateSandboxContract(contractSource);
379
+ const warnings = validateDockerfilePolicy(dockerfile, requirePinnedBase);
380
+ io.stdout.write(`Contract: valid (${contract.digest})\n`);
381
+ for (const warning of warnings)
382
+ io.stdout.write(`Warning: ${warning}\n`);
383
+ const config = defaultBridgeServiceConfig();
384
+ const preflight = new DockerPreflight({
385
+ dockerExecutable: config.dockerExecutable,
386
+ dockerContext: config.dockerContext,
387
+ stateRoot: config.sandboxStateRoot,
388
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
389
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
390
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
391
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
392
+ requireRootless: config.requireRootlessDocker
393
+ });
394
+ try {
395
+ const result = await preflight.refresh();
396
+ if (!result.healthy) {
397
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
398
+ return 1;
399
+ }
400
+ io.stdout.write(`Docker: healthy (${result.runtimeVersion}; capacity ${result.maxConcurrentSandboxes})\n`);
401
+ return 0;
402
+ }
403
+ finally {
404
+ await preflight.release();
405
+ }
406
+ }
407
+ catch (error) {
408
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
409
+ return 1;
410
+ }
411
+ }
412
+ async function reapSandboxes(io) {
413
+ const config = defaultBridgeServiceConfig();
414
+ if (!/^[0-9a-f-]{32,36}$/iu.test(config.runnerId)) {
415
+ io.stderr.write("FORGE_RUNNER_ID must identify the runner whose exact-labeled resources may be reaped.\n");
416
+ return 2;
417
+ }
418
+ const preflight = new DockerPreflight({
419
+ dockerExecutable: config.dockerExecutable,
420
+ dockerContext: config.dockerContext,
421
+ stateRoot: config.sandboxStateRoot,
422
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
423
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
424
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
425
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
426
+ requireRootless: config.requireRootlessDocker
427
+ });
428
+ try {
429
+ const result = await preflight.refresh();
430
+ if (!result.healthy && result.unavailableCode !== "docker_disk_low") {
431
+ io.stderr.write(`Docker: unavailable (${result.unavailableCode ?? "preflight_failed"}): ${result.unavailableReason ?? "unknown reason"}\n`);
432
+ return 1;
433
+ }
434
+ const runtime = new DockerSandboxRuntime(config);
435
+ await runtime.reap({ diskLow: result.diskLow });
436
+ io.stdout.write(`Sandbox reaper completed for runner ${config.runnerId}.\n`);
437
+ return 0;
438
+ }
439
+ catch (error) {
440
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
441
+ return 1;
442
+ }
443
+ finally {
444
+ await preflight.release();
445
+ }
446
+ }
283
447
  function parseConnectArgs(argv) {
284
448
  let serverUrl = null;
285
449
  let runnerId = null;
@@ -468,7 +632,7 @@ async function claimBridgeRunner(args) {
468
632
  })
469
633
  });
470
634
  if (!response.ok) {
471
- throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
635
+ throw new Error(formatPairingFailure(response.status, args.serverUrl, await response.text()));
472
636
  }
473
637
  const value = await response.json();
474
638
  if (!value.runnerId || !value.deviceToken) {
@@ -497,6 +661,8 @@ function pairedConfig(current, args, claim) {
497
661
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
498
662
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
499
663
  supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
664
+ supportsDraftAttachments: current.supportsDraftAttachments ?? true,
665
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(args.cliProviders),
500
666
  repoMappings: current.repoMappings ?? []
501
667
  };
502
668
  }
@@ -541,181 +707,15 @@ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
541
707
  function normalizePairingCode(code) {
542
708
  return code?.replace(/[\s-]/g, "").trim() ?? "";
543
709
  }
710
+ function formatPairingFailure(status, serverUrl, body) {
711
+ const hint = body.includes("runner.invalid_pairing_code")
712
+ ? " Make sure the code was generated by the same Forge server shown above."
713
+ : "";
714
+ return `Pairing failed (${status}) against ${serverUrl}: ${body}${hint}`;
715
+ }
544
716
  function normalizeServerUrl(serverUrl) {
545
717
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
546
718
  }
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
719
  function connectStartupText(config) {
720
720
  const runner = config.runnerId
721
721
  ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
@@ -862,17 +862,9 @@ async function execVersionCommand(probe, timeout = 2000) {
862
862
  if (process.platform !== "win32") {
863
863
  throw error;
864
864
  }
865
- return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
866
- }
867
- }
868
- function windowsCommandLine(probe) {
869
- return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
870
- }
871
- function quoteWindowsCommandLineArg(value) {
872
- if (value.length > 0 && !/[\s"]/.test(value)) {
873
- return value;
865
+ const launch = buildWindowsCmdLaunch(probe.command, probe.args);
866
+ return await execFileAsync(launch.command, [...launch.args], { timeout });
874
867
  }
875
- return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
876
868
  }
877
869
  function versionLine(output) {
878
870
  const lines = output
@@ -17,6 +17,8 @@ export interface BridgeLocalConfig {
17
17
  readonly supportsManagedForgeArtifacts?: boolean;
18
18
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean;
19
19
  readonly supportsDraftRunOnce?: boolean;
20
+ readonly supportsDraftAttachments?: boolean;
21
+ readonly supportsDraftImageAttachments?: boolean;
20
22
  }
21
23
  export interface LocalRepoMapping extends BridgeRepoMapping {
22
24
  readonly label: string;
@@ -54,7 +54,9 @@ export function applyLocalConfigDefaults(config, local) {
54
54
  supportsManagedWorktrees: local.supportsManagedWorktrees ?? config.supportsManagedWorktrees,
55
55
  supportsManagedForgeArtifacts: local.supportsManagedForgeArtifacts ?? config.supportsManagedForgeArtifacts,
56
56
  supportsRunnerAuthoredPhaseArtifacts: local.supportsRunnerAuthoredPhaseArtifacts ?? config.supportsRunnerAuthoredPhaseArtifacts,
57
- supportsDraftRunOnce: local.supportsDraftRunOnce ?? config.supportsDraftRunOnce
57
+ supportsDraftRunOnce: local.supportsDraftRunOnce ?? config.supportsDraftRunOnce,
58
+ supportsDraftAttachments: local.supportsDraftAttachments ?? config.supportsDraftAttachments,
59
+ supportsDraftImageAttachments: local.supportsDraftImageAttachments ?? config.supportsDraftImageAttachments
58
60
  };
59
61
  }
60
62
  export async function addRepoMapping(label, repoPath) {
@@ -113,7 +115,9 @@ function normalizeLocalConfig(config) {
113
115
  supportsManagedWorktrees: config.supportsManagedWorktrees,
114
116
  supportsManagedForgeArtifacts: config.supportsManagedForgeArtifacts,
115
117
  supportsRunnerAuthoredPhaseArtifacts: config.supportsRunnerAuthoredPhaseArtifacts,
116
- supportsDraftRunOnce: config.supportsDraftRunOnce
118
+ supportsDraftRunOnce: config.supportsDraftRunOnce,
119
+ supportsDraftAttachments: config.supportsDraftAttachments,
120
+ supportsDraftImageAttachments: config.supportsDraftImageAttachments
117
121
  };
118
122
  }
119
123
  function emptyConfig() {
@@ -0,0 +1,4 @@
1
+ import type { CommandOptions, CommandResult, CommandRunner } from "./sandbox.types.js";
2
+ export declare class BoundedCommandRunner implements CommandRunner {
3
+ run(command: string, args: readonly string[], options?: CommandOptions): Promise<CommandResult>;
4
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+ export class BoundedCommandRunner {
3
+ async run(command, args, options = {}) {
4
+ const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
5
+ return await new Promise((resolve, reject) => {
6
+ const controller = new AbortController();
7
+ const abort = () => controller.abort(options.signal?.reason ?? "command cancelled");
8
+ options.signal?.addEventListener("abort", abort, { once: true });
9
+ const timeout = options.timeoutMs
10
+ ? setTimeout(() => controller.abort(`command exceeded ${options.timeoutMs}ms`), options.timeoutMs)
11
+ : null;
12
+ timeout?.unref?.();
13
+ const child = spawn(command, [...args], {
14
+ cwd: options.cwd,
15
+ env: options.env ? { ...options.env } : undefined,
16
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
17
+ signal: controller.signal,
18
+ windowsHide: true,
19
+ });
20
+ let stdout = Buffer.alloc(0);
21
+ let stderr = Buffer.alloc(0);
22
+ const append = (current, chunk) => {
23
+ if (current.length >= maxOutputBytes)
24
+ return current;
25
+ return Buffer.concat([current, chunk.subarray(0, maxOutputBytes - current.length)]);
26
+ };
27
+ child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
28
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
29
+ child.on("error", (error) => {
30
+ cleanup();
31
+ reject(error);
32
+ });
33
+ child.on("close", (code) => {
34
+ cleanup();
35
+ resolve({ exitCode: code ?? 1, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") });
36
+ });
37
+ if (options.stdin !== undefined) {
38
+ child.stdin.end(options.stdin);
39
+ }
40
+ function cleanup() {
41
+ if (timeout)
42
+ clearTimeout(timeout);
43
+ options.signal?.removeEventListener("abort", abort);
44
+ }
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,5 @@
1
+ import type { ValidatedSandboxContract } from "./sandbox.types.js";
2
+ export declare function validateSandboxContract(source: string): ValidatedSandboxContract;
3
+ export declare function validateDockerfilePolicy(source: string, requirePinnedBase: boolean): readonly string[];
4
+ export declare function dockerfileBaseImages(source: string): readonly string[];
5
+ export declare function dockerfileUpstreamImages(source: string): readonly string[];