@loucompanion/forge-bridge 0.1.3-dev.9bc5014a2270 → 0.1.3-dev.a891d9601712
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 +36 -2
- package/dist/bridge/bridge.service.js +531 -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 +163 -0
- 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 +33 -0
- package/dist/sandbox/image-policy.js +245 -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 +36 -0
- package/dist/sandbox/preflight.js +239 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +124 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +83 -0
- package/dist/sandbox/runtime.d.ts +214 -0
- package/dist/sandbox/runtime.js +1615 -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 +151 -24
- package/dist/session/session.service.js +14 -7
- package/dist/session/session.types.d.ts +52 -1
- package/dist/session/session.types.js +156 -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 +8 -1
- package/dist/worktree/dependency-warmer.js +40 -3
- package/package.json +4 -2
|
@@ -1,28 +1,57 @@
|
|
|
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 { DockerPreflight } from "../sandbox/preflight.js";
|
|
13
|
+
import { DockerSandboxRuntime } from "../sandbox/runtime.js";
|
|
9
14
|
export class ForgeBridgeService {
|
|
10
15
|
config;
|
|
11
16
|
sessions;
|
|
12
17
|
connections;
|
|
13
18
|
connection = null;
|
|
14
19
|
heartbeatTimer = null;
|
|
20
|
+
leaseWatchdogTimer = null;
|
|
21
|
+
sandboxMaintenanceTimer = null;
|
|
22
|
+
sandboxMaintenanceRunning = false;
|
|
15
23
|
closed = null;
|
|
16
24
|
stopped = false;
|
|
17
25
|
activeSessions = new Map();
|
|
18
|
-
|
|
26
|
+
reservations = new Map();
|
|
27
|
+
preflight;
|
|
28
|
+
sandboxRuntime;
|
|
29
|
+
constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime) {
|
|
19
30
|
this.config = config;
|
|
20
31
|
this.sessions = sessions;
|
|
21
32
|
this.connections = connections;
|
|
33
|
+
this.preflight = preflight ?? new DockerPreflight({
|
|
34
|
+
dockerExecutable: config.dockerExecutable,
|
|
35
|
+
dockerContext: config.dockerContext,
|
|
36
|
+
stateRoot: config.sandboxStateRoot,
|
|
37
|
+
runnerInstanceId: config.runnerInstanceId,
|
|
38
|
+
maxConcurrentSandboxes: config.maxConcurrentSandboxes,
|
|
39
|
+
diskHighWatermarkPercent: config.diskHighWatermarkPercent,
|
|
40
|
+
diskLowWatermarkPercent: config.diskLowWatermarkPercent,
|
|
41
|
+
minimumWorkspaceBytes: config.minimumWorkspaceBytes,
|
|
42
|
+
requireRootless: config.requireRootlessDocker
|
|
43
|
+
});
|
|
44
|
+
this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
|
|
22
45
|
}
|
|
23
46
|
async connectOnce() {
|
|
24
47
|
await this.stopConnection();
|
|
25
48
|
this.stopped = false;
|
|
49
|
+
if (this.config.dockerEnabled) {
|
|
50
|
+
const preflight = await this.preflight.refresh();
|
|
51
|
+
if (canReapManagedSandboxes(preflight)) {
|
|
52
|
+
await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
26
55
|
const connection = this.connections.create(this.config);
|
|
27
56
|
this.connection = connection;
|
|
28
57
|
this.closed = new Promise((resolve) => {
|
|
@@ -33,15 +62,23 @@ export class ForgeBridgeService {
|
|
|
33
62
|
});
|
|
34
63
|
});
|
|
35
64
|
connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
|
|
65
|
+
connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
|
|
66
|
+
connection.onStart?.((dispatch) => this.handleStart(dispatch));
|
|
67
|
+
connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
|
|
36
68
|
connection.onCancel((request) => this.handleCancel(request));
|
|
69
|
+
connection.onReap?.((request) => this.handleReap(request));
|
|
37
70
|
await connection.start();
|
|
38
71
|
await this.invokeAck("Hello", {
|
|
39
72
|
version: this.config.version,
|
|
40
73
|
protocolVersion: this.config.protocolVersion,
|
|
41
74
|
minServerVersion: this.config.minServerVersion ?? null,
|
|
75
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
42
76
|
capabilities: this.capabilities()
|
|
43
77
|
});
|
|
78
|
+
await this.flushCleanupReports();
|
|
44
79
|
this.startHeartbeat();
|
|
80
|
+
this.startLeaseWatchdog();
|
|
81
|
+
this.startSandboxMaintenance();
|
|
45
82
|
}
|
|
46
83
|
async run(signal) {
|
|
47
84
|
let attempt = 0;
|
|
@@ -80,6 +117,7 @@ export class ForgeBridgeService {
|
|
|
80
117
|
this.stopped = true;
|
|
81
118
|
this.abortActiveSessions("bridge stopping");
|
|
82
119
|
await this.stopConnection();
|
|
120
|
+
await this.preflight.release();
|
|
83
121
|
}
|
|
84
122
|
async handleDispatch(rawDispatch) {
|
|
85
123
|
const dispatch = this.parseDispatchOrReport(rawDispatch);
|
|
@@ -89,20 +127,52 @@ export class ForgeBridgeService {
|
|
|
89
127
|
const leaseId = dispatch.lease.id;
|
|
90
128
|
const fencingToken = dispatch.lease.fencingToken;
|
|
91
129
|
const redactionSecrets = exactSecrets(dispatch, this.config);
|
|
92
|
-
|
|
93
|
-
|
|
130
|
+
const existing = this.activeSessions.get(leaseId);
|
|
131
|
+
if (existing) {
|
|
132
|
+
if (existing.fencingToken === fencingToken) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await this.sendResult(dispatch, failureResult("Dispatch fence conflicts with an active bridge session.")).catch(() => undefined);
|
|
94
136
|
return;
|
|
95
137
|
}
|
|
96
138
|
const controller = new AbortController();
|
|
97
|
-
this.activeSessions.set(leaseId, {
|
|
139
|
+
this.activeSessions.set(leaseId, {
|
|
140
|
+
fencingToken,
|
|
141
|
+
dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
|
|
142
|
+
runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
|
|
143
|
+
leaseExpiresAt: dispatch.lease.expiresAt,
|
|
144
|
+
sandboxed: dispatch.sandbox?.executionMode === "docker",
|
|
145
|
+
resources: dispatch.sandbox?.executionMode === "docker"
|
|
146
|
+
? dispatch.sandbox.requestedResources
|
|
147
|
+
: zeroSandboxResources(),
|
|
148
|
+
controller
|
|
149
|
+
});
|
|
98
150
|
try {
|
|
99
151
|
const normalized = this.normalizeDispatch(dispatch);
|
|
100
|
-
|
|
152
|
+
if (normalized.sandbox) {
|
|
153
|
+
const started = {
|
|
154
|
+
dispatchAttemptId: normalized.sandbox.dispatchAttemptId,
|
|
155
|
+
leaseId,
|
|
156
|
+
fencingToken,
|
|
157
|
+
runnerInstanceId: normalized.sandbox.runnerInstanceId
|
|
158
|
+
};
|
|
159
|
+
await this.invokeAck("DispatchStarted", started);
|
|
160
|
+
}
|
|
161
|
+
const sink = {
|
|
101
162
|
emit: async (event) => {
|
|
163
|
+
if (event.eventType === "provider_cli_started") {
|
|
164
|
+
const active = this.activeSessions.get(leaseId);
|
|
165
|
+
if (active)
|
|
166
|
+
active.resources = { ...active.resources, buildSlots: 0 };
|
|
167
|
+
}
|
|
102
168
|
try {
|
|
103
169
|
await this.invokeAck("DispatchEvent", {
|
|
104
170
|
leaseId,
|
|
105
171
|
fencingToken,
|
|
172
|
+
...(dispatch.sandbox ? {
|
|
173
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
174
|
+
runnerInstanceId: dispatch.sandbox.runnerInstanceId
|
|
175
|
+
} : {}),
|
|
106
176
|
event: redactSecrets(event, redactionSecrets)
|
|
107
177
|
});
|
|
108
178
|
}
|
|
@@ -114,19 +184,189 @@ export class ForgeBridgeService {
|
|
|
114
184
|
throw error;
|
|
115
185
|
}
|
|
116
186
|
}
|
|
117
|
-
}
|
|
118
|
-
|
|
187
|
+
};
|
|
188
|
+
const result = normalized.sandbox?.executionMode === "docker"
|
|
189
|
+
? await this.sandboxRuntime.runOnce(normalized, sink, controller.signal)
|
|
190
|
+
: await this.sessions.runOnce(normalized, sink, controller.signal);
|
|
191
|
+
if (normalized.sandbox?.executionMode === "docker") {
|
|
192
|
+
await this.flushCleanupReports().catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
await this.sendResult(dispatch, redactSecrets(result, redactionSecrets));
|
|
119
195
|
}
|
|
120
196
|
catch (error) {
|
|
121
197
|
const summary = controller.signal.aborted
|
|
122
198
|
? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
|
|
123
199
|
: redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
|
|
124
|
-
await this.sendResult(
|
|
200
|
+
await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
|
|
125
201
|
}
|
|
126
202
|
finally {
|
|
127
203
|
this.activeSessions.delete(leaseId);
|
|
128
204
|
}
|
|
129
205
|
}
|
|
206
|
+
async handleOffer(rawDispatch) {
|
|
207
|
+
const envelope = parseOfferEnvelope(rawDispatch);
|
|
208
|
+
if (!envelope) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const dispatch = this.parseDispatchOrReport(envelope.dispatch);
|
|
212
|
+
if (!dispatch?.sandbox
|
|
213
|
+
|| dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
|
|
214
|
+
|| dispatch.lease.id !== envelope.leaseId
|
|
215
|
+
|| dispatch.lease.fencingToken !== envelope.fencingToken)
|
|
216
|
+
return;
|
|
217
|
+
const sandbox = dispatch.sandbox;
|
|
218
|
+
let existing = this.reservations.get(sandbox.dispatchAttemptId);
|
|
219
|
+
if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
|
|
220
|
+
this.reservations.delete(sandbox.dispatchAttemptId);
|
|
221
|
+
existing = undefined;
|
|
222
|
+
}
|
|
223
|
+
if (existing) {
|
|
224
|
+
const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
|
|
225
|
+
await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
|
|
229
|
+
await this.sendOfferDecision(dispatch, false, "stale_runner_instance", "Offer targets a different runner process instance.");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (Date.parse(envelope.expiresAt) <= Date.now()) {
|
|
233
|
+
await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const preflight = this.preflight.current();
|
|
237
|
+
if (sandbox.executionMode === "docker") {
|
|
238
|
+
if (!this.config.dockerEnabled || !preflight?.healthy) {
|
|
239
|
+
await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const networkFailure = sandboxNetworkPolicyFailure(dispatch);
|
|
243
|
+
if (networkFailure) {
|
|
244
|
+
await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const runtimeState = this.sandboxRuntime.snapshot();
|
|
249
|
+
const usedResources = this.usedSandboxResources(runtimeState.degraded);
|
|
250
|
+
const capacity = this.sandboxResourceCapacity(preflight);
|
|
251
|
+
if (sandbox.executionMode === "docker"
|
|
252
|
+
&& !canReserveSandboxResources(usedResources, sandbox.requestedResources, capacity)) {
|
|
253
|
+
await this.sendOfferDecision(dispatch, false, "capacity_unavailable", "Runner has insufficient sandbox CPU, memory, workspace, PID, build, or slot capacity.");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
|
|
257
|
+
this.reservations.set(sandbox.dispatchAttemptId, {
|
|
258
|
+
leaseId: dispatch.lease.id,
|
|
259
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
260
|
+
runnerInstanceId: sandbox.runnerInstanceId,
|
|
261
|
+
expiresAt: dispatch.lease.expiresAt,
|
|
262
|
+
reservedUntil,
|
|
263
|
+
sandboxed: sandbox.executionMode === "docker",
|
|
264
|
+
resources: sandbox.requestedResources
|
|
265
|
+
});
|
|
266
|
+
await this.sendOfferDecision(dispatch, true, null, null, reservedUntil);
|
|
267
|
+
}
|
|
268
|
+
async handleStart(rawDispatch) {
|
|
269
|
+
const envelope = parseStartEnvelope(rawDispatch);
|
|
270
|
+
if (!envelope) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
let dispatch = this.parseDispatchOrReport(envelope.dispatch);
|
|
274
|
+
if (!dispatch?.sandbox
|
|
275
|
+
|| dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
|
|
276
|
+
|| dispatch.lease.id !== envelope.leaseId
|
|
277
|
+
|| dispatch.lease.fencingToken !== envelope.fencingToken)
|
|
278
|
+
return;
|
|
279
|
+
const reservation = this.reservations.get(dispatch.sandbox.dispatchAttemptId);
|
|
280
|
+
if (!reservation
|
|
281
|
+
|| reservation.leaseId !== dispatch.lease.id
|
|
282
|
+
|| reservation.fencingToken !== dispatch.lease.fencingToken
|
|
283
|
+
|| reservation.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
|
|
284
|
+
|| Date.parse(reservation.reservedUntil) <= Date.now()) {
|
|
285
|
+
await this.sendResult(dispatch, failureResult("Start rejected because its reservation is missing or stale.")).catch(() => undefined);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (dispatch.apiKeySecretGrant) {
|
|
289
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
290
|
+
await this.sendResult(dispatch, failureResult("Start rejected because protocol-v2 packets may not contain a plaintext provider grant.")).catch(() => undefined);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (dispatch.apiKeySecretGrantHandle) {
|
|
294
|
+
const response = await this.redeemSecretGrant(dispatch);
|
|
295
|
+
const grant = response.grant;
|
|
296
|
+
if (!response.accepted
|
|
297
|
+
|| !grant
|
|
298
|
+
|| grant.grantId !== dispatch.apiKeySecretGrantHandle.grantId
|
|
299
|
+
|| grant.provider !== dispatch.apiKeySecretGrantHandle.provider
|
|
300
|
+
|| Date.parse(grant.expiresAt) <= Date.now()) {
|
|
301
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
302
|
+
await this.sendResult(dispatch, failureResult(response.message ?? "Secret grant redemption was rejected.")).catch(() => undefined);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
dispatch = { ...dispatch, apiKeySecretGrant: grant, apiKeySecretGrantHandle: null };
|
|
306
|
+
}
|
|
307
|
+
else if (dispatch.execution.requiresApiKeySecretGrant) {
|
|
308
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
309
|
+
await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.reservations.delete(envelope.dispatchAttemptId);
|
|
313
|
+
await this.handleDispatch(dispatch);
|
|
314
|
+
}
|
|
315
|
+
async redeemSecretGrant(dispatch) {
|
|
316
|
+
const sandbox = dispatch.sandbox;
|
|
317
|
+
const handle = dispatch.apiKeySecretGrantHandle;
|
|
318
|
+
const connection = this.connection;
|
|
319
|
+
if (!connection)
|
|
320
|
+
return { accepted: false, code: "bridge.disconnected", message: "Bridge is disconnected.", grant: null };
|
|
321
|
+
return await connection.invoke("RedeemSecretGrant", {
|
|
322
|
+
dispatchAttemptId: sandbox.dispatchAttemptId,
|
|
323
|
+
leaseId: dispatch.lease.id,
|
|
324
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
325
|
+
runnerInstanceId: sandbox.runnerInstanceId,
|
|
326
|
+
grantId: handle.grantId
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async redeemPublishGrant(request, signal) {
|
|
330
|
+
if (signal?.aborted)
|
|
331
|
+
throw new Error(String(signal.reason ?? "publication cancelled"));
|
|
332
|
+
const connection = this.connection;
|
|
333
|
+
if (!connection)
|
|
334
|
+
throw new Error("sandbox_output_publish_forbidden: bridge is disconnected.");
|
|
335
|
+
const response = await connection.invoke("RedeemPublishGrant", request);
|
|
336
|
+
if (!response.accepted || !response.credential) {
|
|
337
|
+
throw new Error(`sandbox_output_publish_forbidden: ${response.message ?? response.code ?? "publication grant redemption was rejected"}`);
|
|
338
|
+
}
|
|
339
|
+
return response.credential;
|
|
340
|
+
}
|
|
341
|
+
async handleLeaseUpdate(request) {
|
|
342
|
+
if (request.runnerInstanceId !== this.config.runnerInstanceId) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const reservation = this.reservations.get(request.dispatchAttemptId);
|
|
346
|
+
if (reservation && reservation.leaseId === request.leaseId && reservation.fencingToken === request.fencingToken) {
|
|
347
|
+
reservation.expiresAt = request.expiresAt;
|
|
348
|
+
}
|
|
349
|
+
const active = this.activeSessions.get(request.leaseId);
|
|
350
|
+
if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
|
|
351
|
+
active.leaseExpiresAt = request.expiresAt;
|
|
352
|
+
await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null) {
|
|
356
|
+
if (!dispatch.sandbox)
|
|
357
|
+
return;
|
|
358
|
+
const payload = {
|
|
359
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
360
|
+
leaseId: dispatch.lease.id,
|
|
361
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
362
|
+
runnerInstanceId: dispatch.sandbox.runnerInstanceId,
|
|
363
|
+
accepted,
|
|
364
|
+
code,
|
|
365
|
+
message,
|
|
366
|
+
reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null
|
|
367
|
+
};
|
|
368
|
+
await this.invokeAck("OfferDecision", payload);
|
|
369
|
+
}
|
|
130
370
|
parseDispatchOrReport(rawDispatch) {
|
|
131
371
|
try {
|
|
132
372
|
return parseRunOnceDispatchPacket(rawDispatch);
|
|
@@ -137,7 +377,7 @@ export class ForgeBridgeService {
|
|
|
137
377
|
return null;
|
|
138
378
|
}
|
|
139
379
|
const summary = invalidDispatchSummary(error);
|
|
140
|
-
void this.
|
|
380
|
+
void this.sendLegacyResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
|
|
141
381
|
{
|
|
142
382
|
role: "runner",
|
|
143
383
|
kind: "error",
|
|
@@ -155,6 +395,14 @@ export class ForgeBridgeService {
|
|
|
155
395
|
}
|
|
156
396
|
active.controller.abort(request.reason ?? "cancelled by server");
|
|
157
397
|
}
|
|
398
|
+
async handleReap(request) {
|
|
399
|
+
if (!this.config.dockerEnabled
|
|
400
|
+
|| request.runnerId !== this.config.runnerId
|
|
401
|
+
|| request.runnerInstanceId !== this.config.runnerInstanceId)
|
|
402
|
+
return;
|
|
403
|
+
await this.runSandboxMaintenance();
|
|
404
|
+
await this.sendHeartbeat();
|
|
405
|
+
}
|
|
158
406
|
abortActiveSessions(reason) {
|
|
159
407
|
for (const active of this.activeSessions.values()) {
|
|
160
408
|
active.controller.abort(reason);
|
|
@@ -185,12 +433,20 @@ export class ForgeBridgeService {
|
|
|
185
433
|
: dispatch.qaBrowser
|
|
186
434
|
};
|
|
187
435
|
}
|
|
188
|
-
async sendResult(
|
|
436
|
+
async sendResult(dispatch, result) {
|
|
189
437
|
const payload = {
|
|
190
|
-
leaseId,
|
|
191
|
-
fencingToken,
|
|
438
|
+
leaseId: dispatch.lease.id,
|
|
439
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
440
|
+
dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
|
|
441
|
+
runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
|
|
192
442
|
result
|
|
193
443
|
};
|
|
444
|
+
await this.sendResultPayload(payload);
|
|
445
|
+
}
|
|
446
|
+
async sendLegacyResult(leaseId, fencingToken, result) {
|
|
447
|
+
await this.sendResultPayload({ leaseId, fencingToken, result });
|
|
448
|
+
}
|
|
449
|
+
async sendResultPayload(payload) {
|
|
194
450
|
try {
|
|
195
451
|
await this.invokeAck("DispatchResult", payload);
|
|
196
452
|
}
|
|
@@ -209,12 +465,90 @@ export class ForgeBridgeService {
|
|
|
209
465
|
}, intervalMs);
|
|
210
466
|
this.heartbeatTimer.unref?.();
|
|
211
467
|
}
|
|
468
|
+
startLeaseWatchdog() {
|
|
469
|
+
this.stopLeaseWatchdog();
|
|
470
|
+
this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
|
|
471
|
+
this.leaseWatchdogTimer.unref?.();
|
|
472
|
+
}
|
|
473
|
+
stopLeaseWatchdog() {
|
|
474
|
+
if (this.leaseWatchdogTimer) {
|
|
475
|
+
clearInterval(this.leaseWatchdogTimer);
|
|
476
|
+
this.leaseWatchdogTimer = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
expireLeases() {
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
for (const [attemptId, reservation] of this.reservations) {
|
|
482
|
+
if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
|
|
483
|
+
this.reservations.delete(attemptId);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (const active of this.activeSessions.values()) {
|
|
487
|
+
if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
|
|
488
|
+
active.controller.abort("dispatch lease expired");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
212
492
|
stopHeartbeat() {
|
|
213
493
|
if (this.heartbeatTimer) {
|
|
214
494
|
clearInterval(this.heartbeatTimer);
|
|
215
495
|
this.heartbeatTimer = null;
|
|
216
496
|
}
|
|
217
497
|
}
|
|
498
|
+
startSandboxMaintenance() {
|
|
499
|
+
this.stopSandboxMaintenance();
|
|
500
|
+
if (!this.config.dockerEnabled)
|
|
501
|
+
return;
|
|
502
|
+
this.sandboxMaintenanceTimer = setInterval(() => {
|
|
503
|
+
this.runSandboxMaintenance().catch(() => undefined);
|
|
504
|
+
}, 60_000);
|
|
505
|
+
this.sandboxMaintenanceTimer.unref?.();
|
|
506
|
+
}
|
|
507
|
+
stopSandboxMaintenance() {
|
|
508
|
+
if (this.sandboxMaintenanceTimer) {
|
|
509
|
+
clearInterval(this.sandboxMaintenanceTimer);
|
|
510
|
+
this.sandboxMaintenanceTimer = null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async runSandboxMaintenance() {
|
|
514
|
+
if (this.sandboxMaintenanceRunning)
|
|
515
|
+
return;
|
|
516
|
+
this.sandboxMaintenanceRunning = true;
|
|
517
|
+
try {
|
|
518
|
+
const preflight = await this.preflight.refresh();
|
|
519
|
+
if (canReapManagedSandboxes(preflight)) {
|
|
520
|
+
const attemptSnapshot = await this.readAttemptSnapshot();
|
|
521
|
+
await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
|
|
522
|
+
await this.flushCleanupReports();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
finally {
|
|
526
|
+
this.sandboxMaintenanceRunning = false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async readAttemptSnapshot() {
|
|
530
|
+
const connection = this.connection;
|
|
531
|
+
if (!connection)
|
|
532
|
+
throw new Error("Bridge is not connected.");
|
|
533
|
+
const response = await connection.invoke("AttemptSnapshot", {
|
|
534
|
+
runnerInstanceId: this.config.runnerInstanceId
|
|
535
|
+
});
|
|
536
|
+
if (!response.accepted) {
|
|
537
|
+
throw new BridgeCallbackRejectedError(response.code ?? null, response.message ?? response.code ?? "Attempt snapshot was rejected.");
|
|
538
|
+
}
|
|
539
|
+
return response.attempts ?? [];
|
|
540
|
+
}
|
|
541
|
+
async flushCleanupReports() {
|
|
542
|
+
if (!this.sandboxRuntime.pendingCleanupReports || !this.sandboxRuntime.acknowledgeCleanupReport)
|
|
543
|
+
return;
|
|
544
|
+
for (const report of await this.sandboxRuntime.pendingCleanupReports()) {
|
|
545
|
+
await this.invokeAck("CleanupReport", {
|
|
546
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
547
|
+
report
|
|
548
|
+
});
|
|
549
|
+
await this.sandboxRuntime.acknowledgeCleanupReport(report.reportId);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
218
552
|
async sendHeartbeat() {
|
|
219
553
|
await this.invokeAck("Heartbeat", {
|
|
220
554
|
status: "online",
|
|
@@ -224,8 +558,13 @@ export class ForgeBridgeService {
|
|
|
224
558
|
});
|
|
225
559
|
}
|
|
226
560
|
capabilities() {
|
|
561
|
+
const preflight = this.preflight.current();
|
|
562
|
+
const runtimeState = this.sandboxRuntime.snapshot();
|
|
563
|
+
const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
|
|
564
|
+
const used = this.usedSandboxResources(runtimeState.degraded);
|
|
565
|
+
const capacity = this.sandboxResourceCapacity(preflight);
|
|
227
566
|
return {
|
|
228
|
-
schemaVersion:
|
|
567
|
+
schemaVersion: 3,
|
|
229
568
|
activeSessionCount: this.activeSessions.size,
|
|
230
569
|
cliProviders: this.config.cliProviders,
|
|
231
570
|
providerStatuses: this.config.providerStatuses,
|
|
@@ -233,11 +572,56 @@ export class ForgeBridgeService {
|
|
|
233
572
|
repoMappings: repoMappingsFor(this.config),
|
|
234
573
|
os: this.config.os,
|
|
235
574
|
sandboxProfiles: this.config.sandboxProfiles,
|
|
575
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
576
|
+
trustDomainId: this.config.trustDomainId,
|
|
577
|
+
maxConcurrentSandboxes: preflight?.maxConcurrentSandboxes ?? 0,
|
|
578
|
+
reservedSandboxCount: reserved,
|
|
579
|
+
activeSandboxCount: runtimeState.active,
|
|
580
|
+
startingSandboxCount: runtimeState.starting,
|
|
581
|
+
degradedReservationCount: runtimeState.degraded,
|
|
582
|
+
sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
|
|
583
|
+
sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
|
|
584
|
+
sandboxFeatures: preflight?.features ?? null,
|
|
585
|
+
sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
|
|
586
|
+
sandboxAuthModes: Object.fromEntries(this.config.cliProviders.map(provider => [provider, ["api_key"]])),
|
|
587
|
+
availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
|
|
588
|
+
availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
|
|
589
|
+
availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
|
|
590
|
+
diskLow: preflight?.diskLow ?? null,
|
|
591
|
+
preflightGeneration: preflight?.generation ?? 0,
|
|
592
|
+
sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
|
|
593
|
+
sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
|
|
236
594
|
supportsApiSecretGrant: this.config.supportsApiSecretGrant,
|
|
237
595
|
supportsManagedWorktrees: this.config.supportsManagedWorktrees,
|
|
238
596
|
supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
|
|
239
597
|
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
|
|
240
|
-
supportsDraftRunOnce: this.config.supportsDraftRunOnce
|
|
598
|
+
supportsDraftRunOnce: this.config.supportsDraftRunOnce,
|
|
599
|
+
supportsDraftAttachments: this.config.supportsDraftAttachments,
|
|
600
|
+
supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
usedSandboxResources(degradedReservations) {
|
|
604
|
+
const resources = [
|
|
605
|
+
...[...this.reservations.values()]
|
|
606
|
+
.filter(reservation => reservation.sandboxed)
|
|
607
|
+
.map(reservation => reservation.resources),
|
|
608
|
+
...[...this.activeSessions.values()]
|
|
609
|
+
.filter(session => session.sandboxed)
|
|
610
|
+
.map(session => session.resources),
|
|
611
|
+
...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
|
|
612
|
+
];
|
|
613
|
+
return sumSandboxResources(resources);
|
|
614
|
+
}
|
|
615
|
+
sandboxResourceCapacity(preflight) {
|
|
616
|
+
if (!preflight)
|
|
617
|
+
return zeroSandboxResources();
|
|
618
|
+
return {
|
|
619
|
+
sandboxSlots: preflight.maxConcurrentSandboxes,
|
|
620
|
+
cpuMillis: preflight.availableCpuMillis,
|
|
621
|
+
memoryBytes: Math.max(0, preflight.availableMemoryBytes - 2 * 1024 ** 3),
|
|
622
|
+
workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes - this.config.minimumWorkspaceBytes),
|
|
623
|
+
pids: this.config.sandboxPidCapacity,
|
|
624
|
+
buildSlots: this.config.maxConcurrentSandboxBuilds
|
|
241
625
|
};
|
|
242
626
|
}
|
|
243
627
|
async invokeAck(methodName, payload) {
|
|
@@ -259,6 +643,8 @@ export class ForgeBridgeService {
|
|
|
259
643
|
}
|
|
260
644
|
async stopConnection() {
|
|
261
645
|
this.stopHeartbeat();
|
|
646
|
+
this.stopLeaseWatchdog();
|
|
647
|
+
this.stopSandboxMaintenance();
|
|
262
648
|
if (this.connection) {
|
|
263
649
|
await this.connection.stop().catch(() => undefined);
|
|
264
650
|
this.connection = null;
|
|
@@ -303,6 +689,9 @@ export class SignalRBridgeConnection {
|
|
|
303
689
|
})
|
|
304
690
|
.configureLogging(LogLevel.Warning)
|
|
305
691
|
.build();
|
|
692
|
+
this.connection.on("Superseded", () => {
|
|
693
|
+
void this.connection.stop();
|
|
694
|
+
});
|
|
306
695
|
}
|
|
307
696
|
start() {
|
|
308
697
|
return this.connection.start();
|
|
@@ -318,11 +707,31 @@ export class SignalRBridgeConnection {
|
|
|
318
707
|
void handler(raw).catch(() => undefined);
|
|
319
708
|
});
|
|
320
709
|
}
|
|
710
|
+
onOffer(handler) {
|
|
711
|
+
this.connection.on("Offer", (raw) => {
|
|
712
|
+
void handler(raw).catch(() => undefined);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
onStart(handler) {
|
|
716
|
+
this.connection.on("Start", (raw) => {
|
|
717
|
+
void handler(raw).catch(() => undefined);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
onLeaseUpdate(handler) {
|
|
721
|
+
this.connection.on("LeaseUpdate", (request) => {
|
|
722
|
+
void handler(request).catch(() => undefined);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
321
725
|
onCancel(handler) {
|
|
322
726
|
this.connection.on("Cancel", (request) => {
|
|
323
727
|
void handler(request).catch(() => undefined);
|
|
324
728
|
});
|
|
325
729
|
}
|
|
730
|
+
onReap(handler) {
|
|
731
|
+
this.connection.on("Reap", (request) => {
|
|
732
|
+
void handler(request).catch(() => undefined);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
326
735
|
invoke(methodName, payload) {
|
|
327
736
|
return this.connection.invoke(methodName, payload);
|
|
328
737
|
}
|
|
@@ -330,9 +739,28 @@ export class SignalRBridgeConnection {
|
|
|
330
739
|
export function defaultBridgeServiceConfig(overrides = {}) {
|
|
331
740
|
const defaults = defaultForgeBridgeConfig();
|
|
332
741
|
const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
|
|
742
|
+
const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
|
|
333
743
|
const config = {
|
|
334
744
|
serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
|
|
335
745
|
runnerId: process.env.FORGE_RUNNER_ID ?? "",
|
|
746
|
+
runnerInstanceId: process.env.FORGE_RUNNER_INSTANCE_ID ?? randomUUID(),
|
|
747
|
+
trustDomainId: process.env.FORGE_TRUST_DOMAIN_ID ?? "default",
|
|
748
|
+
maxConcurrentSandboxes: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOXES ?? 1), 1),
|
|
749
|
+
maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
|
|
750
|
+
sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
|
|
751
|
+
dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
|
|
752
|
+
sandboxPublicationMode,
|
|
753
|
+
dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
|
|
754
|
+
dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
|
|
755
|
+
sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
|
|
756
|
+
requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
|
|
757
|
+
diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
|
|
758
|
+
diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
|
|
759
|
+
minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
|
|
760
|
+
imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
|
|
761
|
+
imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
|
|
762
|
+
auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
|
|
763
|
+
auditRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000), 30 * 24 * 60 * 60 * 1000),
|
|
336
764
|
deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
|
|
337
765
|
deviceName: process.env.FORGE_DEVICE_NAME ?? "",
|
|
338
766
|
defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
|
|
@@ -348,8 +776,10 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
348
776
|
supportsManagedForgeArtifacts: true,
|
|
349
777
|
supportsRunnerAuthoredPhaseArtifacts: true,
|
|
350
778
|
supportsDraftRunOnce: true,
|
|
779
|
+
supportsDraftAttachments: true,
|
|
780
|
+
supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
|
|
351
781
|
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
352
|
-
protocolVersion:
|
|
782
|
+
protocolVersion: 2,
|
|
353
783
|
minServerVersion: null,
|
|
354
784
|
heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
|
|
355
785
|
reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
|
|
@@ -357,7 +787,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
357
787
|
};
|
|
358
788
|
return {
|
|
359
789
|
...config,
|
|
790
|
+
providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
|
|
360
791
|
sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
|
|
792
|
+
supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
|
|
361
793
|
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
362
794
|
};
|
|
363
795
|
}
|
|
@@ -539,9 +971,92 @@ function abortPromise(signal) {
|
|
|
539
971
|
}
|
|
540
972
|
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
541
973
|
}
|
|
974
|
+
function canReapManagedSandboxes(preflight) {
|
|
975
|
+
return preflight.healthy || preflight.unavailableCode === "docker_disk_low";
|
|
976
|
+
}
|
|
977
|
+
function sandboxNetworkPolicyFailure(dispatch) {
|
|
978
|
+
const sandbox = dispatch.sandbox;
|
|
979
|
+
if (!sandbox)
|
|
980
|
+
return "Protocol-v2 sandbox context is missing.";
|
|
981
|
+
if (sandbox.networkPolicy.mode === "open_public"
|
|
982
|
+
&& (sandbox.executionPolicy === "sandbox_required" || process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX !== "true")) {
|
|
983
|
+
return "open_public sandbox networking is not permitted by this runner policy.";
|
|
984
|
+
}
|
|
985
|
+
if (sandbox.networkPolicy.mode === "public_allowlist"
|
|
986
|
+
&& sandbox.networkPolicy.allowedHosts.length > 0
|
|
987
|
+
&& process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
|
|
988
|
+
return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
|
|
989
|
+
}
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
export function canReserveSandboxResources(used, requested, capacity) {
|
|
993
|
+
return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
|
|
994
|
+
&& used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
|
|
995
|
+
&& used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
|
|
996
|
+
&& used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
|
|
997
|
+
&& used.pids + requested.pids <= capacity.pids
|
|
998
|
+
&& used.buildSlots + requested.buildSlots <= capacity.buildSlots;
|
|
999
|
+
}
|
|
1000
|
+
function sumSandboxResources(resources) {
|
|
1001
|
+
return resources.reduce((total, resource) => ({
|
|
1002
|
+
sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
|
|
1003
|
+
cpuMillis: total.cpuMillis + resource.cpuMillis,
|
|
1004
|
+
memoryBytes: total.memoryBytes + resource.memoryBytes,
|
|
1005
|
+
workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
|
|
1006
|
+
pids: total.pids + resource.pids,
|
|
1007
|
+
buildSlots: total.buildSlots + resource.buildSlots
|
|
1008
|
+
}), zeroSandboxResources());
|
|
1009
|
+
}
|
|
1010
|
+
function zeroSandboxResources() {
|
|
1011
|
+
return { sandboxSlots: 0, cpuMillis: 0, memoryBytes: 0, workspaceBytes: 0, pids: 0, buildSlots: 0 };
|
|
1012
|
+
}
|
|
1013
|
+
function defaultSandboxResources() {
|
|
1014
|
+
return {
|
|
1015
|
+
sandboxSlots: 1,
|
|
1016
|
+
cpuMillis: 2000,
|
|
1017
|
+
memoryBytes: 4 * 1024 ** 3,
|
|
1018
|
+
workspaceBytes: 10 * 1024 ** 3,
|
|
1019
|
+
pids: 512,
|
|
1020
|
+
buildSlots: 0
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function parseOfferEnvelope(value) {
|
|
1024
|
+
return parseDispatchEnvelope(value);
|
|
1025
|
+
}
|
|
1026
|
+
function parseStartEnvelope(value) {
|
|
1027
|
+
return parseDispatchEnvelope(value);
|
|
1028
|
+
}
|
|
1029
|
+
function parseDispatchEnvelope(value) {
|
|
1030
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1031
|
+
return null;
|
|
1032
|
+
const envelope = value;
|
|
1033
|
+
if (envelope.schemaVersion !== 1
|
|
1034
|
+
|| typeof envelope.dispatchAttemptId !== "string"
|
|
1035
|
+
|| typeof envelope.leaseId !== "string"
|
|
1036
|
+
|| typeof envelope.fencingToken !== "string"
|
|
1037
|
+
|| typeof envelope.runnerInstanceId !== "string"
|
|
1038
|
+
|| typeof envelope.expiresAt !== "string"
|
|
1039
|
+
|| !envelope.dispatch)
|
|
1040
|
+
return null;
|
|
1041
|
+
return {
|
|
1042
|
+
dispatchAttemptId: envelope.dispatchAttemptId,
|
|
1043
|
+
leaseId: envelope.leaseId,
|
|
1044
|
+
fencingToken: envelope.fencingToken,
|
|
1045
|
+
runnerInstanceId: envelope.runnerInstanceId,
|
|
1046
|
+
expiresAt: envelope.expiresAt,
|
|
1047
|
+
dispatch: envelope.dispatch
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const maxAttachmentExactSecretLength = 8 * 1024;
|
|
542
1051
|
function exactSecrets(dispatch, config) {
|
|
543
1052
|
return [
|
|
544
1053
|
dispatch.apiKeySecretGrant?.secret,
|
|
545
|
-
config.deviceToken
|
|
1054
|
+
config.deviceToken,
|
|
1055
|
+
...boundedAttachmentExactSecrets(dispatch)
|
|
546
1056
|
].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
547
1057
|
}
|
|
1058
|
+
function boundedAttachmentExactSecrets(dispatch) {
|
|
1059
|
+
return (dispatch.draft?.attachments ?? [])
|
|
1060
|
+
.flatMap((attachment) => [attachment.textContent, attachment.contentBase64])
|
|
1061
|
+
.filter((secret) => typeof secret === "string" && secret.length > 0 && secret.length <= maxAttachmentExactSecretLength);
|
|
1062
|
+
}
|