@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba7d36726eda

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +52 -3
  4. package/dist/bridge/bridge.service.js +776 -19
  5. package/dist/bridge/bridge.types.d.ts +131 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +46 -0
  14. package/dist/cli/cli.service.d.ts +42 -2
  15. package/dist/cli/cli.service.js +350 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +39 -0
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +50 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +74 -0
  23. package/dist/runtime/attempt-process-supervisor.js +290 -0
  24. package/dist/runtime/host-listener-inspector.d.ts +28 -0
  25. package/dist/runtime/host-listener-inspector.js +185 -0
  26. package/dist/runtime/host-port-broker.d.ts +76 -0
  27. package/dist/runtime/host-port-broker.js +333 -0
  28. package/dist/runtime/host-runtime-contract.d.ts +39 -0
  29. package/dist/runtime/host-runtime-contract.js +295 -0
  30. package/dist/runtime/host-runtime-lease.d.ts +122 -0
  31. package/dist/runtime/host-runtime-lease.js +1241 -0
  32. package/dist/runtime/host-runtime-promotion.d.ts +21 -0
  33. package/dist/runtime/host-runtime-promotion.js +128 -0
  34. package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
  35. package/dist/runtime/node-host-runtime-adapter.js +205 -0
  36. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  37. package/dist/runtime/playwright-host-runtime-adapter.js +136 -0
  38. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  39. package/dist/runtime/runtime-isolation-lease.js +158 -0
  40. package/dist/sandbox/command-runner.d.ts +4 -0
  41. package/dist/sandbox/command-runner.js +47 -0
  42. package/dist/sandbox/contract.d.ts +5 -0
  43. package/dist/sandbox/contract.js +276 -0
  44. package/dist/sandbox/event-budget.d.ts +11 -0
  45. package/dist/sandbox/event-budget.js +27 -0
  46. package/dist/sandbox/image-policy.d.ts +35 -0
  47. package/dist/sandbox/image-policy.js +247 -0
  48. package/dist/sandbox/index.d.ts +5 -0
  49. package/dist/sandbox/index.js +5 -0
  50. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  51. package/dist/sandbox/lease-watchdog.js +67 -0
  52. package/dist/sandbox/preflight.d.ts +45 -0
  53. package/dist/sandbox/preflight.js +300 -0
  54. package/dist/sandbox/process.d.ts +20 -0
  55. package/dist/sandbox/process.js +209 -0
  56. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  57. package/dist/sandbox/provider-auth/codex.js +212 -0
  58. package/dist/sandbox/repository-lock.d.ts +5 -0
  59. package/dist/sandbox/repository-lock.js +119 -0
  60. package/dist/sandbox/runtime.d.ts +297 -0
  61. package/dist/sandbox/runtime.js +2338 -0
  62. package/dist/sandbox/sandbox.types.d.ts +86 -0
  63. package/dist/sandbox/sandbox.types.js +1 -0
  64. package/dist/sandbox/smoke.d.ts +76 -0
  65. package/dist/sandbox/smoke.js +380 -0
  66. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  67. package/dist/sandbox/worker-supervisor.js +36 -0
  68. package/dist/sandbox/worker.d.ts +1 -0
  69. package/dist/sandbox/worker.js +225 -0
  70. package/dist/sandbox/workspace.service.d.ts +13 -0
  71. package/dist/sandbox/workspace.service.js +67 -0
  72. package/dist/session/phase-completion.artifacts.js +23 -2
  73. package/dist/session/phase-completion.helpers.js +6 -1
  74. package/dist/session/phase-completion.runtime.js +3 -1
  75. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  76. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  77. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  78. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  79. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  80. package/dist/session/provider-cli/provider-cli.process.d.ts +3 -0
  81. package/dist/session/provider-cli/provider-cli.process.js +65 -14
  82. package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
  83. package/dist/session/provider-cli/provider-cli.prompts.js +99 -15
  84. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  85. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  86. package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
  87. package/dist/session/provider-cli/provider-cli.service.js +167 -28
  88. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  89. package/dist/session/session-redaction.d.ts +3 -0
  90. package/dist/session/session-redaction.js +23 -0
  91. package/dist/session/session.base.d.ts +4 -0
  92. package/dist/session/session.service.d.ts +6 -1
  93. package/dist/session/session.service.js +184 -38
  94. package/dist/session/session.types.d.ts +58 -1
  95. package/dist/session/session.types.js +172 -2
  96. package/dist/shared/path/path.js +3 -1
  97. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  98. package/dist/shared/process/host-process-recovery.js +78 -0
  99. package/dist/shared/process/host-process-supervisor.d.ts +40 -0
  100. package/dist/shared/process/host-process-supervisor.js +195 -0
  101. package/dist/shared/provider-capabilities.d.ts +1 -0
  102. package/dist/shared/provider-capabilities.js +3 -0
  103. package/dist/shared/redaction/redaction.js +6 -1
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +6 -2
  114. package/dist/worktree/worktree.service.js +60 -43
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +6 -3
  117. package/runtime/windows-job-supervisor.ps1 +287 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,28 +1,81 @@
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
- import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
7
+ import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
8
+ import { redactText } from "../shared/redaction/redaction.js";
6
9
  import { BridgeSessionService } from "../session/session.service.js";
10
+ import { ProcessBackedProviderCliService, RoutingProviderCliService, StreamingProviderProcessRunner, } from "../session/provider-cli/provider-cli.service.js";
11
+ import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
12
+ import { HostRuntimeLeaseManager } from "../runtime/host-runtime-lease.js";
13
+ import { GitWorktreeService } from "../worktree/worktree.service.js";
14
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
15
+ import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
7
16
  import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
8
17
  import { forgeBridgeVersion } from "../version.js";
