@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 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 (141) 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 +796 -22
  5. package/dist/bridge/bridge.types.d.ts +135 -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.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +356 -205
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +45 -2
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +50 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +74 -0
  24. package/dist/runtime/attempt-process-supervisor.js +290 -0
  25. package/dist/runtime/host-listener-inspector.d.ts +28 -0
  26. package/dist/runtime/host-listener-inspector.js +185 -0
  27. package/dist/runtime/host-port-broker.d.ts +76 -0
  28. package/dist/runtime/host-port-broker.js +333 -0
  29. package/dist/runtime/host-runtime-contract.d.ts +39 -0
  30. package/dist/runtime/host-runtime-contract.js +295 -0
  31. package/dist/runtime/host-runtime-lease.d.ts +122 -0
  32. package/dist/runtime/host-runtime-lease.js +1241 -0
  33. package/dist/runtime/host-runtime-promotion.d.ts +21 -0
  34. package/dist/runtime/host-runtime-promotion.js +128 -0
  35. package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
  36. package/dist/runtime/node-host-runtime-adapter.js +205 -0
  37. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.js +136 -0
  39. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  40. package/dist/runtime/runtime-isolation-lease.js +158 -0
  41. package/dist/sandbox/command-runner.d.ts +4 -0
  42. package/dist/sandbox/command-runner.js +47 -0
  43. package/dist/sandbox/contract.d.ts +5 -0
  44. package/dist/sandbox/contract.js +276 -0
  45. package/dist/sandbox/event-budget.d.ts +11 -0
  46. package/dist/sandbox/event-budget.js +27 -0
  47. package/dist/sandbox/image-policy.d.ts +35 -0
  48. package/dist/sandbox/image-policy.js +247 -0
  49. package/dist/sandbox/index.d.ts +5 -0
  50. package/dist/sandbox/index.js +5 -0
  51. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  52. package/dist/sandbox/lease-watchdog.js +67 -0
  53. package/dist/sandbox/preflight.d.ts +45 -0
  54. package/dist/sandbox/preflight.js +300 -0
  55. package/dist/sandbox/process.d.ts +20 -0
  56. package/dist/sandbox/process.js +209 -0
  57. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  58. package/dist/sandbox/provider-auth/codex.js +212 -0
  59. package/dist/sandbox/repository-lock.d.ts +5 -0
  60. package/dist/sandbox/repository-lock.js +119 -0
  61. package/dist/sandbox/runtime.d.ts +297 -0
  62. package/dist/sandbox/runtime.js +2338 -0
  63. package/dist/sandbox/sandbox.types.d.ts +86 -0
  64. package/dist/sandbox/sandbox.types.js +1 -0
  65. package/dist/sandbox/smoke.d.ts +76 -0
  66. package/dist/sandbox/smoke.js +380 -0
  67. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  68. package/dist/sandbox/worker-supervisor.js +36 -0
  69. package/dist/sandbox/worker.d.ts +1 -0
  70. package/dist/sandbox/worker.js +225 -0
  71. package/dist/sandbox/workspace.service.d.ts +13 -0
  72. package/dist/sandbox/workspace.service.js +67 -0
  73. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  74. package/dist/session/phase-completion.artifacts.js +620 -0
  75. package/dist/session/phase-completion.d.ts +32 -44
  76. package/dist/session/phase-completion.helpers.d.ts +112 -0
  77. package/dist/session/phase-completion.helpers.js +665 -0
  78. package/dist/session/phase-completion.js +28 -3485
  79. package/dist/session/phase-completion.prompts.d.ts +14 -0
  80. package/dist/session/phase-completion.prompts.js +220 -0
  81. package/dist/session/phase-completion.runtime.d.ts +36 -0
  82. package/dist/session/phase-completion.runtime.js +611 -0
  83. package/dist/session/phase-completion.schemas.d.ts +3 -0
  84. package/dist/session/phase-completion.schemas.js +707 -0
  85. package/dist/session/phase-completion.validation.d.ts +34 -0
  86. package/dist/session/phase-completion.validation.js +724 -0
  87. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  88. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  89. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  90. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  91. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  92. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  93. package/dist/session/provider-cli/provider-cli.deterministic.js +329 -0
  94. package/dist/session/provider-cli/provider-cli.process.d.ts +34 -0
  95. package/dist/session/provider-cli/provider-cli.process.js +316 -0
  96. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  97. package/dist/session/provider-cli/provider-cli.prompts.js +573 -0
  98. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  99. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +65 -0
  100. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  101. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  102. package/dist/session/provider-cli/provider-cli.service.d.ts +9 -53
  103. package/dist/session/provider-cli/provider-cli.service.js +182 -2536
  104. package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
  105. package/dist/session/provider-cli/provider-output-state.d.ts +20 -0
  106. package/dist/session/provider-cli/provider-output-state.js +445 -0
  107. package/dist/session/session-redaction.d.ts +3 -0
  108. package/dist/session/session-redaction.js +23 -0
  109. package/dist/session/session.base.d.ts +4 -0
  110. package/dist/session/session.service.d.ts +6 -1
  111. package/dist/session/session.service.js +202 -53
  112. package/dist/session/session.types.d.ts +123 -1
  113. package/dist/session/session.types.js +301 -3
  114. package/dist/shared/path/path.js +3 -1
  115. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  116. package/dist/shared/process/host-process-recovery.js +78 -0
  117. package/dist/shared/process/host-process-supervisor.d.ts +40 -0
  118. package/dist/shared/process/host-process-supervisor.js +195 -0
  119. package/dist/shared/process/windows-launch.d.ts +8 -0
  120. package/dist/shared/process/windows-launch.js +41 -0
  121. package/dist/shared/provider-capabilities.d.ts +1 -0
  122. package/dist/shared/provider-capabilities.js +3 -0
  123. package/dist/shared/redaction/redaction.js +10 -3
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/worktree/artifact-injector.js +39 -6
  127. package/dist/worktree/dependency-warmer.d.ts +11 -0
  128. package/dist/worktree/dependency-warmer.js +45 -1
  129. package/dist/worktree/index.d.ts +1 -0
  130. package/dist/worktree/index.js +1 -0
  131. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  132. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  133. package/dist/worktree/worktree-paths.d.ts +4 -0
  134. package/dist/worktree/worktree-paths.js +30 -0
  135. package/dist/worktree/worktree.base.d.ts +3 -0
  136. package/dist/worktree/worktree.service.d.ts +8 -3
  137. package/dist/worktree/worktree.service.js +89 -52
  138. package/dist/worktree/worktree.types.d.ts +3 -1
  139. package/package.json +6 -3
  140. package/runtime/windows-job-supervisor.ps1 +287 -0
  141. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,28 +1,81 @@
