@loucompanion/forge-bridge 0.1.3-dev.bd0c67bbf352 → 0.1.3-dev.bf78fdcaa623

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