@loucompanion/forge-bridge 0.1.3-dev.9bc5014a2270 → 0.1.3-dev.a891d9601712

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 (61) 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 +531 -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 +163 -0
  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 +33 -0
  18. package/dist/sandbox/image-policy.js +245 -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 +36 -0
  24. package/dist/sandbox/preflight.js +239 -0
  25. package/dist/sandbox/process.d.ts +20 -0
  26. package/dist/sandbox/process.js +124 -0
  27. package/dist/sandbox/repository-lock.d.ts +5 -0
  28. package/dist/sandbox/repository-lock.js +83 -0
  29. package/dist/sandbox/runtime.d.ts +214 -0
  30. package/dist/sandbox/runtime.js +1615 -0
  31. package/dist/sandbox/sandbox.types.d.ts +83 -0
  32. package/dist/sandbox/sandbox.types.js +1 -0
  33. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  34. package/dist/sandbox/worker-supervisor.js +36 -0
  35. package/dist/sandbox/worker.d.ts +1 -0
  36. package/dist/sandbox/worker.js +228 -0
  37. package/dist/sandbox/workspace.service.d.ts +12 -0
  38. package/dist/sandbox/workspace.service.js +49 -0
  39. package/dist/session/phase-completion.artifacts.js +23 -2
  40. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  41. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  42. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  43. package/dist/session/provider-cli/provider-cli.process.d.ts +1 -0
  44. package/dist/session/provider-cli/provider-cli.process.js +23 -1
  45. package/dist/session/provider-cli/provider-cli.prompts.d.ts +12 -1
  46. package/dist/session/provider-cli/provider-cli.prompts.js +119 -2
  47. package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
  48. package/dist/session/provider-cli/provider-cli.service.js +151 -24
  49. package/dist/session/session.service.js +14 -7
  50. package/dist/session/session.types.d.ts +52 -1
  51. package/dist/session/session.types.js +156 -3
  52. package/dist/shared/process/windows-launch.d.ts +1 -1
  53. package/dist/shared/process/windows-launch.js +6 -3
  54. package/dist/shared/provider-capabilities.d.ts +1 -0
  55. package/dist/shared/provider-capabilities.js +3 -0
  56. package/dist/shared/redaction/redaction.js +4 -2
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/dist/worktree/dependency-warmer.d.ts +8 -1
  60. package/dist/worktree/dependency-warmer.js +40 -3
  61. package/package.json +4 -2
@@ -1,28 +1,57 @@
1
1
  import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import os from "node:os";
4
6
  import { defaultForgeBridgeConfig } from "../config.js";
7
+ import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
5
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
6
9
  import { BridgeSessionService } from "../session/session.service.js";
7
10
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
8
11
  import { forgeBridgeVersion } from "../version.js";
12
+ import { DockerPreflight } from "../sandbox/preflight.js";
13
+ import { DockerSandboxRuntime } from "../sandbox/runtime.js";
9
14
  export class ForgeBridgeService {
10
15
  config;
11
16
  sessions;
12
17
  connections;
13
18
  connection = null;
14
19
  heartbeatTimer = null;
20
+ leaseWatchdogTimer = null;
21
+ sandboxMaintenanceTimer = null;
22
+ sandboxMaintenanceRunning = false;
15
23
  closed = null;
16
24
  stopped = false;
17
25
  activeSessions = new Map();
18
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory()) {
26
+ reservations = new Map();
27
+ preflight;
28
+ sandboxRuntime;
29
+ constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
19
30
  this.config = config;
20
31
  this.sessions = sessions;
21
32
  this.connections = connections;
33
+ this.preflight = preflight ?? new DockerPreflight({
34
+ dockerExecutable: config.dockerExecutable,
35
+ dockerContext: config.dockerContext,
36
+ stateRoot: config.sandboxStateRoot,
37
+ runnerInstanceId: config.runnerInstanceId,
38
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
39
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
40
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
41
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
42
+ requireRootless: config.requireRootlessDocker
43
+ });
44
+ this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
22
45
  }