18
+ import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
19
+ import { validateSandboxContract } from "../sandbox/contract.js";
20
+ import { runSandboxProcess } from "../sandbox/process.js";
21
+ import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
22
+ import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
9
23
  export class ForgeBridgeService {
10
24
  config;
11
- sessions;
12
25
  connections;
13
26
  connection = null;
14
27
  heartbeatTimer = null;
28
+ leaseWatchdogTimer = null;
29
+ sandboxMaintenanceTimer = null;
30
+ sandboxMaintenanceRunning = false;
15
31
  closed = null;
16
32
  stopped = false;
17
33
  activeSessions = new Map();
18
- constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory()) {
34
+ reservations = new Map();
35
+ preflight;
36
+ sandboxRuntime;
37
+ sandboxResourceResolver;
38
+ sessions;
39
+ constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
19
40
  this.config = config;
20
- this.sessions = sessions;
21
41
  this.connections = connections;
42
+ this.sessions = sessions ?? createDefaultSessionService(config.sandboxStateRoot);
43
+ this.preflight = preflight ?? new DockerPreflight({
44
+ dockerExecutable: config.dockerExecutable,
45
+ dockerContext: config.dockerContext,
46
+ stateRoot: config.sandboxStateRoot,
47
+ runnerInstanceId: config.runnerInstanceId,
48
+ maxConcurrentSandboxes: config.maxConcurrentSandboxes,
49
+ diskHighWatermarkPercent: config.diskHighWatermarkPercent,
50
+ diskLowWatermarkPercent: config.diskLowWatermarkPercent,
51
+ minimumWorkspaceBytes: config.minimumWorkspaceBytes,
52
+ hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
53
+ defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
54
+ defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
55
+ runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
56
+ requireRootless: config.requireRootlessDocker
57
+ });
58
+ this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
59
+ this.sandboxResourceResolver = sandboxResourceResolver
60
+ ?? (dispatch => resolveSandboxResourcesFromRepo(dispatch, this.config.sandboxRuntimeOverheadBytes));
22
61
  }
