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