23
46
  async connectOnce() {
24
47
  await this.stopConnection();
25
48
  this.stopped = false;
49
+ if (this.config.dockerEnabled) {
50
+ const preflight = await this.preflight.refresh();
51
+ if (canReapManagedSandboxes(preflight)) {
52
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
53
+ }
54
+ }
26
55
  const connection = this.connections.create(this.config);
27
56
  this.connection = connection;
28
57
  this.closed = new Promise((resolve) => {
@@ -33,15 +62,23 @@ export class ForgeBridgeService {
33
62
  });
34
63
  });
35
64
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
65
+ connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
66
+ connection.onStart?.((dispatch) => this.handleStart(dispatch));
67
+ connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
36
68
  connection.onCancel((request) => this.handleCancel(request));
69
+ connection.onReap?.((request) => this.handleReap(request));
37
70
  await connection.start();
38
71
  await this.invokeAck("Hello", {
39
72
  version: this.config.version,
40
73
  protocolVersion: this.config.protocolVersion,
41
74
  minServerVersion: this.config.minServerVersion ?? null,
75
+ runnerInstanceId: this.config.runnerInstanceId,
42
76
  capabilities: this.capabilities()
43
77
  });
78
+ await this.flushCleanupReports();
44
79
  this.startHeartbeat();
80
+ this.startLeaseWatchdog();
81
+ this.startSandboxMaintenance();
45
82
  }
