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

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 (64) 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 +40 -2
  4. package/dist/bridge/bridge.service.js +580 -16
  5. package/dist/bridge/bridge.types.d.ts +114 -1
  6. package/dist/cli/cli.help.js +12 -0
  7. package/dist/cli/cli.service.d.ts +12 -0
  8. package/dist/cli/cli.service.js +171 -1
  9. package/dist/cli/local-config.d.ts +2 -0
  10. package/dist/cli/local-config.js +6 -2
  11. package/dist/sandbox/command-runner.d.ts +4 -0
  12. package/dist/sandbox/command-runner.js +47 -0
  13. package/dist/sandbox/contract.d.ts +5 -0
  14. package/dist/sandbox/contract.js +276 -0
  15. package/dist/sandbox/event-budget.d.ts +11 -0
  16. package/dist/sandbox/event-budget.js +27 -0
  17. package/dist/sandbox/image-policy.d.ts +35 -0
  18. package/dist/sandbox/image-policy.js +247 -0
  19. package/dist/sandbox/index.d.ts +5 -0
  20. package/dist/sandbox/index.js +5 -0
  21. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  22. package/dist/sandbox/lease-watchdog.js +67 -0
  23. package/dist/sandbox/preflight.d.ts +40 -0
  24. package/dist/sandbox/preflight.js +256 -0
  25. package/dist/sandbox/process.d.ts +20 -0
  26. package/dist/sandbox/process.js +124 -0
  27. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  28. package/dist/sandbox/provider-auth/codex.js +212 -0
  29. package/dist/sandbox/repository-lock.d.ts +5 -0
  30. package/dist/sandbox/repository-lock.js +83 -0
  31. package/dist/sandbox/runtime.d.ts +234 -0
  32. package/dist/sandbox/runtime.js +1773 -0
  33. package/dist/sandbox/sandbox.types.d.ts +83 -0
  34. package/dist/sandbox/sandbox.types.js +1 -0
  35. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  36. package/dist/sandbox/worker-supervisor.js +36 -0
  37. package/dist/sandbox/worker.d.ts +1 -0
  38. package/dist/sandbox/worker.js +228 -0
  39. package/dist/sandbox/workspace.service.d.ts +12 -0
  40. package/dist/sandbox/workspace.service.js +49 -0
  41. package/dist/session/phase-completion.artifacts.js +23 -2
  42. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  43. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  44. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  45. package/dist/session/provider-cli/provider-cli.process.d.ts +1 -0
  46. package/dist/session/provider-cli/provider-cli.process.js +23 -1
  47. package/dist/session/provider-cli/provider-cli.prompts.d.ts +12 -1
  48. package/dist/session/provider-cli/provider-cli.prompts.js +119 -2
  49. package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
  50. package/dist/session/provider-cli/provider-cli.service.js +161 -43
  51. package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
  52. package/dist/session/session.service.js +34 -24
  53. package/dist/session/session.types.d.ts +52 -1
  54. package/dist/session/session.types.js +159 -3
  55. package/dist/shared/process/windows-launch.d.ts +1 -1
  56. package/dist/shared/process/windows-launch.js +6 -3
  57. package/dist/shared/provider-capabilities.d.ts +1 -0
  58. package/dist/shared/provider-capabilities.js +3 -0
  59. package/dist/shared/redaction/redaction.js +4 -2
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/dist/worktree/dependency-warmer.d.ts +11 -0
  63. package/dist/worktree/dependency-warmer.js +45 -1
  64. package/package.json +4 -2