1
- import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
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,11 +629,85 @@ 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,
239
665
  supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
240
- supportsDraftRunOnce: this.config.supportsDraftRunOnce
666
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce,
667
+ supportsDraftAttachments: this.config.supportsDraftAttachments,
668
+ supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
669
+ };
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
241
711
  };
242
712
  }
243
713
  async invokeAck(methodName, payload) {
@@ -259,6 +729,8 @@ export class ForgeBridgeService {
259
729
  }
260
730
  async stopConnection() {
261
731
  this.stopHeartbeat();
732
+ this.stopLeaseWatchdog();
733
+ this.stopSandboxMaintenance();
262
734
  if (this.connection) {
263
735
  await this.connection.stop().catch(() => undefined);
264
736
  this.connection = null;
@@ -279,6 +751,12 @@ function isDispatchNoLongerPending(error) {
279
751
  }
280
752
  return error instanceof Error && error.message === "Dispatch is no longer pending.";
281
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
+ }
282
760
  export class SignalRBridgeConnectionFactory {
283
761
  create(config) {
284
762
  return new SignalRBridgeConnection(config);
@@ -293,10 +771,19 @@ export class SignalRBridgeConnection {
293
771
  url.searchParams.set("runnerId", config.runnerId);
294
772
  this.connection = new HubConnectionBuilder()
295
773
  .withUrl(url.toString(), {
296
- accessTokenFactory: () => config.deviceToken
774
+ // Query-string access tokens are intentionally rejected by the API. The bridge is
775
+ // a Node CLI, so use long polling where Authorization headers are carried on each
776
+ // HTTP request instead of relying on browser/WebSocket token fallbacks.
777
+ transport: HttpTransportType.LongPolling,
778
+ headers: {
779
+ Authorization: `Bearer ${config.deviceToken}`
780
+ }
297
781
  })
298
782
  .configureLogging(LogLevel.Warning)
299
783
  .build();
784
+ this.connection.on("Superseded", () => {
785
+ void this.connection.stop();
786
+ });
300
787
  }
301
788
  start() {
302
789
  return this.connection.start();
@@ -312,11 +799,31 @@ export class SignalRBridgeConnection {
312
799
  void handler(raw).catch(() => undefined);
313
800
  });
314
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
+ }
315
817
  onCancel(handler) {
316
818
  this.connection.on("Cancel", (request) => {
317
819
  void handler(request).catch(() => undefined);
318
820
  });
319
821
  }
822
+ onReap(handler) {
823
+ this.connection.on("Reap", (request) => {
824
+ void handler(request).catch(() => undefined);
825
+ });
826
+ }
320
827
  invoke(methodName, payload) {
321
828
  return this.connection.invoke(methodName, payload);
322
829
  }
@@ -324,9 +831,37 @@ export class SignalRBridgeConnection {
324
831
  export function defaultBridgeServiceConfig(overrides = {}) {
325
832
  const defaults = defaultForgeBridgeConfig();
326
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";
327
838
  const config = {
328
839
  serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
329
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),
330
865
  deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
331
866
  deviceName: process.env.FORGE_DEVICE_NAME ?? "",
332
867
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
@@ -342,8 +877,10 @@ export function defaultBridgeServiceConfig(overrides = {}) {
342
877
  supportsManagedForgeArtifacts: true,
343
878
  supportsRunnerAuthoredPhaseArtifacts: true,
344
879
  supportsDraftRunOnce: true,
880
+ supportsDraftAttachments: true,
881
+ supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
345
882
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
346
- protocolVersion: 1,
883
+ protocolVersion: 2,
347
884
  minServerVersion: null,
348
885
  heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
349
886
  reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
@@ -351,7 +888,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
351
888
  };
352
889
  return {
353
890
  ...config,
891
+ providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
354
892
  sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
893
+ supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
355
894
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
356
895
  };
357
896
  }
@@ -367,9 +906,36 @@ function normalizeServerUrl(serverUrl) {
367
906
  function positiveFiniteNumber(value, fallback) {
368
907
  return Number.isFinite(value) && value > 0 ? value : fallback;
369
908
  }
909
+ function nonNegativeFiniteNumber(value, fallback) {
910
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
911
+ }
370
912
  function providerStatusesFor(providers) {
371
913
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
372
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
+ }
373
939
  function defaultSandboxProfiles(providers, platform = process.platform) {
374
940
  if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
375
941
  return ["none"];
@@ -533,9 +1099,217 @@ function abortPromise(signal) {
533
1099
  }
534
1100
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
535
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
+ }
1303
+ const maxAttachmentExactSecretLength = 8 * 1024;
536
1304
  function exactSecrets(dispatch, config) {
537
1305
  return [
538
1306
  dispatch.apiKeySecretGrant?.secret,
539
- config.deviceToken
1307
+ config.deviceToken,
1308
+ ...boundedAttachmentExactSecrets(dispatch)
540
1309
  ].filter((secret) => typeof secret === "string" && secret.length > 0);
541
1310
  }
1311
+ function boundedAttachmentExactSecrets(dispatch) {
1312
+ return (dispatch.draft?.attachments ?? [])
1313
+ .flatMap((attachment) => [attachment.textContent, attachment.contentBase64])
1314
+ .filter((secret) => typeof secret === "string" && secret.length > 0 && secret.length <= maxAttachmentExactSecretLength);
1315
+ }