46
83
  async run(signal) {
47
84
  let attempt = 0;
@@ -80,6 +117,7 @@ export class ForgeBridgeService {
80
117
  this.stopped = true;
81
118
  this.abortActiveSessions("bridge stopping");
82
119
  await this.stopConnection();
120
+ await this.preflight.release();
83
121
  }
84
122
  async handleDispatch(rawDispatch) {
85
123
  const dispatch = this.parseDispatchOrReport(rawDispatch);
@@ -89,20 +127,52 @@ export class ForgeBridgeService {
89
127
  const leaseId = dispatch.lease.id;
90
128
  const fencingToken = dispatch.lease.fencingToken;
91
129
  const redactionSecrets = exactSecrets(dispatch, this.config);
92
- if (this.activeSessions.has(leaseId)) {
93
- await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
130
+ const existing = this.activeSessions.get(leaseId);
131
+ if (existing) {
132
+ if (existing.fencingToken === fencingToken) {
133
+ return;
134
+ }
135
+ await this.sendResult(dispatch, failureResult("Dispatch fence conflicts with an active bridge session.")).catch(() => undefined);
94
136
  return;
95
137
  }
96
138
  const controller = new AbortController();
97
- this.activeSessions.set(leaseId, { fencingToken, controller });
139
+ this.activeSessions.set(leaseId, {
140
+ fencingToken,
141
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
142
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
143
+ leaseExpiresAt: dispatch.lease.expiresAt,
144
+ sandboxed: dispatch.sandbox?.executionMode === "docker",
145
+ resources: dispatch.sandbox?.executionMode === "docker"
146
+ ? dispatch.sandbox.requestedResources
147
+ : zeroSandboxResources(),
148
+ controller
149
+ });
98
150
  try {
99
151
  const normalized = this.normalizeDispatch(dispatch);
100
- const result = await this.sessions.runOnce(normalized, {
152
+ if (normalized.sandbox) {
153
+ const started = {
154
+ dispatchAttemptId: normalized.sandbox.dispatchAttemptId,
155
+ leaseId,
156
+ fencingToken,
157
+ runnerInstanceId: normalized.sandbox.runnerInstanceId
158
+ };
159
+ await this.invokeAck("DispatchStarted", started);
160
+ }
161
+ const sink = {
101
162
  emit: async (event) => {
163
+ if (event.eventType === "provider_cli_started") {
164
+ const active = this.activeSessions.get(leaseId);
165
+ if (active)
166
+ active.resources = { ...active.resources, buildSlots: 0 };
167
+ }
102
168
  try {
103
169
  await this.invokeAck("DispatchEvent", {
104
170
  leaseId,
105
171
  fencingToken,
172
+ ...(dispatch.sandbox ? {
173
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
174
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId
175
+ } : {}),
106
176
  event: redactSecrets(event, redactionSecrets)
107
177
  });
108
178
  }
@@ -114,19 +184,189 @@ export class ForgeBridgeService {
114
184
  throw error;
115
185
  }
116
186
  }
117
- }, controller.signal);
118
- await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
187
+ };
188
+ const result = normalized.sandbox?.executionMode === "docker"
189
+ ? await this.sandboxRuntime.runOnce(normalized, sink, controller.signal)
190
+ : await this.sessions.runOnce(normalized, sink, controller.signal);
191
+ if (normalized.sandbox?.executionMode === "docker") {
192
+ await this.flushCleanupReports().catch(() => undefined);
193
+ }
194
+ await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
119
195
  }
120
196
  catch (error) {
121
197
  const summary = controller.signal.aborted
122
198
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
123
199
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
124
- await this.sendResult(leaseId, fencingToken, failureResult(summary)).catch(() => undefined);
200
+ await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
125
201
  }
126
202
  finally {
127
203
  this.activeSessions.delete(leaseId);
128
204
  }
129
205
  }
206
+ async handleOffer(rawDispatch) {
207
+ const envelope = parseOfferEnvelope(rawDispatch);
208
+ if (!envelope) {
209
+ return;
210
+ }
211
+ const dispatch = this.parseDispatchOrReport(envelope.dispatch);
212
+ if (!dispatch?.sandbox
213
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
214
+ || dispatch.lease.id !== envelope.leaseId
215
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
216
+ return;
217
+ const sandbox = dispatch.sandbox;
218
+ let existing = this.reservations.get(sandbox.dispatchAttemptId);
219
+ if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
220
+ this.reservations.delete(sandbox.dispatchAttemptId);
221
+ existing = undefined;
222
+ }
223
+ if (existing) {
224
+ const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
225
+ await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null);
226
+ return;
227
+ }
228
+ if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
229
+ await this.sendOfferDecision(dispatch, false, "stale_runner_instance", "Offer targets a different runner process instance.");
230
+ return;
231
+ }
232
+ if (Date.parse(envelope.expiresAt) <= Date.now()) {
233
+ await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
234
+ return;
235
+ }
236
+ const preflight = this.preflight.current();
237
+ if (sandbox.executionMode === "docker") {
238
+ if (!this.config.dockerEnabled || !preflight?.healthy) {
239
+ await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
240
+ return;
241
+ }
242
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch);
243
+ if (networkFailure) {
244
+ await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
245
+ return;
246
+ }
247
+ }
248
+ const runtimeState = this.sandboxRuntime.snapshot();
249
+ const usedResources = this.usedSandboxResources(runtimeState.degraded);
250
+ const capacity = this.sandboxResourceCapacity(preflight);
251
+ if (sandbox.executionMode === "docker"
252
+ && !canReserveSandboxResources(usedResources, sandbox.requestedResources, capacity)) {
253
+ await this.sendOfferDecision(dispatch, false, "capacity_unavailable", "Runner has insufficient sandbox CPU, memory, workspace, PID, build, or slot capacity.");
254
+ return;
255
+ }
256
+ const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
257
+ this.reservations.set(sandbox.dispatchAttemptId, {
258
+ leaseId: dispatch.lease.id,
259
+ fencingToken: dispatch.lease.fencingToken,
260
+ runnerInstanceId: sandbox.runnerInstanceId,
261
+ expiresAt: dispatch.lease.expiresAt,
262
+ reservedUntil,
263
+ sandboxed: sandbox.executionMode === "docker",
264
+ resources: sandbox.requestedResources
265
+ });
266
+ await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
267
+ }
268
+ async handleStart(rawDispatch) {
269
+ const envelope = parseStartEnvelope(rawDispatch);
270
+ if (!envelope) {
271
+ return;
272
+ }
273
+ let dispatch = this.parseDispatchOrReport(envelope.dispatch);
274
+ if (!dispatch?.sandbox
275
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
276
+ || dispatch.lease.id !== envelope.leaseId
277
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
278
+ return;
279
+ const reservation = this.reservations.get(dispatch.sandbox.dispatchAttemptId);
280
+ if (!reservation
281
+ || reservation.leaseId !== dispatch.lease.id
282
+ || reservation.fencingToken !== dispatch.lease.fencingToken
283
+ || reservation.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
284
+ || Date.parse(reservation.reservedUntil) <= Date.now()) {
285
+ await this.sendResult(dispatch, failureResult("Start rejected because its reservation is missing or stale.")).catch(() => undefined);
286
+ return;
287
+ }
288
+ if (dispatch.apiKeySecretGrant) {
289
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
290
+ await this.sendResult(dispatch, failureResult("Start rejected because protocol-v2 packets may not contain a plaintext provider grant.")).catch(() => undefined);
291
+ return;
292
+ }
293
+ if (dispatch.apiKeySecretGrantHandle) {
294
+ const response = await this.redeemSecretGrant(dispatch);
295
+ const grant = response.grant;
296
+ if (!response.accepted
297
+ || !grant
298
+ || grant.grantId !== dispatch.apiKeySecretGrantHandle.grantId
299
+ || grant.provider !== dispatch.apiKeySecretGrantHandle.provider
300
+ || Date.parse(grant.expiresAt) <= Date.now()) {
301
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
302
+ await this.sendResult(dispatch, failureResult(response.message ?? "Secret grant redemption was rejected.")).catch(() => undefined);
303
+ return;
304
+ }
305
+ dispatch = { ...dispatch, apiKeySecretGrant: grant, apiKeySecretGrantHandle: null };
306
+ }
307
+ else if (dispatch.execution.requiresApiKeySecretGrant) {
308
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
309
+ await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
310
+ return;
311
+ }
312
+ this.reservations.delete(envelope.dispatchAttemptId);
313
+ await this.handleDispatch(dispatch);
314
+ }
315
+ async redeemSecretGrant(dispatch) {
316
+ const sandbox = dispatch.sandbox;
317
+ const handle = dispatch.apiKeySecretGrantHandle;
318
+ const connection = this.connection;
319
+ if (!connection)
320
+ return { accepted: false, code: "bridge.disconnected", message: "Bridge is disconnected.", grant: null };
321
+ return await connection.invoke("RedeemSecretGrant", {
322
+ dispatchAttemptId: sandbox.dispatchAttemptId,
323
+ leaseId: dispatch.lease.id,
324
+ fencingToken: dispatch.lease.fencingToken,
325
+ runnerInstanceId: sandbox.runnerInstanceId,
326
+ grantId: handle.grantId
327
+ });
328
+ }
329
+ async redeemPublishGrant(request, signal) {
330
+ if (signal?.aborted)
331
+ throw new Error(String(signal.reason ?? "publication cancelled"));
332
+ const connection = this.connection;
333
+ if (!connection)
334
+ throw new Error("sandbox_output_publish_forbidden: bridge is disconnected.");
335
+ const response = await connection.invoke("RedeemPublishGrant", request);
336
+ if (!response.accepted || !response.credential) {
337
+ throw new Error(`sandbox_output_publish_forbidden: ${response.message ?? response.code ?? "publication grant redemption was rejected"}`);
338
+ }
339
+ return response.credential;
340
+ }
341
+ async handleLeaseUpdate(request) {
342
+ if (request.runnerInstanceId !== this.config.runnerInstanceId) {
343
+ return;
344
+ }
345
+ const reservation = this.reservations.get(request.dispatchAttemptId);
346
+ if (reservation && reservation.leaseId === request.leaseId && reservation.fencingToken === request.fencingToken) {
347
+ reservation.expiresAt = request.expiresAt;
348
+ }
349
+ const active = this.activeSessions.get(request.leaseId);
350
+ if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
351
+ active.leaseExpiresAt = request.expiresAt;
352
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
353
+ }
354
+ }
355
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
356
+ if (!dispatch.sandbox)
357
+ return;
358
+ const payload = {
359
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
360
+ leaseId: dispatch.lease.id,
361
+ fencingToken: dispatch.lease.fencingToken,
362
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId,
363
+ accepted,
364
+ code,
365
+ message,
366
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
367
+ };
368
+ await this.invokeAck("OfferDecision", payload);
369
+ }
130
370
  parseDispatchOrReport(rawDispatch) {
131
371
  try {
132
372
  return parseRunOnceDispatchPacket(rawDispatch);
@@ -137,7 +377,7 @@ export class ForgeBridgeService {
137
377
  return null;
138
378
  }
139
379
  const summary = invalidDispatchSummary(error);
140
- void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
380
+ void this.sendLegacyResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
141
381
  {
142
382
  role: "runner",
143
383
  kind: "error",
@@ -155,6 +395,14 @@ export class ForgeBridgeService {
155
395
  }
156
396
  active.controller.abort(request.reason ?? "cancelled by server");
157
397
  }
398
+ async handleReap(request) {
399
+ if (!this.config.dockerEnabled
400
+ || request.runnerId !== this.config.runnerId
401
+ || request.runnerInstanceId !== this.config.runnerInstanceId)
402
+ return;
403
+ await this.runSandboxMaintenance();
404
+ await this.sendHeartbeat();
405
+ }
158
406
  abortActiveSessions(reason) {
159
407
  for (const active of this.activeSessions.values()) {
160
408
  active.controller.abort(reason);
@@ -185,12 +433,20 @@ export class ForgeBridgeService {
185
433
  : dispatch.qaBrowser
186
434
  };
187
435
  }
188
- async sendResult(leaseId, fencingToken, result) {
436
+ async sendResult(dispatch, result) {
189
437
  const payload = {
190
- leaseId,
191
- fencingToken,
438
+ leaseId: dispatch.lease.id,
439
+ fencingToken: dispatch.lease.fencingToken,
440
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
441
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
192
442
  result
193
443
  };
444
+ await this.sendResultPayload(payload);
445
+ }
446
+ async sendLegacyResult(leaseId, fencingToken, result) {
447
+ await this.sendResultPayload({ leaseId, fencingToken, result });
448
+ }
449
+ async sendResultPayload(payload) {
194
450
  try {
195
451
  await this.invokeAck("DispatchResult", payload);
196
452
  }
@@ -209,12 +465,90 @@ export class ForgeBridgeService {
209
465
  }, intervalMs);
210
466
  this.heartbeatTimer.unref?.();
211
467
  }
468
+ startLeaseWatchdog() {
469
+ this.stopLeaseWatchdog();
470
+ this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
471
+ this.leaseWatchdogTimer.unref?.();
472
+ }
473
+ stopLeaseWatchdog() {
474
+ if (this.leaseWatchdogTimer) {
475
+ clearInterval(this.leaseWatchdogTimer);
476
+ this.leaseWatchdogTimer = null;
477
+ }
478
+ }
479
+ expireLeases() {
480
+ const now = Date.now();
481
+ for (const [attemptId, reservation] of this.reservations) {
482
+ if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
483
+ this.reservations.delete(attemptId);
484
+ }
485
+ }
486
+ for (const active of this.activeSessions.values()) {
487
+ if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
488
+ active.controller.abort("dispatch lease expired");
489
+ }
490
+ }
491
+ }
212
492
  stopHeartbeat() {
213
493
  if (this.heartbeatTimer) {
214
494
  clearInterval(this.heartbeatTimer);
215
495
  this.heartbeatTimer = null;
216
496
  }
217
497
  }
498
+ startSandboxMaintenance() {
499
+ this.stopSandboxMaintenance();
500
+ if (!this.config.dockerEnabled)
501
+ return;
502
+ this.sandboxMaintenanceTimer = setInterval(() => {
503
+ this.runSandboxMaintenance().catch(() => undefined);
504
+ }, 60_000);
505
+ this.sandboxMaintenanceTimer.unref?.();
506
+ }
507
+ stopSandboxMaintenance() {
508
+ if (this.sandboxMaintenanceTimer) {
509
+ clearInterval(this.sandboxMaintenanceTimer);
510
+ this.sandboxMaintenanceTimer = null;
511
+ }
512
+ }
513
+ async runSandboxMaintenance() {
514
+ if (this.sandboxMaintenanceRunning)
515
+ return;
516
+ this.sandboxMaintenanceRunning = true;
517
+ try {
518
+ const preflight = await this.preflight.refresh();
519
+ if (canReapManagedSandboxes(preflight)) {
520
+ const attemptSnapshot = await this.readAttemptSnapshot();
521
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
522
+ await this.flushCleanupReports();
523
+ }
524
+ }
525
+ finally {
526
+ this.sandboxMaintenanceRunning = false;
527
+ }
528
+ }
529
+ async readAttemptSnapshot() {
530
+ const connection = this.connection;
531
+ if (!connection)
532
+ throw new Error("Bridge is not connected.");
533
+ const response = await connection.invoke("AttemptSnapshot", {
534
+ runnerInstanceId: this.config.runnerInstanceId
535
+ });
536
+ if (!response.accepted) {
537
+ throw new BridgeCallbackRejectedError(response.code ?? null, response.message ?? response.code ?? "Attempt snapshot was rejected.");
538
+ }
539
+ return response.attempts ?? [];
540
+ }
541
+ async flushCleanupReports() {
542
+ if (!this.sandboxRuntime.pendingCleanupReports || !this.sandboxRuntime.acknowledgeCleanupReport)
543
+ return;
544
+ for (const report of await this.sandboxRuntime.pendingCleanupReports()) {
545
+ await this.invokeAck("CleanupReport", {
546
+ runnerInstanceId: this.config.runnerInstanceId,
547
+ report
548
+ });
549
+ await this.sandboxRuntime.acknowledgeCleanupReport(report.reportId);
550
+ }
551
+ }
218
552
  async sendHeartbeat() {
219
553
  await this.invokeAck("Heartbeat", {
220
554
  status: "online",
@@ -224,8 +558,13 @@ export class ForgeBridgeService {
224
558
  });
225
559
  }
226
560
  capabilities() {
561
+ const preflight = this.preflight.current();
562
+ const runtimeState = this.sandboxRuntime.snapshot();
563
+ const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
564
+ const used = this.usedSandboxResources(runtimeState.degraded);
565
+ const capacity = this.sandboxResourceCapacity(preflight);
227
566
  return {
228
- schemaVersion: 2,
567
+ schemaVersion: 3,
229
568
  activeSessionCount: this.activeSessions.size,
230
569
  cliProviders: this.config.cliProviders,
231
570
  providerStatuses: this.config.providerStatuses,
@@ -233,11 +572,56 @@ export class ForgeBridgeService {
233
572
  repoMappings: repoMappingsFor(this.config),
234
573
  os: this.config.os,
235
574
  sandboxProfiles: this.config.sandboxProfiles,
575
+ runnerInstanceId: this.config.runnerInstanceId,
576
+ trustDomainId: this.config.trustDomainId,
577
+ maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
578
+ reservedSandboxCount: reserved,
579
+ activeSandboxCount: runtimeState.active,
580
+ startingSandboxCount: runtimeState.starting,
581
+ degradedReservationCount: runtimeState.degraded,
582
+ sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
583
+ sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
584
+ sandboxFeatures: preflight?.features ?? null,
585
+ sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
586
+ sandboxAuthModes: Object.fromEntries(this.config.cliProviders.map(provider => [provider, ["api_key"]])),
587
+ availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
588
+ availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
589
+ availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
590
+ diskLow: preflight?.diskLow ?? null,
591
+ preflightGeneration: preflight?.generation ?? 0,
592
+ sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
593
+ sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
236
594
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
237
595
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
238
596
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
239
597
  supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
240
- supportsDraftRunOnce: this.config.supportsDraftRunOnce
598
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce,
599
+ supportsDraftAttachments: this.config.supportsDraftAttachments,
600
+ supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
601
+ };
602
+ }
603
+ usedSandboxResources(degradedReservations) {
604
+ const resources = [
605
+ ...[...this.reservations.values()]
606
+ .filter(reservation => reservation.sandboxed)
607
+ .map(reservation => reservation.resources),
608
+ ...[...this.activeSessions.values()]
609
+ .filter(session => session.sandboxed)
610
+ .map(session => session.resources),
611
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
612
+ ];
613
+ return sumSandboxResources(resources);
614
+ }
615
+ sandboxResourceCapacity(preflight) {
616
+ if (!preflight)
617
+ return zeroSandboxResources();
618
+ return {
619
+ sandboxSlots: preflight.maxConcurrentSandboxes,
620
+ cpuMillis: preflight.availableCpuMillis,
621
+ memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
622
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
623
+ pids: this.config.sandboxPidCapacity,
624
+ buildSlots: this.config.maxConcurrentSandboxBuilds
241
625
  };
242
626
  }
243
627
  async invokeAck(methodName, payload) {
@@ -259,6 +643,8 @@ export class ForgeBridgeService {
259
643
  }
260
644
  async stopConnection() {
261
645
  this.stopHeartbeat();
646
+ this.stopLeaseWatchdog();
647
+ this.stopSandboxMaintenance();
262
648
  if (this.connection) {
263
649
  await this.connection.stop().catch(() => undefined);
264
650
  this.connection = null;
@@ -303,6 +689,9 @@ export class SignalRBridgeConnection {
303
689
  })
304
690
  .configureLogging(LogLevel.Warning)
305
691
  .build();
692
+ this.connection.on("Superseded", () => {
693
+ void this.connection.stop();
694
+ });
306
695
  }
307
696
  start() {
308
697
  return this.connection.start();
@@ -318,11 +707,31 @@ export class SignalRBridgeConnection {
318
707
  void handler(raw).catch(() => undefined);
319
708
  });
320
709
  }
710
+ onOffer(handler) {
711
+ this.connection.on("Offer", (raw) => {
712
+ void handler(raw).catch(() => undefined);
713
+ });
714
+ }
715
+ onStart(handler) {
716
+ this.connection.on("Start", (raw) => {
717
+ void handler(raw).catch(() => undefined);
718
+ });
719
+ }
720
+ onLeaseUpdate(handler) {
721
+ this.connection.on("LeaseUpdate", (request) => {
722
+ void handler(request).catch(() => undefined);
723
+ });
724
+ }
321
725
  onCancel(handler) {
322
726
  this.connection.on("Cancel", (request) => {
323
727
  void handler(request).catch(() => undefined);
324
728
  });
325
729
  }
730
+ onReap(handler) {
731
+ this.connection.on("Reap", (request) => {
732
+ void handler(request).catch(() => undefined);
733
+ });
734
+ }
326
735
  invoke(methodName, payload) {
327
736
  return this.connection.invoke(methodName, payload);
328
737
  }
@@ -330,9 +739,28 @@ export class SignalRBridgeConnection {
330
739
  export function defaultBridgeServiceConfig(overrides = {}) {
331
740
  const defaults = defaultForgeBridgeConfig();
332
741
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
742
+ const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
333
743
  const config = {
334
744
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
335
745
  runnerId: process.env.FORGE_RUNNER_ID ?? "",
746
+ runnerInstanceId: process.env.FORGE_RUNNER_INSTANCE_ID ?? randomUUID(),
747
+ trustDomainId: process.env.FORGE_TRUST_DOMAIN_ID ?? "default",
748
+ maxConcurrentSandboxes: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOXES ?? 1), 1),
749
+ maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
750
+ sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
751
+ dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
752
+ sandboxPublicationMode,
753
+ dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
754
+ dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
755
+ sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
756
+ requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
757
+ diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
758
+ diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
759
+ minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
760
+ imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
761
+ imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
762
+ auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
763
+ auditRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000), 30 * 24 * 60 * 60 * 1000),
336
764
  deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
337
765
  deviceName: process.env.FORGE_DEVICE_NAME ?? "",
338
766
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
@@ -348,8 +776,10 @@ export function defaultBridgeServiceConfig(overrides = {}) {
348
776
  supportsManagedForgeArtifacts: true,
349
777
  supportsRunnerAuthoredPhaseArtifacts: true,
350
778
  supportsDraftRunOnce: true,
779
+ supportsDraftAttachments: true,
780
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
351
781
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
352
- protocolVersion: 1,
782
+ protocolVersion: 2,
353
783
  minServerVersion: null,
354
784
  heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
355
785
  reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
@@ -357,7 +787,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
357
787
  };
358
788
  return {
359
789
  ...config,
790
+ providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
360
791
  sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
792
+ supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
361
793
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
362
794
  };
363
795
  }
@@ -539,9 +971,92 @@ function abortPromise(signal) {
539
971
  }
540
972
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
541
973
  }