@@ -1,7 +1,26 @@
1
- import type { RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
1
+ import type { ApiKeySecretGrant, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
2
+ import type { SandboxFeatureMatrix } from "../sandbox/sandbox.types.js";
2
3
  export interface BridgeServiceConfig {
3
4
  readonly serverUrl: string;
4
5
  readonly runnerId: string;
6
+ readonly runnerInstanceId: string;
7
+ readonly trustDomainId: string;
8
+ readonly maxConcurrentSandboxes: number;
9
+ readonly maxConcurrentSandboxBuilds: number;
10
+ readonly sandboxPidCapacity: number;
11
+ readonly dockerEnabled: boolean;
12
+ readonly sandboxPublicationMode?: "bridge_push" | "shared_filesystem";
13
+ readonly dockerExecutable: string;
14
+ readonly dockerContext: string;
15
+ readonly sandboxStateRoot: string;
16
+ readonly requireRootlessDocker: boolean;
17
+ readonly diskHighWatermarkPercent: number;
18
+ readonly diskLowWatermarkPercent: number;
19
+ readonly minimumWorkspaceBytes: number;
20
+ readonly imageRetentionCount: number;
21
+ readonly imageRetentionTtlMs: number;
22
+ readonly auditRetentionCount: number;
23
+ readonly auditRetentionTtlMs: number;
5
24
  readonly deviceToken: string;
6
25
  readonly deviceName: string;
7
26
  readonly defaultRepoRoot: string;
@@ -17,6 +36,8 @@ export interface BridgeServiceConfig {
17
36
  readonly supportsManagedForgeArtifacts: boolean;
18
37
  readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
19
38
  readonly supportsDraftRunOnce: boolean;
39
+ readonly supportsDraftAttachments: boolean;
40
+ readonly supportsDraftImageAttachments: boolean;
20
41
  readonly version: string;
21
42
  readonly protocolVersion: number;
22
43
  readonly minServerVersion?: string | null;
@@ -52,17 +73,51 @@ export interface BridgeCapabilities {
52
73
  readonly repoMappings?: readonly BridgeRepoMapping[] | null;
53
74
  readonly os?: string | null;
54
75
  readonly sandboxProfiles?: readonly string[] | null;
76
+ readonly runnerInstanceId?: string | null;
77
+ readonly trustDomainId?: string | null;
78
+ readonly sandboxRuntimes?: readonly string[] | null;
79
+ readonly sandboxRuntimeVersion?: string | null;
80
+ readonly sandboxFeatures?: SandboxFeatureMatrix | null;
81
+ readonly sandboxPublicationModes?: readonly string[] | null;
82
+ readonly sandboxAuthModes?: Readonly<Record<string, readonly string[]>> | null;
83
+ readonly maxConcurrentSandboxes?: number | null;
84
+ readonly reservedSandboxCount?: number | null;
85
+ readonly activeSandboxCount?: number | null;
86
+ readonly startingSandboxCount?: number | null;
87
+ readonly degradedReservationCount?: number | null;
88
+ readonly availableMemoryBytes?: number | null;
89
+ readonly availableCpuMillis?: number | null;
90
+ readonly availableWorkspaceBytes?: number | null;
91
+ readonly diskLow?: boolean | null;
92
+ readonly preflightGeneration?: number | null;
93
+ readonly sandboxUnavailableCode?: string | null;
94
+ readonly sandboxUnavailableReason?: string | null;
55
95
  readonly supportsApiSecretGrant?: boolean | null;
56
96
  readonly supportsManagedWorktrees?: boolean | null;
57
97
  readonly supportsManagedForgeArtifacts?: boolean | null;
58
98
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
59
99
  readonly supportsDraftRunOnce?: boolean | null;
100
+ readonly supportsDraftAttachments?: boolean | null;
101
+ readonly supportsDraftImageAttachments?: boolean | null;
60
102
  }
61
103
  export interface BridgeHelloRequest {
62
104
  readonly version: string;
63
105
  readonly protocolVersion: number;
64
106
  readonly capabilities: BridgeCapabilities;
65
107
  readonly minServerVersion?: string | null;
108
+ readonly runnerInstanceId: string;
109
+ }
110
+ export interface BridgePublishCredential {
111
+ readonly username: string;
112
+ readonly secret: string;
113
+ readonly remoteUrl: string;
114
+ readonly expiresAt: string;
115
+ }
116
+ export interface BridgePublishGrantRedemptionResponse {
117
+ readonly accepted: boolean;
118
+ readonly code?: string | null;
119
+ readonly message?: string | null;
120
+ readonly credential?: BridgePublishCredential | null;
66
121
  }
67
122
  export interface BridgeHeartbeatRequest {
68
123
  readonly status?: string | null;
@@ -78,17 +133,75 @@ export interface BridgeCallbackAck {
78
133
  export interface BridgeDispatchEventCallback {
79
134
  readonly leaseId: string;
80
135
  readonly fencingToken: string;
136
+ readonly dispatchAttemptId?: string | null;
137
+ readonly runnerInstanceId?: string | null;
81
138
  readonly event: RunnerDispatchEvent;
82
139
  }
83
140
  export interface BridgeDispatchResultCallback {
84
141
  readonly leaseId: string;
85
142
  readonly fencingToken: string;
143
+ readonly dispatchAttemptId?: string | null;
144
+ readonly runnerInstanceId?: string | null;
86
145
  readonly result: RunOnceResultPacket;
87
146
  }
147
+ export interface BridgeOfferDecisionCallback {
148
+ readonly dispatchAttemptId: string;
149
+ readonly leaseId: string;
150
+ readonly fencingToken: string;
151
+ readonly runnerInstanceId: string;
152
+ readonly accepted: boolean;
153
+ readonly code?: string | null;
154
+ readonly message?: string | null;
155
+ readonly reservedUntil?: string | null;
156
+ }
157
+ export interface BridgeDispatchStartedCallback {
158
+ readonly dispatchAttemptId: string;
159
+ readonly leaseId: string;
160
+ readonly fencingToken: string;
161
+ readonly runnerInstanceId: string;
162
+ }
163
+ export interface BridgeSecretGrantRedemptionRequest {
164
+ readonly dispatchAttemptId: string;
165
+ readonly leaseId: string;
166
+ readonly fencingToken: string;
167
+ readonly runnerInstanceId: string;
168
+ readonly grantId: string;
169
+ }
170
+ export interface BridgeSecretGrantRedemptionResponse extends BridgeCallbackAck {
171
+ readonly grant?: ApiKeySecretGrant | null;
172
+ }
173
+ export interface BridgeLeaseUpdateRequest {
174
+ readonly dispatchAttemptId: string;
175
+ readonly leaseId: string;
176
+ readonly fencingToken: string;
177
+ readonly runnerInstanceId: string;
178
+ readonly expiresAt: string;
179
+ }
88
180
  export interface BridgeCancelRequest {
89
181
  readonly leaseId: string;
90
182
  readonly fencingToken: string;
91
183
  readonly reason?: string | null;
92
184
  }
185
+ export interface BridgeReapRequest {
186
+ readonly runnerId: string;
187
+ readonly runnerInstanceId: string;
188
+ readonly requestedAt: string;
189
+ readonly reason?: string | null;
190
+ }
191
+ export interface BridgeAttemptSnapshotItem {
192
+ readonly dispatchAttemptId: string;
193
+ readonly fencingToken: string;
194
+ readonly runnerInstanceId: string;
195
+ readonly status: string;
196
+ readonly expiresAt: string;
197
+ }
198
+ export interface BridgeAttemptSnapshotResponse extends BridgeCallbackAck {
199
+ readonly attempts?: readonly BridgeAttemptSnapshotItem[] | null;
200
+ readonly generatedAt?: string | null;
201
+ }
93
202
  export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
203
+ export type BridgeOfferHandler = (dispatch: unknown) => Promise<void>;
204
+ export type BridgeStartHandler = (dispatch: unknown) => Promise<void>;
205
+ export type BridgeLeaseUpdateHandler = (request: BridgeLeaseUpdateRequest) => Promise<void>;
94
206
  export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;
207
+ export type BridgeReapHandler = (request: BridgeReapRequest) => Promise<void>;
@@ -19,6 +19,7 @@ export function helpText() {
19
19
  " add-repo * Map a Forge repo label/name to a local checkout path",
20
20
  " remove-repo * Remove a saved repo mapping",
21
21
  " cleanup * Prune managed bridge worktrees",
22
+ " sandbox * Initialize, diagnose, or reap Docker sandboxes",
22
23
  " version * Print the installed bridge package version",
23
24
  " help * Display help for command",
24
25
  " run-once * Internal: run one dispatch packet from JSON",
@@ -133,6 +134,17 @@ export function commandHelpText(command) {
133
134
  " forge-bridge cleanup --all --force",
134
135
  "",
135
136
  ].join("\n");
137
+ case "sandbox":
138
+ return [
139
+ "Forge Bridge — sandbox",
140
+ "Initialize or diagnose a repository-owned Forge sandbox contract.",
141
+ "",
142
+ "Usage:",
143
+ " forge-bridge sandbox init [--repo <path>]",
144
+ " forge-bridge sandbox doctor [--repo <path>] [--require-pinned-base]",
145
+ " forge-bridge sandbox reap",
146
+ "",
147
+ ].join("\n");
136
148
  case "run-once":
137
149
  return [
138
150
  "Forge Bridge — run-once",
@@ -45,6 +45,18 @@ export declare function parseArgs(argv: readonly string[]): {
45
45
  readonly config: BridgeServiceConfig;
46
46
  readonly mode: CleanupMode;
47
47
  readonly force: boolean;
48
+ } | {
49
+ readonly ok: true;
50
+ readonly command: "sandbox-init";
51
+ readonly repoPath: string;
52
+ } | {
53
+ readonly ok: true;
54
+ readonly command: "sandbox-doctor";
55
+ readonly repoPath: string;
56
+ readonly requirePinnedBase: boolean;
57
+ } | {
58
+ readonly ok: true;
59
+ readonly command: "sandbox-reap";
48
60
  } | {
49
61
  readonly ok: false;
50
62
  readonly error: string;
@@ -6,11 +6,15 @@ 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 { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
9
10
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
10
11
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
11
12
  import { forgeBridgeVersion } from "../version.js";
12
13
  import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
13
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";
14
18
  import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
15
19
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
16
20
  import { commandHelpText, helpText } from "./cli.help.js";
@@ -88,6 +92,7 @@ export class ForgeBridgeCliService {
88
92
  if (args.command === "pair") {
89
93
  try {
90
94
  const current = readLocalConfigSync();
95
+ io.stdout.write(`Pairing with server: ${args.serverUrl}\n`);
91
96
  const code = await resolvePairingCode(args.code);
92
97
  const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
93
98
  await fs.mkdir(repoRoot, { recursive: true });
@@ -124,6 +129,15 @@ export class ForgeBridgeCliService {
124
129
  if (args.command === "cleanup") {
125
130
  return await cleanupRepos(args.config, args.mode, args.force, io);
126
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
+ }
127
141
  if (args.command === "mcp") {
128
142
  const parsed = parseCompletionMcpArgs(args.argv);
129
143
  if (!parsed.ok) {
@@ -252,6 +266,9 @@ export function parseArgs(argv) {
252
266
  if (argv[0] === "mcp") {
253
267
  return { ok: true, command: "mcp", argv };
254
268
  }
269
+ if (argv[0] === "sandbox") {
270
+ return parseSandboxArgs(argv);
271
+ }
255
272
  if (argv[0] !== "run-once") {
256
273
  return {
257
274
  ok: false,
@@ -282,6 +299,151 @@ export function parseArgs(argv) {
282
299
  resultPath: path.resolve(resultPath)
283
300
  };
284
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
+ }
285
447
  function parseConnectArgs(argv) {
286
448
  let serverUrl = null;
287
449
  let runnerId = null;
@@ -470,7 +632,7 @@ async function claimBridgeRunner(args) {
470
632
  })
471
633
  });
472
634
  if (!response.ok) {
473
- throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
635
+ throw new Error(formatPairingFailure(response.status, args.serverUrl, await response.text()));
474
636
  }
475
637
  const value = await response.json();
476
638
  if (!value.runnerId || !value.deviceToken) {
@@ -499,6 +661,8 @@ function pairedConfig(current, args, claim) {
499
661
  supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
500
662
  supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
501
663
  supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
664
+ supportsDraftAttachments: current.supportsDraftAttachments ?? true,
665
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(args.cliProviders),
502
666
  repoMappings: current.repoMappings ?? []
503
667
  };
504
668
  }
@@ -543,6 +707,12 @@ async function resolvePairRepoRoot(repoRoot, configuredRoot) {
543
707
  function normalizePairingCode(code) {
544
708
  return code?.replace(/[\s-]/g, "").trim() ?? "";
545
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
+ }
546
716
  function normalizeServerUrl(serverUrl) {
547
717
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
548
718
  }
@@ -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[];