@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.d46a8e73d03d
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.
- package/README.md +44 -0
- package/dist/bridge/bridge.base.d.ts +5 -1
- package/dist/bridge/bridge.service.d.ts +40 -2
- package/dist/bridge/bridge.service.js +580 -16
- package/dist/bridge/bridge.types.d.ts +114 -1
- package/dist/cli/cli.help.js +12 -0
- package/dist/cli/cli.service.d.ts +12 -0
- package/dist/cli/cli.service.js +171 -1
- package/dist/cli/local-config.d.ts +2 -0
- package/dist/cli/local-config.js +6 -2
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +40 -0
- package/dist/sandbox/preflight.js +256 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +124 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +83 -0
- package/dist/sandbox/runtime.d.ts +234 -0
- package/dist/sandbox/runtime.js +1773 -0
- package/dist/sandbox/sandbox.types.d.ts +83 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +228 -0
- package/dist/sandbox/workspace.service.d.ts +12 -0
- package/dist/sandbox/workspace.service.js +49 -0
- package/dist/session/phase-completion.artifacts.js +23 -2
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.process.js +23 -1
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +12 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +119 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.service.js +161 -43
- package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
- package/dist/session/session.service.js +34 -24
- package/dist/session/session.types.d.ts +52 -1
- package/dist/session/session.types.js +159 -3
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +4 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +11 -0
- package/dist/worktree/dependency-warmer.js +45 -1
- package/package.json +4 -2
|
@@ -1,28 +1,58 @@
|
|
|
1
1
|
import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
4
6
|
import { defaultForgeBridgeConfig } from "../config.js";
|
|
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 { canRunDockerMaintenance, 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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
}
|
|
118
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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:
|
|
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;
|
|
@@ -303,6 +695,9 @@ export class SignalRBridgeConnection {
|
|
|
303
695
|
})
|
|
304
696
|
.configureLogging(LogLevel.Warning)
|
|
305
697
|
.build();
|
|
698
|
+
this.connection.on("Superseded", () => {
|
|
699
|
+
void this.connection.stop();
|
|
700
|
+
});
|
|
306
701
|
}
|
|
307
702
|
start() {
|
|
308
703
|
return this.connection.start();
|
|
@@ -318,11 +713,31 @@ export class SignalRBridgeConnection {
|
|
|
318
713
|
void handler(raw).catch(() => undefined);
|
|
319
714
|
});
|
|
320
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
|
+
}
|
|
321
731
|
onCancel(handler) {
|
|
322
732
|
this.connection.on("Cancel", (request) => {
|
|
323
733
|
void handler(request).catch(() => undefined);
|
|
324
734
|
});
|
|
325
735
|
}
|
|
736
|
+
onReap(handler) {
|
|
737
|
+
this.connection.on("Reap", (request) => {
|
|
738
|
+
void handler(request).catch(() => undefined);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
326
741
|
invoke(methodName, payload) {
|
|
327
742
|
return this.connection.invoke(methodName, payload);
|
|
328
743
|
}
|
|
@@ -330,9 +745,28 @@ export class SignalRBridgeConnection {
|
|
|
330
745
|
export function defaultBridgeServiceConfig(overrides = {}) {
|
|
331
746
|
const defaults = defaultForgeBridgeConfig();
|
|
332
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";
|
|
333
749
|
const config = {
|
|
334
750
|
serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
|
|
335
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),
|
|
336
770
|
deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
|
|
337
771
|
deviceName: process.env.FORGE_DEVICE_NAME ?? "",
|
|
338
772
|
defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
|
|
@@ -348,8 +782,10 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
348
782
|
supportsManagedForgeArtifacts: true,
|
|
349
783
|
supportsRunnerAuthoredPhaseArtifacts: true,
|
|
350
784
|
supportsDraftRunOnce: true,
|
|
785
|
+
supportsDraftAttachments: true,
|
|
786
|
+
supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
|
|
351
787
|
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
352
|
-
protocolVersion:
|
|
788
|
+
protocolVersion: 2,
|
|
353
789
|
minServerVersion: null,
|
|
354
790
|
heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
|
|
355
791
|
reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
|
|
@@ -357,7 +793,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
357
793
|
};
|
|
358
794
|
return {
|
|
359
795
|
...config,
|
|
796
|
+
providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
|
|
360
797
|
sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
|
|
798
|
+
supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
|
|
361
799
|
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
362
800
|
};
|
|
363
801
|
}
|
|
@@ -376,6 +814,30 @@ function positiveFiniteNumber(value, fallback) {
|
|
|
376
814
|
function providerStatusesFor(providers) {
|
|
377
815
|
return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
|
|
378
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
|
+
}
|
|
379
841
|
function defaultSandboxProfiles(providers, platform = process.platform) {
|
|
380
842
|
if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
|
|
381
843
|
return ["none"];
|
|
@@ -539,9 +1001,111 @@ function abortPromise(signal) {
|
|
|
539
1001
|
}
|
|
540
1002
|
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
541
1003
|
}
|
|
1004
|
+
export function canReapManagedSandboxes(preflight) {
|
|
1005
|
+
return canRunDockerMaintenance(preflight);
|
|
1006
|
+
}
|
|
1007
|
+
function sandboxNetworkPolicyFailure(dispatch) {
|
|
1008
|
+
const sandbox = dispatch.sandbox;
|
|
1009
|
+
if (!sandbox)
|
|
1010
|
+
return "Protocol-v2 sandbox context is missing.";
|
|
1011
|
+
if (sandbox.networkPolicy.mode === "open_public"
|
|
1012
|
+
&& (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
|
|
1013
|
+
return "open_public sandbox networking is not permitted by this runner policy.";
|
|
1014
|
+
}
|
|
1015
|
+
if (sandbox.networkPolicy.mode === "public_allowlist"
|
|
1016
|
+
&& sandbox.networkPolicy.allowedHosts.length > 0
|
|
1017
|
+
&& process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
|
|
1018
|
+
return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
|
|
1019
|
+
}
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
function sandboxAuthFailure(dispatch, config) {
|
|
1023
|
+
const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
|
|
1024
|
+
if (unsupported) {
|
|
1025
|
+
return unsupported;
|
|
1026
|
+
}
|
|
1027
|
+
if (dispatch.execution.authKind.trim().toLowerCase() !== "bridge_cli") {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
const status = providerStatus(config, dispatch.execution.provider);
|
|
1031
|
+
if (status?.signedIn === false) {
|
|
1032
|
+
return `${dispatch.execution.provider} Local CLI is not signed in on this runner.`;
|
|
1033
|
+
}
|
|
1034
|
+
const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
|
|
1035
|
+
if (!adapter) {
|
|
1036
|
+
return `${dispatch.execution.provider} Local CLI auth is not portable into Docker yet.`;
|
|
1037
|
+
}
|
|
1038
|
+
const doctor = adapter.doctor();
|
|
1039
|
+
return doctor.available ? null : doctor.message;
|
|
1040
|
+
}
|
|
1041
|
+
export function canReserveSandboxResources(used, requested, capacity) {
|
|
1042
|
+
return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
|
|
1043
|
+
&& used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
|
|
1044
|
+
&& used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
|
|
1045
|
+
&& used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
|
|
1046
|
+
&& used.pids + requested.pids <= capacity.pids
|
|
1047
|
+
&& used.buildSlots + requested.buildSlots <= capacity.buildSlots;
|
|
1048
|
+
}
|
|
1049
|
+
function sumSandboxResources(resources) {
|
|
1050
|
+
return resources.reduce((total, resource) => ({
|
|
1051
|
+
sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
|
|
1052
|
+
cpuMillis: total.cpuMillis + resource.cpuMillis,
|
|
1053
|
+
memoryBytes: total.memoryBytes + resource.memoryBytes,
|
|
1054
|
+
workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
|
|
1055
|
+
pids: total.pids + resource.pids,
|
|
1056
|
+
buildSlots: total.buildSlots + resource.buildSlots
|
|
1057
|
+
}), zeroSandboxResources());
|
|
1058
|
+
}
|
|
1059
|
+
function zeroSandboxResources() {
|
|
1060
|
+
return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
|
|
1061
|
+
}
|
|
1062
|
+
function defaultSandboxResources() {
|
|
1063
|
+
return {
|
|
1064
|
+
sandboxSlots: 1,
|
|
1065
|
+
cpuMillis: 2000,
|
|
1066
|
+
memoryBytes: 2 * 1024 ** 3,
|
|
1067
|
+
workspaceBytes: 10 * 1024 ** 3,
|
|
1068
|
+
pids: 512,
|
|
1069
|
+
buildSlots: 0
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function parseOfferEnvelope(value) {
|
|
1073
|
+
return parseDispatchEnvelope(value);
|
|
1074
|
+
}
|
|
1075
|
+
function parseStartEnvelope(value) {
|
|
1076
|
+
return parseDispatchEnvelope(value);
|
|
1077
|
+
}
|
|
1078
|
+
function parseDispatchEnvelope(value) {
|
|
1079
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1080
|
+
return null;
|
|
1081
|
+
const envelope = value;
|
|
1082
|
+
if (envelope.schemaVersion !== 1
|
|
1083
|
+
|| typeof envelope.dispatchAttemptId !== "string"
|
|
1084
|
+
|| typeof envelope.leaseId !== "string"
|
|
1085
|
+
|| typeof envelope.fencingToken !== "string"
|
|
1086
|
+
|| typeof envelope.runnerInstanceId !== "string"
|
|
1087
|
+
|| typeof envelope.expiresAt !== "string"
|
|
1088
|
+
|| !envelope.dispatch)
|
|
1089
|
+
return null;
|
|
1090
|
+
return {
|
|
1091
|
+
dispatchAttemptId: envelope.dispatchAttemptId,
|
|
1092
|
+
leaseId: envelope.leaseId,
|
|
1093
|
+
fencingToken: envelope.fencingToken,
|
|
1094
|
+
runnerInstanceId: envelope.runnerInstanceId,
|
|
1095
|
+
expiresAt: envelope.expiresAt,
|
|
1096
|
+
dispatch: envelope.dispatch
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
const maxAttachmentExactSecretLength = 8 * 1024;
|
|
542
1100
|
function exactSecrets(dispatch, config) {
|
|
543
1101
|
return [
|
|
544
1102
|
dispatch.apiKeySecretGrant?.secret,
|
|
545
|
-
config.deviceToken
|
|
1103
|
+
config.deviceToken,
|
|
1104
|
+
...boundedAttachmentExactSecrets(dispatch)
|
|
546
1105
|
].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
547
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
|
+
}
|