974
+ function canReapManagedSandboxes(preflight) {
975
+ return preflight.healthy || preflight.unavailableCode === "docker_disk_low";
976
+ }
977
+ function sandboxNetworkPolicyFailure(dispatch) {
978
+ const sandbox = dispatch.sandbox;
979
+ if (!sandbox)
980
+ return "Protocol-v2 sandbox context is missing.";
981
+ if (sandbox.networkPolicy.mode === "open_public"
982
+ && (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
983
+ return "open_public sandbox networking is not permitted by this runner policy.";
984
+ }
985
+ if (sandbox.networkPolicy.mode === "public_allowlist"
986
+ && sandbox.networkPolicy.allowedHosts.length > 0
987
+ && process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
988
+ return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
989
+ }
990
+ return null;
991
+ }
992
+ export function canReserveSandboxResources(used, requested, capacity) {
993
+ return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
994
+ && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
995
+ && used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
996
+ && used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
997
+ && used.pids + requested.pids <= capacity.pids
998
+ && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
999
+ }
1000
+ function sumSandboxResources(resources) {
1001
+ return resources.reduce((total, resource) => ({
1002
+ sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1003
+ cpuMillis: total.cpuMillis + resource.cpuMillis,
1004
+ memoryBytes: total.memoryBytes + resource.memoryBytes,
1005
+ workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1006
+ pids: total.pids + resource.pids,
1007
+ buildSlots: total.buildSlots + resource.buildSlots
1008
+ }), zeroSandboxResources());
1009
+ }
1010
+ function zeroSandboxResources() {
1011
+ return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
1012
+ }
1013
+ function defaultSandboxResources() {
1014
+ return {
1015
+ sandboxSlots: 1,
1016
+ cpuMillis: 2000,
1017
+ memoryBytes: 4 * 1024 ** 3,
1018
+ workspaceBytes: 10 * 1024 ** 3,
1019
+ pids: 512,
1020
+ buildSlots: 0
1021
+ };
1022
+ }
1023
+ function parseOfferEnvelope(value) {
1024
+ return parseDispatchEnvelope(value);
1025
+ }
1026
+ function parseStartEnvelope(value) {
1027
+ return parseDispatchEnvelope(value);
1028
+ }
1029
+ function parseDispatchEnvelope(value) {
1030
+ if (!value || typeof value !== "object" || Array.isArray(value))
1031
+ return null;
1032
+ const envelope = value;
1033
+ if (envelope.schemaVersion !== 1
1034
+ || typeof envelope.dispatchAttemptId !== "string"
1035
+ || typeof envelope.leaseId !== "string"
1036
+ || typeof envelope.fencingToken !== "string"
1037
+ || typeof envelope.runnerInstanceId !== "string"
1038
+ || typeof envelope.expiresAt !== "string"
1039
+ || !envelope.dispatch)
1040
+ return null;
1041
+ return {
1042
+ dispatchAttemptId: envelope.dispatchAttemptId,
1043
+ leaseId: envelope.leaseId,
1044
+ fencingToken: envelope.fencingToken,
1045
+ runnerInstanceId: envelope.runnerInstanceId,
1046
+ expiresAt: envelope.expiresAt,
1047
+ dispatch: envelope.dispatch
1048
+ };
1049
+ }
1050
+ const maxAttachmentExactSecretLength = 8 * 1024;
542
1051
  function exactSecrets(dispatch, config) {
543
1052
  return [
544
1053
  dispatch.apiKeySecretGrant?.secret,
545
- config.deviceToken
1054
+ config.deviceToken,
1055
+ ...boundedAttachmentExactSecrets(dispatch)
546
1056
  ].filter((secret) => typeof secret === "string" && secret.length > 0);
547
1057
  }
1058
+ function boundedAttachmentExactSecrets(dispatch) {
1059
+ return (dispatch.draft?.attachments ?? [])
1060
+ .flatMap((attachment) => [attachment.textContent, attachment.contentBase64])
1061
+ .filter((secret) => typeof secret === "string" && secret.length > 0 && secret.length <= maxAttachmentExactSecretLength);
1062
+ }