23
62
  async connectOnce() {
24
63
  await this.stopConnection();
25
64
  this.stopped = false;
65
+ const hostReap = await this.sessions.reapHostRuntime?.({
66
+ runnerId: this.config.runnerId,
67
+ runnerInstanceId: this.config.runnerInstanceId,
68
+ });
69
+ if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
70
+ logBridgeStatus(`Managed-host recovery reaped ${hostReap.reaped} stale attempt(s), protected ${hostReap.reusedPidsProtected} reused PID(s), and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
71
+ }
72
+ if (this.config.dockerEnabled) {
73
+ const preflight = await this.preflight.refresh();
74
+ if (canReapManagedSandboxes(preflight)) {
75
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
76
+ await this.preflight.refresh();
77
+ }
78
+ }
26
79
  const connection = this.connections.create(this.config);
27
80
  this.connection = connection;
28
81
  this.closed = new Promise((resolve) => {
@@ -33,15 +86,23 @@ export class ForgeBridgeService {
33
86
  });
34
87
  });
35
88
  connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
89
+ connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
90
+ connection.onStart?.((dispatch) => this.handleStart(dispatch));
91
+ connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
36
92
  connection.onCancel((request) => this.handleCancel(request));
93
+ connection.onReap?.((request) => this.handleReap(request));
37
94
  await connection.start();
38
95
  await this.invokeAck("Hello", {
39
96
  version: this.config.version,
40
97
  protocolVersion: this.config.protocolVersion,
41
98
  minServerVersion: this.config.minServerVersion ?? null,
99
+ runnerInstanceId: this.config.runnerInstanceId,
42
100
  capabilities: this.capabilities()
43
101
  });
102
+ await this.flushCleanupReports();
44
103
  this.startHeartbeat();
104
+ this.startLeaseWatchdog();
105
+ this.startSandboxMaintenance();
45
106
  }
46
107
  async run(signal) {
47
108
  let attempt = 0;
@@ -80,6 +141,7 @@ export class ForgeBridgeService {
80
141
  this.stopped = true;
81
142
  this.abortActiveSessions("bridge stopping");
82
143
  await this.stopConnection();
144
+ await this.preflight.release();
83
145
  }
84
146
  async handleDispatch(rawDispatch) {
85
147
  const dispatch = this.parseDispatchOrReport(rawDispatch);
@@ -89,21 +151,53 @@ export class ForgeBridgeService {
89
151
  const leaseId = dispatch.lease.id;
90
152
  const fencingToken = dispatch.lease.fencingToken;
91
153
  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);
154
+ const existing = this.activeSessions.get(leaseId);
155
+ if (existing) {
156
+ if (existing.fencingToken === fencingToken) {
157
+ return;
158
+ }
159
+ await this.sendResult(dispatch, failureResult("Dispatch fence conflicts with an active bridge session.")).catch(() => undefined);
94
160
  return;
95
161
  }
96
162
  const controller = new AbortController();
97
- this.activeSessions.set(leaseId, { fencingToken, controller });
163
+ this.activeSessions.set(leaseId, {
164
+ fencingToken,
165
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
166
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
167
+ leaseExpiresAt: dispatch.lease.expiresAt,
168
+ sandboxed: dispatch.sandbox?.executionMode === "docker",
169
+ resources: dispatch.sandbox?.executionMode === "docker"
170
+ ? dispatch.sandbox.requestedResources
171
+ : zeroSandboxResources(),
172
+ controller
173
+ });
98
174
  try {
99
175
  const normalized = this.normalizeDispatch(dispatch);
100
- const result = await this.sessions.runOnce(normalized, {
176
+ if (normalized.sandbox) {
177
+ const started = {
178
+ dispatchAttemptId: normalized.sandbox.dispatchAttemptId,
179
+ leaseId,
180
+ fencingToken,
181
+ runnerInstanceId: normalized.sandbox.runnerInstanceId
182
+ };
183
+ await this.invokeAck("DispatchStarted", started);
184
+ }
185
+ const sink = {
101
186
  emit: async (event) => {
187
+ if (event.eventType === "provider_cli_started") {
188
+ const active = this.activeSessions.get(leaseId);
189
+ if (active)
190
+ active.resources = { ...active.resources, buildSlots: 0 };
191
+ }
102
192
  try {
103
193
  await this.invokeAck("DispatchEvent", {
104
194
  leaseId,
105
195
  fencingToken,
106
- event: redactSecrets(event, redactionSecrets)
196
+ ...(dispatch.sandbox ? {
197
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
198
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId
199
+ } : {}),
200
+ event: redactRunnerDispatchEvent(event, redactionSecrets)
107
201
  });
108
202
  }
109
203
  catch (error) {
@@ -114,19 +208,221 @@ export class ForgeBridgeService {
114
208
  throw error;
115
209
  }
116
210
  }
117
- }, controller.signal);
118
- await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
211
+ };
212
+ const result = normalized.sandbox?.executionMode === "docker"
213
+ ? await this.sandboxRuntime.runOnce(normalized, sink, controller.signal)
214
+ : await this.sessions.runOnce(normalized, sink, controller.signal);
215
+ if (normalized.sandbox?.executionMode === "docker") {
216
+ await this.flushCleanupReports().catch(() => undefined);
217
+ }
218
+ await this.sendResult(dispatch, redactRunOnceResultPacket(result, redactionSecrets));
119
219
  }
120
220
  catch (error) {
121
221
  const summary = controller.signal.aborted
122
222
  ? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
123
223
  : redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
124
- await this.sendResult(leaseId, fencingToken, failureResult(summary)).catch(() => undefined);
224
+ await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
125
225
  }
126
226
  finally {
127
227
  this.activeSessions.delete(leaseId);
128
228
  }
129
229
  }
230
+ async handleOffer(rawDispatch) {
231
+ const envelope = parseOfferEnvelope(rawDispatch);
232
+ if (!envelope) {
233
+ return;
234
+ }
235
+ const dispatch = this.parseDispatchOrReport(envelope.dispatch);
236
+ if (!dispatch?.sandbox
237
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
238
+ || dispatch.lease.id !== envelope.leaseId
239
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
240
+ return;
241
+ const sandbox = dispatch.sandbox;
242
+ let existing = this.reservations.get(sandbox.dispatchAttemptId);
243
+ if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
244
+ this.reservations.delete(sandbox.dispatchAttemptId);
245
+ existing = undefined;
246
+ }
247
+ if (existing) {
248
+ const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
249
+ await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null, sameFence ? existing.resources : null);
250
+ return;
251
+ }
252
+ if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
253
+ await this.sendOfferDecision(dispatch, false, "stale_runner_instance", "Offer targets a different runner process instance.");
254
+ return;
255
+ }
256
+ if (Date.parse(envelope.expiresAt) <= Date.now()) {
257
+ await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
258
+ return;
259
+ }
260
+ const preflight = sandbox.executionMode === "docker" && this.config.dockerEnabled
261
+ ? await this.preflight.run()
262
+ : this.preflight.current();
263
+ if (sandbox.executionMode === "docker") {
264
+ if (!this.config.dockerEnabled || !preflight?.healthy) {
265
+ await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
266
+ return;
267
+ }
268
+ const networkFailure = sandboxNetworkPolicyFailure(dispatch, this.config);
269
+ if (networkFailure) {
270
+ await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
271
+ return;
272
+ }
273
+ const authFailure = sandboxAuthFailure(dispatch, this.config);
274
+ if (authFailure) {
275
+ await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
276
+ return;
277
+ }
278
+ }
279
+ const runtimeState = this.sandboxRuntime.snapshot();
280
+ const usedResources = this.usedSandboxResources(runtimeState.degraded);
281
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
282
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources);
283
+ let requestedResources = sandbox.requestedResources;
284
+ let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
285
+ if (sandbox.executionMode === "docker") {
286
+ try {
287
+ const profile = await this.sandboxResourceResolver(this.normalizeDispatch(dispatch));
288
+ requestedResources = profile.resources;
289
+ sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
290
+ }
291
+ catch (error) {
292
+ await this.sendOfferDecision(dispatch, false, "sandbox_capacity_profile_unavailable", error instanceof Error ? error.message : "The immutable sandbox contract could not be profiled for admission.");
293
+ return;
294
+ }
295
+ }
296
+ if (sandbox.executionMode === "docker"
297
+ && !canReserveSandboxResources(usedResources, requestedResources, capacity)) {
298
+ await this.sendOfferDecision(dispatch, false, "capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, preflight, this.config));
299
+ return;
300
+ }
301
+ const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
302
+ this.reservations.set(sandbox.dispatchAttemptId, {
303
+ leaseId: dispatch.lease.id,
304
+ fencingToken: dispatch.lease.fencingToken,
305
+ runnerInstanceId: sandbox.runnerInstanceId,
306
+ expiresAt: dispatch.lease.expiresAt,
307
+ reservedUntil,
308
+ sandboxed: sandbox.executionMode === "docker",
309
+ resources: requestedResources
310
+ });
311
+ await this.sendOfferDecision(dispatch, true, null, null, reservedUntil, requestedResources, sandboxConfigCommitSha);
312
+ }
313
+ async handleStart(rawDispatch) {
314
+ const envelope = parseStartEnvelope(rawDispatch);
315
+ if (!envelope) {
316
+ return;
317
+ }
318
+ let dispatch = this.parseDispatchOrReport(envelope.dispatch);
319
+ if (!dispatch?.sandbox
320
+ || dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
321
+ || dispatch.lease.id !== envelope.leaseId
322
+ || dispatch.lease.fencingToken !== envelope.fencingToken)
323
+ return;
324
+ const reservation = this.reservations.get(dispatch.sandbox.dispatchAttemptId);
325
+ if (!reservation
326
+ || reservation.leaseId !== dispatch.lease.id
327
+ || reservation.fencingToken !== dispatch.lease.fencingToken
328
+ || reservation.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
329
+ || Date.parse(reservation.reservedUntil) <= Date.now()) {
330
+ await this.sendResult(dispatch, failureResult("Start rejected because its reservation is missing or stale.")).catch(() => undefined);
331
+ return;
332
+ }
333
+ if (dispatch.apiKeySecretGrant) {
334
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
335
+ await this.sendResult(dispatch, failureResult("Start rejected because protocol-v2 packets may not contain a plaintext provider grant.")).catch(() => undefined);
336
+ return;
337
+ }
338
+ if (dispatch.apiKeySecretGrantHandle) {
339
+ const response = await this.redeemSecretGrant(dispatch);
340
+ const grant = response.grant;
341
+ if (!response.accepted
342
+ || !grant
343
+ || grant.grantId !== dispatch.apiKeySecretGrantHandle.grantId
344
+ || grant.provider !== dispatch.apiKeySecretGrantHandle.provider
345
+ || Date.parse(grant.expiresAt) <= Date.now()) {
346
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
347
+ await this.sendResult(dispatch, failureResult(response.message ?? "Secret grant redemption was rejected.")).catch(() => undefined);
348
+ return;
349
+ }
350
+ dispatch = { ...dispatch, apiKeySecretGrant: grant, apiKeySecretGrantHandle: null };
351
+ }
352
+ else if (dispatch.execution.requiresApiKeySecretGrant) {
353
+ this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
354
+ await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
355
+ return;
356
+ }
357
+ dispatch = {
358
+ ...dispatch,
359
+ sandbox: { ...dispatch.sandbox, requestedResources: reservation.resources },
360
+ };
361
+ this.reservations.delete(envelope.dispatchAttemptId);
362
+ await this.handleDispatch(dispatch);
363
+ }
364
+ async redeemSecretGrant(dispatch) {
365
+ const sandbox = dispatch.sandbox;
366
+ const handle = dispatch.apiKeySecretGrantHandle;
367
+ const connection = this.connection;
368
+ if (!connection)
369
+ return { accepted: false, code: "bridge.disconnected", message: "Bridge is disconnected.", grant: null };
370
+ return await connection.invoke("RedeemSecretGrant", {
371
+ dispatchAttemptId: sandbox.dispatchAttemptId,
372
+ leaseId: dispatch.lease.id,
373
+ fencingToken: dispatch.lease.fencingToken,
374
+ runnerInstanceId: sandbox.runnerInstanceId,
375
+ grantId: handle.grantId
376
+ });
377
+ }
378
+ async redeemPublishGrant(request, signal) {
379
+ if (signal?.aborted)
380
+ throw new Error(String(signal.reason ?? "publication cancelled"));
381
+ const connection = this.connection;
382
+ if (!connection)
383
+ throw new Error("sandbox_output_publish_forbidden: bridge is disconnected.");
384
+ const response = await connection.invoke("RedeemPublishGrant", request);
385
+ if (!response.accepted || !response.credential) {
386
+ throw new Error(`sandbox_output_publish_forbidden: ${response.message ?? response.code ?? "publication grant redemption was rejected"}`);
387
+ }
388
+ return response.credential;
389
+ }
390
+ async handleLeaseUpdate(request) {
391
+ if (request.runnerInstanceId !== this.config.runnerInstanceId) {
392
+ return;
393
+ }
394
+ const reservation = this.reservations.get(request.dispatchAttemptId);
395
+ if (reservation && reservation.leaseId === request.leaseId && reservation.fencingToken === request.fencingToken) {
396
+ reservation.expiresAt = request.expiresAt;
397
+ }
398
+ const active = this.activeSessions.get(request.leaseId);
399
+ if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
400
+ active.leaseExpiresAt = request.expiresAt;
401
+ if (active.sandboxed) {
402
+ await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
403
+ }
404
+ else {
405
+ await this.sessions.updateLease?.(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
406
+ }
407
+ }
408
+ }
409
+ async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null, reservedResources = null, sandboxConfigCommitSha = null) {
410
+ if (!dispatch.sandbox)
411
+ return;
412
+ const payload = {
413
+ dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
414
+ leaseId: dispatch.lease.id,
415
+ fencingToken: dispatch.lease.fencingToken,
416
+ runnerInstanceId: dispatch.sandbox.runnerInstanceId,
417
+ accepted,
418
+ code,
419
+ message,
420
+ reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null,
421
+ reservedResources: accepted ? reservedResources : null,
422
+ sandboxConfigCommitSha: accepted ? sandboxConfigCommitSha : null,
423
+ };
424
+ await this.invokeAck("OfferDecision", payload);
425
+ }
130
426
  parseDispatchOrReport(rawDispatch) {
131
427
  try {
132
428
  return parseRunOnceDispatchPacket(rawDispatch);
@@ -137,7 +433,7 @@ export class ForgeBridgeService {
137
433
  return null;
138
434
  }
139
435
  const summary = invalidDispatchSummary(error);
140
- void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
436
+ void this.sendLegacyResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
141
437
  {
142
438
  role: "runner",
143
439
  kind: "error",
@@ -155,6 +451,14 @@ export class ForgeBridgeService {
155
451
  }
156
452
  active.controller.abort(request.reason ?? "cancelled by server");
157
453
  }
454
+ async handleReap(request) {
455
+ if (!this.config.dockerEnabled
456
+ || request.runnerId !== this.config.runnerId
457
+ || request.runnerInstanceId !== this.config.runnerInstanceId)
458
+ return;
459
+ await this.runSandboxMaintenance();
460
+ await this.sendHeartbeat();
461
+ }
158
462
  abortActiveSessions(reason) {
159
463
  for (const active of this.activeSessions.values()) {
160
464
  active.controller.abort(reason);
@@ -185,12 +489,20 @@ export class ForgeBridgeService {
185
489
  : dispatch.qaBrowser
186
490
  };
187
491
  }
188
- async sendResult(leaseId, fencingToken, result) {
492
+ async sendResult(dispatch, result) {
189
493
  const payload = {
190
- leaseId,
191
- fencingToken,
494
+ leaseId: dispatch.lease.id,
495
+ fencingToken: dispatch.lease.fencingToken,
496
+ dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
497
+ runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
192
498
  result
193
499
  };
500
+ await this.sendResultPayload(payload);
501
+ }
502
+ async sendLegacyResult(leaseId, fencingToken, result) {
503
+ await this.sendResultPayload({ leaseId, fencingToken, result });
504
+ }
505
+ async sendResultPayload(payload) {
194
506
  try {
195
507
  await this.invokeAck("DispatchResult", payload);
196
508
  }
@@ -209,12 +521,90 @@ export class ForgeBridgeService {
209
521
  }, intervalMs);
210
522
  this.heartbeatTimer.unref?.();
211
523
  }
524
+ startLeaseWatchdog() {
525
+ this.stopLeaseWatchdog();
526
+ this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
527
+ this.leaseWatchdogTimer.unref?.();
528
+ }
529
+ stopLeaseWatchdog() {
530
+ if (this.leaseWatchdogTimer) {
531
+ clearInterval(this.leaseWatchdogTimer);
532
+ this.leaseWatchdogTimer = null;
533
+ }
534
+ }
535
+ expireLeases() {
536
+ const now = Date.now();
537
+ for (const [attemptId, reservation] of this.reservations) {
538
+ if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
539
+ this.reservations.delete(attemptId);
540
+ }
541
+ }
542
+ for (const active of this.activeSessions.values()) {
543
+ if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
544
+ active.controller.abort("dispatch lease expired");
545
+ }
546
+ }
547
+ }
212
548
  stopHeartbeat() {
213
549
  if (this.heartbeatTimer) {
214
550
  clearInterval(this.heartbeatTimer);
215
551
  this.heartbeatTimer = null;
216
552
  }
217
553
  }
554
+ startSandboxMaintenance() {
555
+ this.stopSandboxMaintenance();
556
+ if (!this.config.dockerEnabled)
557
+ return;
558
+ this.sandboxMaintenanceTimer = setInterval(() => {
559
+ this.runSandboxMaintenance().catch(() => undefined);
560
+ }, 60_000);
561
+ this.sandboxMaintenanceTimer.unref?.();
562
+ }
563
+ stopSandboxMaintenance() {
564
+ if (this.sandboxMaintenanceTimer) {
565
+ clearInterval(this.sandboxMaintenanceTimer);
566
+ this.sandboxMaintenanceTimer = null;
567
+ }
568
+ }
569
+ async runSandboxMaintenance() {
570
+ if (this.sandboxMaintenanceRunning)
571
+ return;
572
+ this.sandboxMaintenanceRunning = true;
573
+ try {
574
+ const preflight = await this.preflight.refresh();
575
+ if (canReapManagedSandboxes(preflight)) {
576
+ const attemptSnapshot = await this.readAttemptSnapshot();
577
+ await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
578
+ await this.flushCleanupReports();
579
+ }
580
+ }
581
+ finally {
582
+ this.sandboxMaintenanceRunning = false;
583
+ }
584
+ }
585
+ async readAttemptSnapshot() {
586
+ const connection = this.connection;
587
+ if (!connection)
588
+ throw new Error("Bridge is not connected.");
589
+ const response = await connection.invoke("AttemptSnapshot", {
590
+ runnerInstanceId: this.config.runnerInstanceId
591
+ });
592
+ if (!response.accepted) {
593
+ throw new BridgeCallbackRejectedError(response.code ?? null, response.message ?? response.code ?? "Attempt snapshot was rejected.");
594
+ }
595
+ return response.attempts ?? [];
596
+ }
597
+ async flushCleanupReports() {
598
+ if (!this.sandboxRuntime.pendingCleanupReports || !this.sandboxRuntime.acknowledgeCleanupReport)
599
+ return;
600
+ for (const report of await this.sandboxRuntime.pendingCleanupReports()) {
601
+ await this.invokeAck("CleanupReport", {
602
+ runnerInstanceId: this.config.runnerInstanceId,
603
+ report
604
+ });
605
+ await this.sandboxRuntime.acknowledgeCleanupReport(report.reportId);
606
+ }
607
+ }
218
608
  async sendHeartbeat() {
219
609
  await this.invokeAck("Heartbeat", {
220
610
  status: "online",
@@ -224,8 +614,14 @@ export class ForgeBridgeService {
224
614
  });
225
615
  }
226
616
  capabilities() {
617
+ const preflight = this.preflight.current();
618
+ const runtimeState = this.sandboxRuntime.snapshot();
619
+ const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
620
+ const used = this.usedSandboxResources(runtimeState.degraded);
621
+ const activeResources = this.activeSandboxResources(runtimeState.degraded);
622
+ const capacity = this.sandboxResourceCapacity(preflight, activeResources);
227
623
  return {
228
- schemaVersion: 2,
624
+ schemaVersion: 3,
229
625
  activeSessionCount: this.activeSessions.size,
230
626
  cliProviders: this.config.cliProviders,
231
627
  providerStatuses: this.config.providerStatuses,
@@ -233,6 +629,36 @@ export class ForgeBridgeService {
233
629
  repoMappings: repoMappingsFor(this.config),
234
630
  os: this.config.os,
235
631
  sandboxProfiles: this.config.sandboxProfiles,
632
+ runnerInstanceId: this.config.runnerInstanceId,
633
+ trustDomainId: this.config.trustDomainId,
634
+ maxConcurrentSandboxes: capacity.sandboxSlots,
635
+ reservedSandboxCount: reserved,
636
+ activeSandboxCount: runtimeState.active,
637
+ startingSandboxCount: runtimeState.starting,
638
+ degradedReservationCount: runtimeState.degraded,
639
+ sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
640
+ sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
641
+ sandboxFeatures: preflight?.features ?? null,
642
+ sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
643
+ sandboxAuthModes: sandboxAuthModesFor(this.config),
644
+ availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
645
+ availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
646
+ availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
647
+ diskLow: preflight?.diskLow ?? null,
648
+ preflightGeneration: preflight?.generation ?? 0,
649
+ sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
650
+ sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
651
+ sandboxMemoryCapacity: preflight ? {
652
+ defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
653
+ defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
654
+ runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
655
+ hostReserveBytes: preflight.hostReserveBytes,
656
+ activeReservationBytes: used.memoryBytes,
657
+ daemonTotalBytes: preflight.totalMemoryBytes,
658
+ daemonAvailableBytes: preflight.availableMemoryBytes,
659
+ availableForAdmissionBytes: Math.max(0, capacity.memoryBytes - used.memoryBytes),
660
+ } : null,
661
+ hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
236
662
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
237
663
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
238
664
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
@@ -242,6 +668,48 @@ export class ForgeBridgeService {
242
668
  supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
243
669
  };
244
670
  }
671
+ usedSandboxResources(degradedReservations) {
672
+ const resources = [
673
+ ...[...this.reservations.values()]
674
+ .filter(reservation => reservation.sandboxed)
675
+ .map(reservation => reservation.resources),
676
+ ...[...this.activeSessions.values()]
677
+ .filter(session => session.sandboxed)
678
+ .map(session => session.resources),
679
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
680
+ ];
681
+ return sumSandboxResources(resources);
682
+ }
683
+ activeSandboxResources(degradedReservations) {
684
+ return sumSandboxResources([
685
+ ...[...this.activeSessions.values()]
686
+ .filter(session => session.sandboxed)
687
+ .map(session => session.resources),
688
+ ...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
689
+ ]);
690
+ }
691
+ sandboxResourceCapacity(preflight, activeResources) {
692
+ if (!preflight)
693
+ return zeroSandboxResources();
694
+ return {
695
+ // Host free memory and disk already reflect active containers. Add their
696
+ // commitments back to form a stable total capacity, then subtract them
697
+ // exactly once in usedSandboxResources.
698
+ sandboxSlots: Math.min(this.config.maxConcurrentSandboxes, preflight.maxConcurrentSandboxes + activeResources.sandboxSlots),
699
+ cpuMillis: preflight.availableCpuMillis,
700
+ workerMemoryLimitBytes: 0,
701
+ serviceMemoryLimitBytes: 0,
702
+ runtimeOverheadBytes: 0,
703
+ memoryBytes: Math.max(0, preflight.availableMemoryBytes
704
+ - (this.config.sandboxHostMemoryReserveBytes ?? 2 * 1024 ** 3)
705
+ + activeResources.memoryBytes),
706
+ workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
707
+ - this.config.minimumWorkspaceBytes
708
+ + activeResources.workspaceBytes),
709
+ pids: this.config.sandboxPidCapacity,
710
+ buildSlots: this.config.maxConcurrentSandboxBuilds
711
+ };
712
+ }
245
713
  async invokeAck(methodName, payload) {
246
714
  const connection = this.connection;
247
715
  if (!connection) {
@@ -261,6 +729,8 @@ export class ForgeBridgeService {
261
729
  }
262
730
  async stopConnection() {
263
731
  this.stopHeartbeat();
732
+ this.stopLeaseWatchdog();
733
+ this.stopSandboxMaintenance();
264
734
  if (this.connection) {
265
735
  await this.connection.stop().catch(() => undefined);
266
736
  this.connection = null;
@@ -281,6 +751,12 @@ function isDispatchNoLongerPending(error) {
281
751
  }
282
752
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
283
753
  }
754
+ function createDefaultSessionService(sandboxStateRoot) {
755
+ const rawProcessRunner = new StreamingProviderProcessRunner();
756
+ const processSupervisors = new AttemptProcessSupervisorRegistry();
757
+ const coordinatedProcessRunner = new AttemptCoordinatedProviderProcessRunner(processSupervisors, rawProcessRunner);
758
+ return new BridgeSessionService(new GitWorktreeService(undefined, new RepositoryCoordinatorLock(sandboxStateRoot)), new RoutingProviderCliService(new ProcessBackedProviderCliService(coordinatedProcessRunner)), new HostRuntimeLeaseManager(sandboxStateRoot, undefined, undefined, processSupervisors, rawProcessRunner));
759
+ }
284
760
  export class SignalRBridgeConnectionFactory {
285
761
  create(config) {
286
762
  return new SignalRBridgeConnection(config);
@@ -305,6 +781,9 @@ export class SignalRBridgeConnection {
305
781
  })
306
782
  .configureLogging(LogLevel.Warning)
307
783
  .build();
784
+ this.connection.on("Superseded", () => {
785
+ void this.connection.stop();
786
+ });
308
787
  }
309
788
  start() {
310
789
  return this.connection.start();
@@ -320,11 +799,31 @@ export class SignalRBridgeConnection {
320
799
  void handler(raw).catch(() => undefined);
321
800
  });
322
801
  }
802
+ onOffer(handler) {
803
+ this.connection.on("Offer", (raw) => {
804
+ void handler(raw).catch(() => undefined);
805
+ });
806
+ }
807
+ onStart(handler) {
808
+ this.connection.on("Start", (raw) => {
809
+ void handler(raw).catch(() => undefined);
810
+ });
811
+ }
812
+ onLeaseUpdate(handler) {
813
+ this.connection.on("LeaseUpdate", (request) => {
814
+ void handler(request).catch(() => undefined);
815
+ });
816
+ }
323
817
  onCancel(handler) {
324
818
  this.connection.on("Cancel", (request) => {
325
819
  void handler(request).catch(() => undefined);
326
820
  });
327
821
  }
822
+ onReap(handler) {
823
+ this.connection.on("Reap", (request) => {
824
+ void handler(request).catch(() => undefined);
825
+ });
826
+ }
328
827
  invoke(methodName, payload) {
329
828
  return this.connection.invoke(methodName, payload);
330
829
  }
@@ -332,9 +831,37 @@ export class SignalRBridgeConnection {
332
831
  export function defaultBridgeServiceConfig(overrides = {}) {
333
832
  const defaults = defaultForgeBridgeConfig();
334
833
  const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
834
+ const sandboxHostMemoryReserveDefault = process.platform === "linux"
835
+ ? 2 * 1024 ** 3
836
+ : 1024 ** 3;
837
+ const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
335
838
  const config = {
336
839
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
337
840
  runnerId: process.env.FORGE_RUNNER_ID ?? "",
841
+ runnerInstanceId: process.env.FORGE_RUNNER_INSTANCE_ID ?? randomUUID(),
842
+ trustDomainId: process.env.FORGE_TRUST_DOMAIN_ID ?? "default",
843
+ maxConcurrentSandboxes: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOXES ?? 3), 3),
844
+ maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
845
+ sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
846
+ dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
847
+ allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true",
848
+ sandboxPublicationMode,
849
+ dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
850
+ dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
851
+ sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
852
+ requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
853
+ diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
854
+ diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
855
+ minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
856
+ sandboxHostMemoryReserveBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES ?? sandboxHostMemoryReserveDefault), sandboxHostMemoryReserveDefault),
857
+ defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
858
+ defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
859
+ sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
860
+ sandboxMemorySampleIntervalMs: Math.min(30_000, Math.max(250, positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_MEMORY_SAMPLE_INTERVAL_MS ?? 1000), 1000))),
861
+ imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
862
+ imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
863
+ auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
864
+ auditRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000), 30 * 24 * 60 * 60 * 1000),
338
865
  deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
339
866
  deviceName: process.env.FORGE_DEVICE_NAME ?? "",
340
867
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
@@ -351,9 +878,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
351
878
  supportsRunnerAuthoredPhaseArtifacts: true,
352
879
  supportsDraftRunOnce: true,
353
880
  supportsDraftAttachments: true,
354
- supportsDraftImageAttachments: false,
881
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
355
882
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
356
- protocolVersion: 1,
883
+ protocolVersion: 2,
357
884
  minServerVersion: null,
358
885
  heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
359
886
  reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
@@ -361,7 +888,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
361
888
  };
362
889
  return {
363
890
  ...config,
891
+ providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
364
892
  sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
893
+ supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
365
894
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
366
895
  };
367
896
  }
@@ -377,9 +906,36 @@ function normalizeServerUrl(serverUrl) {
377
906
  function positiveFiniteNumber(value, fallback) {
378
907
  return Number.isFinite(value) && value > 0 ? value : fallback;
379
908
  }
909
+ function nonNegativeFiniteNumber(value, fallback) {
910
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
911
+ }
380
912
  function providerStatusesFor(providers) {
381
913
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
382
914
  }
915
+ function sandboxAuthModesFor(config) {
916
+ return Object.fromEntries(config.cliProviders.map((provider) => {
917
+ const modes = ["api_key"];
918
+ if (sandboxBridgeCliAuthAvailable(config, provider)) {
919
+ modes.push("bridge_cli");
920
+ }
921
+ return [provider, modes];
922
+ }));
923
+ }
924
+ function sandboxBridgeCliAuthAvailable(config, provider) {
925
+ if (provider.trim().toLowerCase() !== "codex") {
926
+ return false;
927
+ }
928
+ const status = providerStatus(config, provider);
929
+ if (status?.signedIn === false) {
930
+ return false;
931
+ }
932
+ return codexSandboxProviderAuthAdapter.doctor().available;
933
+ }
934
+ function providerStatus(config, provider) {
935
+ const normalized = provider.trim().toLowerCase();
936
+ return Object.entries(config.providerStatuses)
937
+ .find(([key]) => key.trim().toLowerCase() === normalized)?.[1];
938
+ }
383
939
  function defaultSandboxProfiles(providers, platform = process.platform) {
384
940
  if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
385
941
  return ["none"];
@@ -543,6 +1099,207 @@ function abortPromise(signal) {
543
1099
  }
544
1100
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
545
1101
  }
1102
+ export function canReapManagedSandboxes(preflight) {
1103
+ return canRunDockerMaintenance(preflight);
1104
+ }
1105
+ function sandboxNetworkPolicyFailure(dispatch, config) {
1106
+ const sandbox = dispatch.sandbox;
1107
+ if (!sandbox)
1108
+ return "Protocol-v2 sandbox context is missing.";
1109
+ if (sandbox.networkPolicy.mode === "open_public"
1110
+ && (sandbox.executionPolicy === "sandbox_required" || !config.allowOpenPublicSandbox)) {
1111
+ return "open_public sandbox networking is not permitted by this runner policy.";
1112
+ }
1113
+ if (sandbox.networkPolicy.mode === "public_allowlist"
1114
+ && sandbox.networkPolicy.allowedHosts.length > 0
1115
+ && process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
1116
+ return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
1117
+ }
1118
+ return null;
1119
+ }
1120
+ function sandboxAuthFailure(dispatch, config) {
1121
+ const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
1122
+ if (unsupported) {
1123
+ return unsupported;
1124
+ }
1125
+ if (dispatch.execution.authKind.trim().toLowerCase() !== "bridge_cli") {
1126
+ return null;
1127
+ }
1128
+ const status = providerStatus(config, dispatch.execution.provider);
1129
+ if (status?.signedIn === false) {
1130
+ return `${dispatch.execution.provider} Local CLI is not signed in on this runner.`;
1131
+ }
1132
+ const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
1133
+ if (!adapter) {
1134
+ return `${dispatch.execution.provider} Local CLI auth is not portable into Docker yet.`;
1135
+ }
1136
+ const doctor = adapter.doctor();
1137
+ return doctor.available ? null : doctor.message;
1138
+ }
1139
+ export function canReserveSandboxResources(used, requested, capacity) {
1140
+ return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
1141
+ && used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
1142
+ && used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
1143
+ && used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
1144
+ && used.pids + requested.pids <= capacity.pids
1145
+ && used.buildSlots + requested.buildSlots <= capacity.buildSlots;
1146
+ }
1147
+ export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
1148
+ const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
1149
+ if (requested.sandboxSlots > availableSlots) {
1150
+ return `Sandbox slot capacity is insufficient: requested ${requested.sandboxSlots}, available ${availableSlots}, ${used.sandboxSlots} already reserved.`;
1151
+ }
1152
+ const availableMemoryBytes = Math.max(0, capacity.memoryBytes - used.memoryBytes);
1153
+ if (requested.memoryBytes > availableMemoryBytes) {
1154
+ return [
1155
+ `Sandbox memory capacity is insufficient: requested ${formatBytes(requested.memoryBytes)} total`,
1156
+ `(worker ${formatBytes(requested.workerMemoryLimitBytes)}, services ${formatBytes(requested.serviceMemoryLimitBytes)}, runtime overhead ${formatBytes(requested.runtimeOverheadBytes)}),`,
1157
+ `but ${formatBytes(availableMemoryBytes)} is available for admission.`,
1158
+ `Docker reports ${formatBytes(preflight?.availableMemoryBytes ?? 0)} available of ${formatBytes(preflight?.totalMemoryBytes ?? 0)};`,
1159
+ `Forge reserves ${formatBytes(preflight?.hostReserveBytes ?? config.sandboxHostMemoryReserveBytes ?? 0)} for the host and tracks ${formatBytes(used.memoryBytes)} in active or reserved attempts.`,
1160
+ ].join(" ");
1161
+ }
1162
+ const availableCpuMillis = Math.max(0, capacity.cpuMillis - used.cpuMillis);
1163
+ if (requested.cpuMillis > availableCpuMillis) {
1164
+ return `Sandbox CPU capacity is insufficient: requested ${requested.cpuMillis} millis, available ${availableCpuMillis} millis, ${used.cpuMillis} millis already reserved.`;
1165
+ }
1166
+ const availableWorkspaceBytes = Math.max(0, capacity.workspaceBytes - used.workspaceBytes);
1167
+ if (requested.workspaceBytes > availableWorkspaceBytes) {
1168
+ return `Sandbox workspace capacity is insufficient: requested ${formatBytes(requested.workspaceBytes)}, available ${formatBytes(availableWorkspaceBytes)}, ${formatBytes(used.workspaceBytes)} already reserved.`;
1169
+ }
1170
+ const availablePids = Math.max(0, capacity.pids - used.pids);
1171
+ if (requested.pids > availablePids) {
1172
+ return `Sandbox PID capacity is insufficient: requested ${requested.pids}, available ${availablePids}, ${used.pids} already reserved.`;
1173
+ }
1174
+ const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
1175
+ return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
1176
+ }
1177
+ function formatBytes(bytes) {
1178
+ if (!Number.isFinite(bytes) || bytes <= 0)
1179
+ return "0 B";
1180
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
1181
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
1182
+ const value = bytes / 1024 ** power;
1183
+ return `${value >= 10 || power === 0 ? value.toFixed(0) : value.toFixed(2)} ${units[power]}`;
1184
+ }
1185
+ function sumSandboxResources(resources) {
1186
+ return resources.reduce((total, resource) => ({
1187
+ sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
1188
+ cpuMillis: total.cpuMillis + resource.cpuMillis,
1189
+ workerMemoryLimitBytes: total.workerMemoryLimitBytes + resource.workerMemoryLimitBytes,
1190
+ serviceMemoryLimitBytes: total.serviceMemoryLimitBytes + resource.serviceMemoryLimitBytes,
1191
+ runtimeOverheadBytes: total.runtimeOverheadBytes + resource.runtimeOverheadBytes,
1192
+ memoryBytes: total.memoryBytes + resource.memoryBytes,
1193
+ workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
1194
+ pids: total.pids + resource.pids,
1195
+ buildSlots: total.buildSlots + resource.buildSlots
1196
+ }), zeroSandboxResources());
1197
+ }
1198
+ function zeroSandboxResources() {
1199
+ return {
1200
+ sandboxSlots: 0,
1201
+ cpuMillis: 0,
1202
+ workerMemoryLimitBytes: 0,
1203
+ serviceMemoryLimitBytes: 0,
1204
+ runtimeOverheadBytes: 0,
1205
+ memoryBytes: 0,
1206
+ workspaceBytes: 0,
1207
+ pids: 0,
1208
+ buildSlots: 0,
1209
+ };
1210
+ }
1211
+ function defaultSandboxResources() {
1212
+ return {
1213
+ sandboxSlots: 1,
1214
+ cpuMillis: 2000,
1215
+ workerMemoryLimitBytes: 2 * 1024 ** 3,
1216
+ serviceMemoryLimitBytes: 0,
1217
+ runtimeOverheadBytes: 128 * 1024 ** 2,
1218
+ memoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
1219
+ workspaceBytes: 10 * 1024 ** 3,
1220
+ pids: 512,
1221
+ buildSlots: 0
1222
+ };
1223
+ }
1224
+ export function resolveDynamicSandboxResources(requested, serviceCount, networkMode, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1225
+ const serviceMemoryLimitBytes = Math.max(0, serviceCount) * 1024 ** 3;
1226
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, baseRuntimeOverheadBytes)
1227
+ + (networkMode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
1228
+ return {
1229
+ ...requested,
1230
+ serviceMemoryLimitBytes,
1231
+ runtimeOverheadBytes,
1232
+ memoryBytes: requested.workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes,
1233
+ };
1234
+ }
1235
+ export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
1236
+ const repoPath = dispatch.repo.serverLocalPath;
1237
+ const sandbox = dispatch.sandbox;
1238
+ if (!repoPath || !sandbox) {
1239
+ throw new Error("Sandbox admission needs a mapped repository.");
1240
+ }
1241
+ const selection = selectSandboxConfigRefs(sandbox.executionPolicy, sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
1242
+ const configCommitSha = await resolveFirstGitCommit(repoPath, selection.refs);
1243
+ if (!configCommitSha) {
1244
+ throw new Error("Sandbox admission could not resolve the immutable sandbox config commit.");
1245
+ }
1246
+ const contractResult = await runSandboxProcess({
1247
+ command: "git",
1248
+ args: ["-C", repoPath, "show", `${configCommitSha}:.forge/forge.sandbox.yml`],
1249
+ timeoutMs: 30_000,
1250
+ maxOutputBytes: 1024 * 1024,
1251
+ });
1252
+ if (contractResult.exitCode !== 0) {
1253
+ throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
1254
+ }
1255
+ const contract = validateSandboxContract(contractResult.stdout);
1256
+ return {
1257
+ resources: resolveDynamicSandboxResources(sandbox.requestedResources, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
1258
+ sandboxConfigCommitSha: configCommitSha,
1259
+ };
1260
+ }
1261
+ async function resolveFirstGitCommit(repoPath, refs) {
1262
+ for (const ref of refs) {
1263
+ const result = await runSandboxProcess({
1264
+ command: "git",
1265
+ args: ["-C", repoPath, "rev-parse", "--verify", `${ref}^{commit}`],
1266
+ timeoutMs: 30_000,
1267
+ maxOutputBytes: 1024 * 1024,
1268
+ });
1269
+ const commitSha = result.stdout.trim().toLowerCase();
1270
+ if (result.exitCode === 0 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commitSha)) {
1271
+ return commitSha;
1272
+ }
1273
+ }
1274
+ return null;
1275
+ }
1276
+ function parseOfferEnvelope(value) {
1277
+ return parseDispatchEnvelope(value);
1278
+ }
1279
+ function parseStartEnvelope(value) {
1280
+ return parseDispatchEnvelope(value);
1281
+ }
1282
+ function parseDispatchEnvelope(value) {
1283
+ if (!value || typeof value !== "object" || Array.isArray(value))
1284
+ return null;
1285
+ const envelope = value;
1286
+ if (envelope.schemaVersion !== 1
1287
+ || typeof envelope.dispatchAttemptId !== "string"
1288
+ || typeof envelope.leaseId !== "string"
1289
+ || typeof envelope.fencingToken !== "string"
1290
+ || typeof envelope.runnerInstanceId !== "string"
1291
+ || typeof envelope.expiresAt !== "string"
1292
+ || !envelope.dispatch)
1293
+ return null;
1294
+ return {
1295
+ dispatchAttemptId: envelope.dispatchAttemptId,
1296
+ leaseId: envelope.leaseId,
1297
+ fencingToken: envelope.fencingToken,
1298
+ runnerInstanceId: envelope.runnerInstanceId,
1299
+ expiresAt: envelope.expiresAt,
1300
+ dispatch: envelope.dispatch
1301
+ };
1302
+ }
546
1303
  const maxAttachmentExactSecretLength = 8 * 1024;
547
1304
  function exactSecrets(dispatch, config) {
548
1305
  return [