@loucompanion/forge-bridge 0.1.3-dev.0b2f81d660c0 → 0.1.3-dev.0bd9fb991b79

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 (51) 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 +564 -14
  5. package/dist/bridge/bridge.types.d.ts +110 -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 +168 -1
  9. package/dist/sandbox/command-runner.d.ts +4 -0
  10. package/dist/sandbox/command-runner.js +47 -0
  11. package/dist/sandbox/contract.d.ts +5 -0
  12. package/dist/sandbox/contract.js +276 -0
  13. package/dist/sandbox/event-budget.d.ts +11 -0
  14. package/dist/sandbox/event-budget.js +27 -0
  15. package/dist/sandbox/image-policy.d.ts +35 -0
  16. package/dist/sandbox/image-policy.js +247 -0
  17. package/dist/sandbox/index.d.ts +5 -0
  18. package/dist/sandbox/index.js +5 -0
  19. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  20. package/dist/sandbox/lease-watchdog.js +67 -0
  21. package/dist/sandbox/preflight.d.ts +40 -0
  22. package/dist/sandbox/preflight.js +256 -0
  23. package/dist/sandbox/process.d.ts +20 -0
  24. package/dist/sandbox/process.js +124 -0
  25. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  26. package/dist/sandbox/provider-auth/codex.js +212 -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 +234 -0
  30. package/dist/sandbox/runtime.js +1773 -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/provider-cli/provider-cli.base.d.ts +1 -0
  40. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  41. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  42. package/dist/session/provider-cli/provider-cli.prompts.d.ts +2 -0
  43. package/dist/session/provider-cli/provider-cli.prompts.js +31 -0
  44. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -0
  45. package/dist/session/provider-cli/provider-cli.service.js +72 -15
  46. package/dist/session/session.service.js +14 -7
  47. package/dist/session/session.types.d.ts +41 -1
  48. package/dist/session/session.types.js +119 -2
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +4 -2
@@ -1,29 +1,58 @@
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";
5
7
  import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
6
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
7
9
  import { BridgeSessionService } from "../session/session.service.js";
8
10
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
9
11
  import { forgeBridgeVersion } from "../version.js";
12
+ import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
13
+ import { DockerSandboxRuntime } from "../sandbox/runtime.js";
14
+ import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
10
15
  export class ForgeBridgeService {
11
16
  config;
12
17
  sessions;
13
18
  connections;
14
19
  connection = null;
15
20
  heartbeatTimer = null;
21
+ leaseWatchdogTimer = null;
22
+ sandboxMaintenanceTimer = null;
23
+ sandboxMaintenanceRunning = false;
16
24
  closed = null;
17
25
  stopped = false;
18
26
  activeSessions = new Map();
19
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory()) {
27
+ reservations = new Map();
28
+ preflight;
29
+ sandboxRuntime;
30
+ constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
20
31
  this.config = config;
21
32
  this.sessions = sessions;
22
33
  this.connections = connections;
34
+ this.preflight = preflight ?? new DockerPreflight({
35
+ dockerExecutable: config.dockerExecutable,
36
+ dockerContext: config.dockerContext,
37
+ stateRoot: config.sandboxStateRoot,
38
+ runnerInstanceId: config.runnerInstanceId,
39
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
40
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
41
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
42
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
43
+ requireRootless: config.requireRootlessDocker
44
+ });
45
+ this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
23
46
  }
24
47
  async connectOnce() {
25
48
  await this.stopConnection();
26
49
  this.stopped = false;
50
+ if (this.config.dockerEnabled) {
51
+ const preflight = await this.preflight.refresh();
52
+ if (canReapManagedSandboxes(preflight)) {
53
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
54
+ }
55
+ }
27
56
  const connection = this.connections.create(this.config);
28
57
  this.connection = connection;
29
58
  this.closed = new Promise((resolve) => {
@@ -34,15 +63,23 @@ export class ForgeBridgeService {
34
63
  });
35
64
  });
36
65
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
66
+ connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
67
+ connection.onStart?.((dispatch) => this.handleStart(dispatch));
68
+ connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
37
69
  connection.onCancel((request) => this.handleCancel(request));
70
+ connection.onReap?.((request) => this.handleReap(request));
38
71
  await connection.start();
39
72
  await this.invokeAck("Hello", {
40
73
  version: this.config.version,
41
74
  protocolVersion: this.config.protocolVersion,
42
75
  minServerVersion: this.config.minServerVersion ?? null,
76
+ runnerInstanceId: this.config.runnerInstanceId,
43
77
  capabilities: this.capabilities()
44
78
  });
79
+ await this.flushCleanupReports();
45
80
  this.startHeartbeat();
81
+ this.startLeaseWatchdog();
82
+ this.startSandboxMaintenance();
46
83
  }
47
84
  async run(signal) {
48
85
  let attempt = 0;
@@ -81,6 +118,7 @@ export class ForgeBridgeService {
81
118
  this.stopped = true;
82
119
  this.abortActiveSessions("bridge stopping");
83
120
  await this.stopConnection();
121
+ await this.preflight.release();
84
122
  }
85
123
  async handleDispatch(rawDispatch) {
86
124
  const dispatch = this.parseDispatchOrReport(rawDispatch);
@@ -90,20 +128,52 @@ export class ForgeBridgeService {
90
128
  const leaseId = dispatch.lease.id;
91
129
  const fencingToken = dispatch.lease.fencingToken;
92
130
  const redactionSecrets = exactSecrets(dispatch, this.config);
93
- if (this.activeSessions.has(leaseId)) {
94
- await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
131
+ const existing = this.activeSessions.get(leaseId);
132
+ if (existing) {
133
+ if (existing.fencingToken === fencingToken) {
134
+ return;
135
+ }
136
+ await this.sendResult(dispatch, failureResult("Dispatch fence conflicts with an active bridge session.")).catch(() => undefined);
95
137
  return;
96
138
  }
97
139
  const controller = new AbortController();
98
- this.activeSessions.set(leaseId, { fencingToken, controller });
140
+ this.activeSessions.set(leaseId, {
141
+ fencingToken,
142
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
143
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
144
+ leaseExpiresAt: dispatch.lease.expiresAt,
145
+ sandboxed: dispatch.sandbox?.executionMode === "docker",
146
+ resources: dispatch.sandbox?.executionMode === "docker"
147
+ ? dispatch.sandbox.requestedResources
148
+ : zeroSandboxResources(),
149
+ controller
150
+ });
99
151
  try {
100
152
  const normalized = this.normalizeDispatch(dispatch);
101
- const result = await this.sessions.runOnce(normalized, {
153
+ if (normalized.sandbox) {
154
+ const started = {
155
+ dispatchAttemptId: normalized.sandbox.dispatchAttemptId,
156
+ leaseId,
157
+ fencingToken,
158
+ runnerInstanceId: normalized.sandbox.runnerInstanceId
159
+ };
160
+ await this.invokeAck("DispatchStarted", started);
161
+ }
162
+ const sink = {
102
163
  emit: async (event) => {
164
+ if (event.eventType === "provider_cli_started") {
165
+ const active = this.activeSessions.get(leaseId);
166
+ if (active)
167
+ active.resources = { ...active.resources, buildSlots: 0 };
168
+ }
103
169
  try {
104
170
  await this.invokeAck("DispatchEvent", {
105
171
  leaseId,
106
172
  fencingToken,
173
+ ...(dispatch.sandbox ? {
174
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
175
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId
176
+ } : {}),
107
177
  event: redactSecrets(event, redactionSecrets)
108
178
  });
109
179
  }
@@ -115,19 +185,194 @@ export class ForgeBridgeService {
115
185
  throw error;
116
186
  }
117
187
  }
118
- }, controller.signal);
119
- await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
188
+ };
189
+ const result = normalized.sandbox?.executionMode === "docker"
190
+ ? await this.sandboxRuntime.runOnce(normalized, sink, controller.signal)
191
+ : await this.sessions.runOnce(normalized, sink, controller.signal);
192
+ if (normalized.sandbox?.executionMode === "docker") {
193
+ await this.flushCleanupReports().catch(() => undefined);
194
+ }
195
+ await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
120
196
  }
121
197
  catch (error) {
122
198
  const summary = controller.signal.aborted
123
199
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
124
200
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
125
- await this.sendResult(leaseId, fencingToken, failureResult(summary)).catch(() => undefined);
201
+ await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
126
202
  }
127
203
  finally {
128
204
  this.activeSessions.delete(leaseId);
129
205
  }
130
206
  }
207
+ async handleOffer(rawDispatch) {
208
+ const envelope = parseOfferEnvelope(rawDispatch);
209
+ if (!envelope) {
210
+ return;
211
+ }
212
+ const dispatch = this.parseDispatchOrReport(envelope.dispatch);
213
+ if (!dispatch?.sandbox
214
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
215
+ || dispatch.lease.id !== envelope.leaseId
216
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
217
+ return;
218
+ const sandbox = dispatch.sandbox;
219
+ let existing = this.reservations.get(sandbox.dispatchAttemptId);
220
+ if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
221
+ this.reservations.delete(sandbox.dispatchAttemptId);
222
+ existing = undefined;
223
+ }
224
+ if (existing) {
225
+ const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
226
+ await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null);
227
+ return;
228
+ }
229
+ if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
230
+ await this.sendOfferDecision(dispatch, false, "stale_runner_instance", "Offer targets a different runner process instance.");
231
+ return;
232
+ }
233
+ if (Date.parse(envelope.expiresAt) <= Date.now()) {
234
+ await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
235
+ return;
236
+ }
237
+ const preflight = this.preflight.current();
238
+ if (sandbox.executionMode === "docker") {
239
+ if (!this.config.dockerEnabled || !preflight?.healthy) {
240
+ await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
241
+ return;
242
+ }
243
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch);
244
+ if (networkFailure) {
245
+ await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
246
+ return;
247
+ }
248
+ const authFailure = sandboxAuthFailure(dispatch, this.config);
249
+ if (authFailure) {
250
+ await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
251
+ return;
252
+ }
253
+ }
254
+ const runtimeState = this.sandboxRuntime.snapshot();
255
+ const usedResources = this.usedSandboxResources(runtimeState.degraded);
256
+ const capacity = this.sandboxResourceCapacity(preflight);
257
+ if (sandbox.executionMode === "docker"
258
+ && !canReserveSandboxResources(usedResources, sandbox.requestedResources, capacity)) {
259
+ await this.sendOfferDecision(dispatch, false, "capacity_unavailable", "Runner has insufficient sandbox CPU, memory, workspace, PID, build, or slot capacity.");
260
+ return;
261
+ }
262
+ const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
263
+ this.reservations.set(sandbox.dispatchAttemptId, {
264
+ leaseId: dispatch.lease.id,
265
+ fencingToken: dispatch.lease.fencingToken,
266
+ runnerInstanceId: sandbox.runnerInstanceId,
267
+ expiresAt: dispatch.lease.expiresAt,
268
+ reservedUntil,
269
+ sandboxed: sandbox.executionMode === "docker",
270
+ resources: sandbox.requestedResources
271
+ });
272
+ await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
273
+ }
274
+ async handleStart(rawDispatch) {
275
+ const envelope = parseStartEnvelope(rawDispatch);
276
+ if (!envelope) {
277
+ return;
278
+ }
279
+ let dispatch = this.parseDispatchOrReport(envelope.dispatch);
280
+ if (!dispatch?.sandbox
281
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
282
+ || dispatch.lease.id !== envelope.leaseId
283
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
284
+ return;
285
+ const reservation = this.reservations.get(dispatch.sandbox.dispatchAttemptId);
286
+ if (!reservation
287
+ || reservation.leaseId !== dispatch.lease.id
288
+ || reservation.fencingToken !== dispatch.lease.fencingToken
289
+ || reservation.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
290
+ || Date.parse(reservation.reservedUntil) <= Date.now()) {
291
+ await this.sendResult(dispatch, failureResult("Start rejected because its reservation is missing or stale.")).catch(() => undefined);
292
+ return;
293
+ }
294
+ if (dispatch.apiKeySecretGrant) {
295
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
296
+ await this.sendResult(dispatch, failureResult("Start rejected because protocol-v2 packets may not contain a plaintext provider grant.")).catch(() => undefined);
297
+ return;
298
+ }
299
+ if (dispatch.apiKeySecretGrantHandle) {
300
+ const response = await this.redeemSecretGrant(dispatch);
301
+ const grant = response.grant;
302
+ if (!response.accepted
303
+ || !grant
304
+ || grant.grantId !== dispatch.apiKeySecretGrantHandle.grantId
305
+ || grant.provider !== dispatch.apiKeySecretGrantHandle.provider
306
+ || Date.parse(grant.expiresAt) <= Date.now()) {
307
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
308
+ await this.sendResult(dispatch, failureResult(response.message ?? "Secret grant redemption was rejected.")).catch(() => undefined);
309
+ return;
310
+ }
311
+ dispatch = { ...dispatch, apiKeySecretGrant: grant, apiKeySecretGrantHandle: null };
312
+ }
313
+ else if (dispatch.execution.requiresApiKeySecretGrant) {
314
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
315
+ await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
316
+ return;
317
+ }
318
+ this.reservations.delete(envelope.dispatchAttemptId);
319
+ await this.handleDispatch(dispatch);
320
+ }
321
+ async redeemSecretGrant(dispatch) {
322
+ const sandbox = dispatch.sandbox;
323
+ const handle = dispatch.apiKeySecretGrantHandle;
324
+ const connection = this.connection;
325
+ if (!connection)
326
+ return { accepted: false, code: "bridge.disconnected", message: "Bridge is disconnected.", grant: null };
327
+ return await connection.invoke("RedeemSecretGrant", {
328
+ dispatchAttemptId: sandbox.dispatchAttemptId,
329
+ leaseId: dispatch.lease.id,
330
+ fencingToken: dispatch.lease.fencingToken,
331
+ runnerInstanceId: sandbox.runnerInstanceId,
332
+ grantId: handle.grantId
333
+ });
334
+ }
335
+ async redeemPublishGrant(request, signal) {
336
+ if (signal?.aborted)
337
+ throw new Error(String(signal.reason ?? "publication cancelled"));
338
+ const connection = this.connection;
339
+ if (!connection)
340
+ throw new Error("sandbox_output_publish_forbidden: bridge is disconnected.");
341
+ const response = await connection.invoke("RedeemPublishGrant", request);
342
+ if (!response.accepted || !response.credential) {
343
+ throw new Error(`sandbox_output_publish_forbidden: ${response.message ?? response.code ?? "publication grant redemption was rejected"}`);
344
+ }
345
+ return response.credential;
346
+ }
347
+ async handleLeaseUpdate(request) {
348
+ if (request.runnerInstanceId !== this.config.runnerInstanceId) {
349
+ return;
350
+ }
351
+ const reservation = this.reservations.get(request.dispatchAttemptId);
352
+ if (reservation && reservation.leaseId === request.leaseId && reservation.fencingToken === request.fencingToken) {
353
+ reservation.expiresAt = request.expiresAt;
354
+ }
355
+ const active = this.activeSessions.get(request.leaseId);
356
+ if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
357
+ active.leaseExpiresAt = request.expiresAt;
358
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
359
+ }
360
+ }
361
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
362
+ if (!dispatch.sandbox)
363
+ return;
364
+ const payload = {
365
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
366
+ leaseId: dispatch.lease.id,
367
+ fencingToken: dispatch.lease.fencingToken,
368
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId,
369
+ accepted,
370
+ code,
371
+ message,
372
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
373
+ };
374
+ await this.invokeAck("OfferDecision", payload);
375
+ }
131
376
  parseDispatchOrReport(rawDispatch) {
132
377
  try {
133
378
  return parseRunOnceDispatchPacket(rawDispatch);
@@ -138,7 +383,7 @@ export class ForgeBridgeService {
138
383
  return null;
139
384
  }
140
385
  const summary = invalidDispatchSummary(error);
141
- void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
386
+ void this.sendLegacyResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
142
387
  {
143
388
  role: "runner",
144
389
  kind: "error",
@@ -156,6 +401,14 @@ export class ForgeBridgeService {
156
401
  }
157
402
  active.controller.abort(request.reason ?? "cancelled by server");
158
403
  }
404
+ async handleReap(request) {
405
+ if (!this.config.dockerEnabled
406
+ || request.runnerId !== this.config.runnerId
407
+ || request.runnerInstanceId !== this.config.runnerInstanceId)
408
+ return;
409
+ await this.runSandboxMaintenance();
410
+ await this.sendHeartbeat();
411
+ }
159
412
  abortActiveSessions(reason) {
160
413
  for (const active of this.activeSessions.values()) {
161
414
  active.controller.abort(reason);
@@ -186,12 +439,20 @@ export class ForgeBridgeService {
186
439
  : dispatch.qaBrowser
187
440
  };
188
441
  }
189
- async sendResult(leaseId, fencingToken, result) {
442
+ async sendResult(dispatch, result) {
190
443
  const payload = {
191
- leaseId,
192
- fencingToken,
444
+ leaseId: dispatch.lease.id,
445
+ fencingToken: dispatch.lease.fencingToken,
446
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
447
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
193
448
  result
194
449
  };
450
+ await this.sendResultPayload(payload);
451
+ }
452
+ async sendLegacyResult(leaseId, fencingToken, result) {
453
+ await this.sendResultPayload({ leaseId, fencingToken, result });
454
+ }
455
+ async sendResultPayload(payload) {
195
456
  try {
196
457
  await this.invokeAck("DispatchResult", payload);
197
458
  }
@@ -210,12 +471,90 @@ export class ForgeBridgeService {
210
471
  }, intervalMs);
211
472
  this.heartbeatTimer.unref?.();
212
473
  }
474
+ startLeaseWatchdog() {
475
+ this.stopLeaseWatchdog();
476
+ this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
477
+ this.leaseWatchdogTimer.unref?.();
478
+ }
479
+ stopLeaseWatchdog() {
480
+ if (this.leaseWatchdogTimer) {
481
+ clearInterval(this.leaseWatchdogTimer);
482
+ this.leaseWatchdogTimer = null;
483
+ }
484
+ }
485
+ expireLeases() {
486
+ const now = Date.now();
487
+ for (const [attemptId, reservation] of this.reservations) {
488
+ if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
489
+ this.reservations.delete(attemptId);
490
+ }
491
+ }
492
+ for (const active of this.activeSessions.values()) {
493
+ if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
494
+ active.controller.abort("dispatch lease expired");
495
+ }
496
+ }
497
+ }
213
498
  stopHeartbeat() {
214
499
  if (this.heartbeatTimer) {
215
500
  clearInterval(this.heartbeatTimer);
216
501
  this.heartbeatTimer = null;
217
502
  }
218
503
  }
504
+ startSandboxMaintenance() {
505
+ this.stopSandboxMaintenance();
506
+ if (!this.config.dockerEnabled)
507
+ return;
508
+ this.sandboxMaintenanceTimer = setInterval(() => {
509
+ this.runSandboxMaintenance().catch(() => undefined);
510
+ }, 60_000);
511
+ this.sandboxMaintenanceTimer.unref?.();
512
+ }
513
+ stopSandboxMaintenance() {
514
+ if (this.sandboxMaintenanceTimer) {
515
+ clearInterval(this.sandboxMaintenanceTimer);
516
+ this.sandboxMaintenanceTimer = null;
517
+ }
518
+ }
519
+ async runSandboxMaintenance() {
520
+ if (this.sandboxMaintenanceRunning)
521
+ return;
522
+ this.sandboxMaintenanceRunning = true;
523
+ try {
524
+ const preflight = await this.preflight.refresh();
525
+ if (canReapManagedSandboxes(preflight)) {
526
+ const attemptSnapshot = await this.readAttemptSnapshot();
527
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
528
+ await this.flushCleanupReports();
529
+ }
530
+ }
531
+ finally {
532
+ this.sandboxMaintenanceRunning = false;
533
+ }
534
+ }
535
+ async readAttemptSnapshot() {
536
+ const connection = this.connection;
537
+ if (!connection)
538
+ throw new Error("Bridge is not connected.");
539
+ const response = await connection.invoke("AttemptSnapshot", {
540
+ runnerInstanceId: this.config.runnerInstanceId
541
+ });
542
+ if (!response.accepted) {
543
+ throw new BridgeCallbackRejectedError(response.code ?? null, response.message ?? response.code ?? "Attempt snapshot was rejected.");
544
+ }
545
+ return response.attempts ?? [];
546
+ }
547
+ async flushCleanupReports() {
548
+ if (!this.sandboxRuntime.pendingCleanupReports || !this.sandboxRuntime.acknowledgeCleanupReport)
549
+ return;
550
+ for (const report of await this.sandboxRuntime.pendingCleanupReports()) {
551
+ await this.invokeAck("CleanupReport", {
552
+ runnerInstanceId: this.config.runnerInstanceId,
553
+ report
554
+ });
555
+ await this.sandboxRuntime.acknowledgeCleanupReport(report.reportId);
556
+ }
557
+ }
219
558
  async sendHeartbeat() {
220
559
  await this.invokeAck("Heartbeat", {
221
560
  status: "online",
@@ -225,8 +564,13 @@ export class ForgeBridgeService {
225
564
  });
226
565
  }
227
566
  capabilities() {
567
+ const preflight = this.preflight.current();
568
+ const runtimeState = this.sandboxRuntime.snapshot();
569
+ const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
570
+ const used = this.usedSandboxResources(runtimeState.degraded);
571
+ const capacity = this.sandboxResourceCapacity(preflight);
228
572
  return {
229
- schemaVersion: 2,
573
+ schemaVersion: 3,
230
574
  activeSessionCount: this.activeSessions.size,
231
575
  cliProviders: this.config.cliProviders,
232
576
  providerStatuses: this.config.providerStatuses,
@@ -234,6 +578,25 @@ export class ForgeBridgeService {
234
578
  repoMappings: repoMappingsFor(this.config),
235
579
  os: this.config.os,
236
580
  sandboxProfiles: this.config.sandboxProfiles,
581
+ runnerInstanceId: this.config.runnerInstanceId,
582
+ trustDomainId: this.config.trustDomainId,
583
+ maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
584
+ reservedSandboxCount: reserved,
585
+ activeSandboxCount: runtimeState.active,
586
+ startingSandboxCount: runtimeState.starting,
587
+ degradedReservationCount: runtimeState.degraded,
588
+ sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
589
+ sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
590
+ sandboxFeatures: preflight?.features ?? null,
591
+ sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
592
+ sandboxAuthModes: sandboxAuthModesFor(this.config),
593
+ availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
594
+ availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
595
+ availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
596
+ diskLow: preflight?.diskLow ?? null,
597
+ preflightGeneration: preflight?.generation ?? 0,
598
+ sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
599
+ sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
237
600
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
238
601
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
239
602
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
@@ -243,6 +606,30 @@ export class ForgeBridgeService {
243
606
  supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
244
607
  };
245
608
  }
609
+ usedSandboxResources(degradedReservations) {
610
+ const resources = [
611
+ ...[...this.reservations.values()]
612
+ .filter(reservation => reservation.sandboxed)
613
+ .map(reservation => reservation.resources),
614
+ ...[...this.activeSessions.values()]
615
+ .filter(session => session.sandboxed)
616
+ .map(session => session.resources),
617
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
618
+ ];
619
+ return sumSandboxResources(resources);
620
+ }
621
+ sandboxResourceCapacity(preflight) {
622
+ if (!preflight)
623
+ return zeroSandboxResources();
624
+ return {
625
+ sandboxSlots: preflight.maxConcurrentSandboxes,
626
+ cpuMillis: preflight.availableCpuMillis,
627
+ memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
628
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
629
+ pids: this.config.sandboxPidCapacity,
630
+ buildSlots: this.config.maxConcurrentSandboxBuilds
631
+ };
632
+ }
246
633
  async invokeAck(methodName, payload) {
247
634
  const connection = this.connection;
248
635
  if (!connection) {
@@ -262,6 +649,8 @@ export class ForgeBridgeService {
262
649
  }
263
650
  async stopConnection() {
264
651
  this.stopHeartbeat();
652
+ this.stopLeaseWatchdog();
653
+ this.stopSandboxMaintenance();
265
654
  if (this.connection) {
266
655
  await this.connection.stop().catch(() => undefined);
267
656
  this.connection = null;
@@ -306,6 +695,9 @@ export class SignalRBridgeConnection {
306
695
  })
307
696
  .configureLogging(LogLevel.Warning)
308
697
  .build();
698
+ this.connection.on("Superseded", () => {
699
+ void this.connection.stop();
700
+ });
309
701
  }
310
702
  start() {
311
703
  return this.connection.start();
@@ -321,11 +713,31 @@ export class SignalRBridgeConnection {
321
713
  void handler(raw).catch(() => undefined);
322
714
  });
323
715
  }
716
+ onOffer(handler) {
717
+ this.connection.on("Offer", (raw) => {
718
+ void handler(raw).catch(() => undefined);
719
+ });
720
+ }
721
+ onStart(handler) {
722
+ this.connection.on("Start", (raw) => {
723
+ void handler(raw).catch(() => undefined);
724
+ });
725
+ }
726
+ onLeaseUpdate(handler) {
727
+ this.connection.on("LeaseUpdate", (request) => {
728
+ void handler(request).catch(() => undefined);
729
+ });
730
+ }
324
731
  onCancel(handler) {
325
732
  this.connection.on("Cancel", (request) => {
326
733
  void handler(request).catch(() => undefined);
327
734
  });
328
735
  }
736
+ onReap(handler) {
737
+ this.connection.on("Reap", (request) => {
738
+ void handler(request).catch(() => undefined);
739
+ });
740
+ }
329
741
  invoke(methodName, payload) {
330
742
  return this.connection.invoke(methodName, payload);
331
743
  }
@@ -333,9 +745,28 @@ export class SignalRBridgeConnection {
333
745
  export function defaultBridgeServiceConfig(overrides = {}) {
334
746
  const defaults = defaultForgeBridgeConfig();
335
747
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
748
+ const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
336
749
  const config = {
337
750
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
338
751
  runnerId: process.env.FORGE_RUNNER_ID ?? "",
752
+ runnerInstanceId: process.env.FORGE_RUNNER_INSTANCE_ID ?? randomUUID(),
753
+ trustDomainId: process.env.FORGE_TRUST_DOMAIN_ID ?? "default",
754
+ maxConcurrentSandboxes: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOXES ?? 3), 3),
755
+ maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
756
+ sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
757
+ dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
758
+ sandboxPublicationMode,
759
+ dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
760
+ dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
761
+ sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
762
+ requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
763
+ diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
764
+ diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
765
+ minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
766
+ imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
767
+ imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
768
+ auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
769
+ auditRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000), 30 * 24 * 60 * 60 * 1000),
339
770
  deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
340
771
  deviceName: process.env.FORGE_DEVICE_NAME ?? "",
341
772
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
@@ -354,7 +785,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
354
785
  supportsDraftAttachments: true,
355
786
  supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
356
787
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
357
- protocolVersion: 1,
788
+ protocolVersion: 2,
358
789
  minServerVersion: null,
359
790
  heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
360
791
  reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
@@ -383,6 +814,30 @@ function positiveFiniteNumber(value, fallback) {
383
814
  function providerStatusesFor(providers) {
384
815
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
385
816
  }
817
+ function sandboxAuthModesFor(config) {
818
+ return Object.fromEntries(config.cliProviders.map((provider) => {
819
+ const modes = ["api_key"];
820
+ if (sandboxBridgeCliAuthAvailable(config, provider)) {
821
+ modes.push("bridge_cli");
822
+ }
823
+ return [provider, modes];
824
+ }));
825
+ }
826
+ function sandboxBridgeCliAuthAvailable(config, provider) {
827
+ if (provider.trim().toLowerCase() !== "codex") {
828
+ return false;
829
+ }
830
+ const status = providerStatus(config, provider);
831
+ if (status?.signedIn === false) {
832
+ return false;
833
+ }
834
+ return codexSandboxProviderAuthAdapter.doctor().available;
835
+ }
836
+ function providerStatus(config, provider) {
837
+ const normalized = provider.trim().toLowerCase();
838
+ return Object.entries(config.providerStatuses)
839
+ .find(([key]) => key.trim().toLowerCase() === normalized)?.[1];
840
+ }
386
841
  function defaultSandboxProfiles(providers, platform = process.platform) {
387
842
  if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
388
843
  return ["none"];
@@ -546,6 +1001,101 @@ function abortPromise(signal) {
546
1001
  }
547
1002
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
548
1003
  }
1004
+ export function canReapManagedSandboxes(preflight) {
1005
+ return canRunDockerMaintenance(preflight);
1006
+ }
1007
+ function sandboxNetworkPolicyFailure(dispatch) {
1008
+ const sandbox = dispatch.sandbox;
1009
+ if (!sandbox)
1010
+ return "Protocol-v2 sandbox context is missing.";
1011
+ if (sandbox.networkPolicy.mode === "open_public"
1012
+ && (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
1013
+ return "open_public sandbox networking is not permitted by this runner policy.";
1014
+ }
1015
+ if (sandbox.networkPolicy.mode === "public_allowlist"
1016
+ && sandbox.networkPolicy.allowedHosts.length > 0
1017
+ && process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
1018
+ return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
1019
+ }
1020
+ return null;
1021
+ }
1022
+ function sandboxAuthFailure(dispatch, config) {
1023
+ const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1024
+ if (unsupported) {
1025
+ return unsupported;
1026
+ }
1027
+ if (dispatch.execution.authKind.trim().toLowerCase() !== "bridge_cli") {
1028
+ return null;
1029
+ }
1030
+ const status = providerStatus(config, dispatch.execution.provider);
1031
+ if (status?.signedIn === false) {
1032
+ return `${dispatch.execution.provider} Local CLI is not signed in on this runner.`;
1033
+ }
1034
+ const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
1035
+ if (!adapter) {
1036
+ return `${dispatch.execution.provider} Local CLI auth is not portable into Docker yet.`;
1037
+ }
1038
+ const doctor = adapter.doctor();
1039
+ return doctor.available ? null : doctor.message;
1040
+ }
1041
+ export function canReserveSandboxResources(used, requested, capacity) {
1042
+ return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
1043
+ && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
1044
+ && used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
1045
+ && used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
1046
+ && used.pids + requested.pids <= capacity.pids
1047
+ && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1048
+ }
1049
+ function sumSandboxResources(resources) {
1050
+ return resources.reduce((total, resource) => ({
1051
+ sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1052
+ cpuMillis: total.cpuMillis + resource.cpuMillis,
1053
+ memoryBytes: total.memoryBytes + resource.memoryBytes,
1054
+ workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1055
+ pids: total.pids + resource.pids,
1056
+ buildSlots: total.buildSlots + resource.buildSlots
1057
+ }), zeroSandboxResources());
1058
+ }
1059
+ function zeroSandboxResources() {
1060
+ return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
1061
+ }
1062
+ function defaultSandboxResources() {
1063
+ return {
1064
+ sandboxSlots: 1,
1065
+ cpuMillis: 2000,
1066
+ memoryBytes: 2 * 1024 ** 3,
1067
+ workspaceBytes: 10 * 1024 ** 3,
1068
+ pids: 512,
1069
+ buildSlots: 0
1070
+ };
1071
+ }
1072
+ function parseOfferEnvelope(value) {
1073
+ return parseDispatchEnvelope(value);
1074
+ }
1075
+ function parseStartEnvelope(value) {
1076
+ return parseDispatchEnvelope(value);
1077
+ }
1078
+ function parseDispatchEnvelope(value) {
1079
+ if (!value || typeof value !== "object" || Array.isArray(value))
1080
+ return null;
1081
+ const envelope = value;
1082
+ if (envelope.schemaVersion !== 1
1083
+ || typeof envelope.dispatchAttemptId !== "string"
1084
+ || typeof envelope.leaseId !== "string"
1085
+ || typeof envelope.fencingToken !== "string"
1086
+ || typeof envelope.runnerInstanceId !== "string"
1087
+ || typeof envelope.expiresAt !== "string"
1088
+ || !envelope.dispatch)
1089
+ return null;
1090
+ return {
1091
+ dispatchAttemptId: envelope.dispatchAttemptId,
1092
+ leaseId: envelope.leaseId,
1093
+ fencingToken: envelope.fencingToken,
1094
+ runnerInstanceId: envelope.runnerInstanceId,
1095
+ expiresAt: envelope.expiresAt,
1096
+ dispatch: envelope.dispatch
1097
+ };
1098
+ }
549
1099
  const maxAttachmentExactSecretLength = 8 * 1024;
550
1100
  function exactSecrets(dispatch, config) {
551
1101
  return [