@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 0.1.3-dev.ba7d36726eda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/bridge/bridge.base.d.ts +5 -1
- package/dist/bridge/bridge.service.d.ts +52 -3
- package/dist/bridge/bridge.service.js +796 -22
- package/dist/bridge/bridge.types.d.ts +135 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +356 -205
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +45 -2
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +50 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +74 -0
- package/dist/runtime/attempt-process-supervisor.js +290 -0
- package/dist/runtime/host-listener-inspector.d.ts +28 -0
- package/dist/runtime/host-listener-inspector.js +185 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +39 -0
- package/dist/runtime/host-runtime-contract.js +295 -0
- package/dist/runtime/host-runtime-lease.d.ts +122 -0
- package/dist/runtime/host-runtime-lease.js +1241 -0
- package/dist/runtime/host-runtime-promotion.d.ts +21 -0
- package/dist/runtime/host-runtime-promotion.js +128 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
- package/dist/runtime/node-host-runtime-adapter.js +205 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +136 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- 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 +45 -0
- package/dist/sandbox/preflight.js +300 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -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 +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -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 +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +67 -0
- package/dist/session/phase-completion.artifacts.d.ts +10 -0
- package/dist/session/phase-completion.artifacts.js +620 -0
- package/dist/session/phase-completion.d.ts +32 -44
- package/dist/session/phase-completion.helpers.d.ts +112 -0
- package/dist/session/phase-completion.helpers.js +665 -0
- package/dist/session/phase-completion.js +28 -3485
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +220 -0
- package/dist/session/phase-completion.runtime.d.ts +36 -0
- package/dist/session/phase-completion.runtime.js +611 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +707 -0
- package/dist/session/phase-completion.validation.d.ts +34 -0
- package/dist/session/phase-completion.validation.js +724 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +329 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +34 -0
- package/dist/session/provider-cli/provider-cli.process.js +316 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +573 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +65 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +9 -53
- package/dist/session/provider-cli/provider-cli.service.js +182 -2536
- package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +20 -0
- package/dist/session/provider-cli/provider-output-state.js +445 -0
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +4 -0
- package/dist/session/session.service.d.ts +6 -1
- package/dist/session/session.service.js +202 -53
- package/dist/session/session.types.d.ts +123 -1
- package/dist/session/session.types.js +301 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +40 -0
- package/dist/shared/process/host-process-supervisor.js +195 -0
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +39 -6
- package/dist/worktree/dependency-warmer.d.ts +11 -0
- package/dist/worktree/dependency-warmer.js +45 -1
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +8 -3
- package/dist/worktree/worktree.service.js +89 -52
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +287 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,28 +1,81 @@
|
|
|
1
|
-
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
|
|
1
|
+
import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
4
6
|
import { defaultForgeBridgeConfig } from "../config.js";
|
|
5
|
-
import {
|
|
7
|
+
import { supportsDraftImageAttachmentsFor } from "../shared/provider-capabilities.js";
|
|
8
|
+
import { redactText } from "../shared/redaction/redaction.js";
|
|
6
9
|
import { BridgeSessionService } from "../session/session.service.js";
|
|
10
|
+
import { ProcessBackedProviderCliService, RoutingProviderCliService, StreamingProviderProcessRunner, } from "../session/provider-cli/provider-cli.service.js";
|
|
11
|
+
import { redactRunnerDispatchEvent, redactRunOnceResultPacket } from "../session/session-redaction.js";
|
|
12
|
+
import { HostRuntimeLeaseManager } from "../runtime/host-runtime-lease.js";
|
|
13
|
+
import { GitWorktreeService } from "../worktree/worktree.service.js";
|
|
14
|
+
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
15
|
+
import { AttemptCoordinatedProviderProcessRunner, AttemptProcessSupervisorRegistry, } from "../runtime/attempt-process-supervisor-registry.js";
|
|
7
16
|
import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
|
|
8
17
|
import { forgeBridgeVersion } from "../version.js";
|
|
18
|
+
import { canRunDockerMaintenance, DockerPreflight } from "../sandbox/preflight.js";
|
|
19
|
+
import { validateSandboxContract } from "../sandbox/contract.js";
|
|
20
|
+
import { runSandboxProcess } from "../sandbox/process.js";
|
|
21
|
+
import { DockerSandboxRuntime, selectSandboxConfigRefs } from "../sandbox/runtime.js";
|
|
22
|
+
import { codexSandboxProviderAuthAdapter, sandboxProviderAuthAdapterFor, sandboxProviderAuthUnavailableMessage } from "../sandbox/provider-auth/codex.js";
|
|
9
23
|
export class ForgeBridgeService {
|
|
10
24
|
config;
|
|
11
|
-
sessions;
|
|
12
25
|
connections;
|
|
13
26
|
connection = null;
|
|
14
27
|
heartbeatTimer = null;
|
|
28
|
+
leaseWatchdogTimer = null;
|
|
29
|
+
sandboxMaintenanceTimer = null;
|
|
30
|
+
sandboxMaintenanceRunning = false;
|
|
15
31
|
closed = null;
|
|
16
32
|
stopped = false;
|
|
17
33
|
activeSessions = new Map();
|
|
18
|
-
|
|
34
|
+
reservations = new Map();
|
|
35
|
+
preflight;
|
|
36
|
+
sandboxRuntime;
|
|
37
|
+
sandboxResourceResolver;
|
|
38
|
+
sessions;
|
|
39
|
+
constructor(config, sessions = undefined, connections = new SignalRBridgeConnectionFactory(), preflight, sandboxRuntime, sandboxResourceResolver) {
|
|
19
40
|
this.config = config;
|
|
20
|
-
this.sessions = sessions;
|
|
21
41
|
this.connections = connections;
|
|
42
|
+
this.sessions = sessions ?? createDefaultSessionService(config.sandboxStateRoot);
|
|
43
|
+
this.preflight = preflight ?? new DockerPreflight({
|
|
44
|
+
dockerExecutable: config.dockerExecutable,
|
|
45
|
+
dockerContext: config.dockerContext,
|
|
46
|
+
stateRoot: config.sandboxStateRoot,
|
|
47
|
+
runnerInstanceId: config.runnerInstanceId,
|
|
48
|
+
maxConcurrentSandboxes: config.maxConcurrentSandboxes,
|
|
49
|
+
diskHighWatermarkPercent: config.diskHighWatermarkPercent,
|
|
50
|
+
diskLowWatermarkPercent: config.diskLowWatermarkPercent,
|
|
51
|
+
minimumWorkspaceBytes: config.minimumWorkspaceBytes,
|
|
52
|
+
hostMemoryReserveBytes: config.sandboxHostMemoryReserveBytes,
|
|
53
|
+
defaultWorkerMemoryLimitBytes: config.defaultSandboxWorkerMemoryLimitBytes,
|
|
54
|
+
defaultServiceMemoryLimitBytes: config.defaultSandboxServiceMemoryLimitBytes,
|
|
55
|
+
runtimeOverheadBytes: config.sandboxRuntimeOverheadBytes,
|
|
56
|
+
requireRootless: config.requireRootlessDocker
|
|
57
|
+
});
|
|
58
|
+
this.sandboxRuntime = sandboxRuntime ?? new DockerSandboxRuntime(config, (request, signal) => this.redeemPublishGrant(request, signal));
|
|
59
|
+
this.sandboxResourceResolver = sandboxResourceResolver
|
|
60
|
+
?? (dispatch => resolveSandboxResourcesFromRepo(dispatch, this.config.sandboxRuntimeOverheadBytes));
|
|
22
61
|
}
|
|
23
62
|
async connectOnce() {
|
|
24
63
|
await this.stopConnection();
|
|
25
64
|
this.stopped = false;
|
|
65
|
+
const hostReap = await this.sessions.reapHostRuntime?.({
|
|
66
|
+
runnerId: this.config.runnerId,
|
|
67
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
68
|
+
});
|
|
69
|
+
if (hostReap && (hostReap.reaped > 0 || hostReap.degraded > 0)) {
|
|
70
|
+
logBridgeStatus(`Managed-host recovery reaped ${hostReap.reaped} stale attempt(s), protected ${hostReap.reusedPidsProtected} reused PID(s), and left ${hostReap.degraded} degraded attempt(s) for reconciliation.`);
|
|
71
|
+
}
|
|
72
|
+
if (this.config.dockerEnabled) {
|
|
73
|
+
const preflight = await this.preflight.refresh();
|
|
74
|
+
if (canReapManagedSandboxes(preflight)) {
|
|
75
|
+
await this.sandboxRuntime.reap({ diskLow: preflight.diskLow });
|
|
76
|
+
await this.preflight.refresh();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
26
79
|
const connection = this.connections.create(this.config);
|
|
27
80
|
this.connection = connection;
|
|
28
81
|
this.closed = new Promise((resolve) => {
|
|
@@ -33,15 +86,23 @@ export class ForgeBridgeService {
|
|
|
33
86
|
});
|
|
34
87
|
});
|
|
35
88
|
connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
|
|
89
|
+
connection.onOffer?.((dispatch) => this.handleOffer(dispatch));
|
|
90
|
+
connection.onStart?.((dispatch) => this.handleStart(dispatch));
|
|
91
|
+
connection.onLeaseUpdate?.((request) => this.handleLeaseUpdate(request));
|
|
36
92
|
connection.onCancel((request) => this.handleCancel(request));
|
|
93
|
+
connection.onReap?.((request) => this.handleReap(request));
|
|
37
94
|
await connection.start();
|
|
38
95
|
await this.invokeAck("Hello", {
|
|
39
96
|
version: this.config.version,
|
|
40
97
|
protocolVersion: this.config.protocolVersion,
|
|
41
98
|
minServerVersion: this.config.minServerVersion ?? null,
|
|
99
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
42
100
|
capabilities: this.capabilities()
|
|
43
101
|
});
|
|
102
|
+
await this.flushCleanupReports();
|
|
44
103
|
this.startHeartbeat();
|
|
104
|
+
this.startLeaseWatchdog();
|
|
105
|
+
this.startSandboxMaintenance();
|
|
45
106
|
}
|
|
46
107
|
async run(signal) {
|
|
47
108
|
let attempt = 0;
|
|
@@ -80,6 +141,7 @@ export class ForgeBridgeService {
|
|
|
80
141
|
this.stopped = true;
|
|
81
142
|
this.abortActiveSessions("bridge stopping");
|
|
82
143
|
await this.stopConnection();
|
|
144
|
+
await this.preflight.release();
|
|
83
145
|
}
|
|
84
146
|
async handleDispatch(rawDispatch) {
|
|
85
147
|
const dispatch = this.parseDispatchOrReport(rawDispatch);
|
|
@@ -89,21 +151,53 @@ export class ForgeBridgeService {
|
|
|
89
151
|
const leaseId = dispatch.lease.id;
|
|
90
152
|
const fencingToken = dispatch.lease.fencingToken;
|
|
91
153
|
const redactionSecrets = exactSecrets(dispatch, this.config);
|
|
92
|
-
|
|
93
|
-
|
|
154
|
+
const existing = this.activeSessions.get(leaseId);
|
|
155
|
+
if (existing) {
|
|
156
|
+
if (existing.fencingToken === fencingToken) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await this.sendResult(dispatch, failureResult("Dispatch fence conflicts with an active bridge session.")).catch(() => undefined);
|
|
94
160
|
return;
|
|
95
161
|
}
|
|
96
162
|
const controller = new AbortController();
|
|
97
|
-
this.activeSessions.set(leaseId, {
|
|
163
|
+
this.activeSessions.set(leaseId, {
|
|
164
|
+
fencingToken,
|
|
165
|
+
dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
|
|
166
|
+
runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
|
|
167
|
+
leaseExpiresAt: dispatch.lease.expiresAt,
|
|
168
|
+
sandboxed: dispatch.sandbox?.executionMode === "docker",
|
|
169
|
+
resources: dispatch.sandbox?.executionMode === "docker"
|
|
170
|
+
? dispatch.sandbox.requestedResources
|
|
171
|
+
: zeroSandboxResources(),
|
|
172
|
+
controller
|
|
173
|
+
});
|
|
98
174
|
try {
|
|
99
175
|
const normalized = this.normalizeDispatch(dispatch);
|
|
100
|
-
|
|
176
|
+
if (normalized.sandbox) {
|
|
177
|
+
const started = {
|
|
178
|
+
dispatchAttemptId: normalized.sandbox.dispatchAttemptId,
|
|
179
|
+
leaseId,
|
|
180
|
+
fencingToken,
|
|
181
|
+
runnerInstanceId: normalized.sandbox.runnerInstanceId
|
|
182
|
+
};
|
|
183
|
+
await this.invokeAck("DispatchStarted", started);
|
|
184
|
+
}
|
|
185
|
+
const sink = {
|
|
101
186
|
emit: async (event) => {
|
|
187
|
+
if (event.eventType === "provider_cli_started") {
|
|
188
|
+
const active = this.activeSessions.get(leaseId);
|
|
189
|
+
if (active)
|
|
190
|
+
active.resources = { ...active.resources, buildSlots: 0 };
|
|
191
|
+
}
|
|
102
192
|
try {
|
|
103
193
|
await this.invokeAck("DispatchEvent", {
|
|
104
194
|
leaseId,
|
|
105
195
|
fencingToken,
|
|
106
|
-
|
|
196
|
+
...(dispatch.sandbox ? {
|
|
197
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
198
|
+
runnerInstanceId: dispatch.sandbox.runnerInstanceId
|
|
199
|
+
} : {}),
|
|
200
|
+
event: redactRunnerDispatchEvent(event, redactionSecrets)
|
|
107
201
|
});
|
|
108
202
|
}
|
|
109
203
|
catch (error) {
|
|
@@ -114,19 +208,221 @@ export class ForgeBridgeService {
|
|
|
114
208
|
throw error;
|
|
115
209
|
}
|
|
116
210
|
}
|
|
117
|
-
}
|
|
118
|
-
|
|
211
|
+
};
|
|
212
|
+
const result = normalized.sandbox?.executionMode === "docker"
|
|
213
|
+
? await this.sandboxRuntime.runOnce(normalized, sink, controller.signal)
|
|
214
|
+
: await this.sessions.runOnce(normalized, sink, controller.signal);
|
|
215
|
+
if (normalized.sandbox?.executionMode === "docker") {
|
|
216
|
+
await this.flushCleanupReports().catch(() => undefined);
|
|
217
|
+
}
|
|
218
|
+
await this.sendResult(dispatch, redactRunOnceResultPacket(result, redactionSecrets));
|
|
119
219
|
}
|
|
120
220
|
catch (error) {
|
|
121
221
|
const summary = controller.signal.aborted
|
|
122
222
|
? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
|
|
123
223
|
: redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
|
|
124
|
-
await this.sendResult(
|
|
224
|
+
await this.sendResult(dispatch, failureResult(summary)).catch(() => undefined);
|
|
125
225
|
}
|
|
126
226
|
finally {
|
|
127
227
|
this.activeSessions.delete(leaseId);
|
|
128
228
|
}
|
|
129
229
|
}
|
|
230
|
+
async handleOffer(rawDispatch) {
|
|
231
|
+
const envelope = parseOfferEnvelope(rawDispatch);
|
|
232
|
+
if (!envelope) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const dispatch = this.parseDispatchOrReport(envelope.dispatch);
|
|
236
|
+
if (!dispatch?.sandbox
|
|
237
|
+
|| dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
|
|
238
|
+
|| dispatch.lease.id !== envelope.leaseId
|
|
239
|
+
|| dispatch.lease.fencingToken !== envelope.fencingToken)
|
|
240
|
+
return;
|
|
241
|
+
const sandbox = dispatch.sandbox;
|
|
242
|
+
let existing = this.reservations.get(sandbox.dispatchAttemptId);
|
|
243
|
+
if (existing && Date.parse(existing.reservedUntil) <= Date.now()) {
|
|
244
|
+
this.reservations.delete(sandbox.dispatchAttemptId);
|
|
245
|
+
existing = undefined;
|
|
246
|
+
}
|
|
247
|
+
if (existing) {
|
|
248
|
+
const sameFence = existing.fencingToken === dispatch.lease.fencingToken;
|
|
249
|
+
await this.sendOfferDecision(dispatch, sameFence, sameFence ? null : "stale_fence", sameFence ? null : "Offer fence conflicts with the existing reservation.", sameFence ? existing.reservedUntil : null, sameFence ? existing.resources : null);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (sandbox.runnerInstanceId !== this.config.runnerInstanceId) {
|
|
253
|
+
await this.sendOfferDecision(dispatch, false, "stale_runner_instance", "Offer targets a different runner process instance.");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (Date.parse(envelope.expiresAt) <= Date.now()) {
|
|
257
|
+
await this.sendOfferDecision(dispatch, false, "lease_expired", "Offer lease is already expired.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const preflight = sandbox.executionMode === "docker" && this.config.dockerEnabled
|
|
261
|
+
? await this.preflight.run()
|
|
262
|
+
: this.preflight.current();
|
|
263
|
+
if (sandbox.executionMode === "docker") {
|
|
264
|
+
if (!this.config.dockerEnabled || !preflight?.healthy) {
|
|
265
|
+
await this.sendOfferDecision(dispatch, false, preflight?.unavailableCode ?? "docker_unavailable", preflight?.unavailableReason ?? "Docker sandbox preflight is unavailable.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const networkFailure = sandboxNetworkPolicyFailure(dispatch, this.config);
|
|
269
|
+
if (networkFailure) {
|
|
270
|
+
await this.sendOfferDecision(dispatch, false, "policy_mismatch", networkFailure);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const authFailure = sandboxAuthFailure(dispatch, this.config);
|
|
274
|
+
if (authFailure) {
|
|
275
|
+
await this.sendOfferDecision(dispatch, false, "sandbox_auth_unavailable", authFailure);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const runtimeState = this.sandboxRuntime.snapshot();
|
|
280
|
+
const usedResources = this.usedSandboxResources(runtimeState.degraded);
|
|
281
|
+
const activeResources = this.activeSandboxResources(runtimeState.degraded);
|
|
282
|
+
const capacity = this.sandboxResourceCapacity(preflight, activeResources);
|
|
283
|
+
let requestedResources = sandbox.requestedResources;
|
|
284
|
+
let sandboxConfigCommitSha = sandbox.sandboxConfigCommitSha ?? null;
|
|
285
|
+
if (sandbox.executionMode === "docker") {
|
|
286
|
+
try {
|
|
287
|
+
const profile = await this.sandboxResourceResolver(this.normalizeDispatch(dispatch));
|
|
288
|
+
requestedResources = profile.resources;
|
|
289
|
+
sandboxConfigCommitSha = profile.sandboxConfigCommitSha;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
await this.sendOfferDecision(dispatch, false, "sandbox_capacity_profile_unavailable", error instanceof Error ? error.message : "The immutable sandbox contract could not be profiled for admission.");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (sandbox.executionMode === "docker"
|
|
297
|
+
&& !canReserveSandboxResources(usedResources, requestedResources, capacity)) {
|
|
298
|
+
await this.sendOfferDecision(dispatch, false, "capacity_unavailable", sandboxCapacityFailureMessage(usedResources, requestedResources, capacity, preflight, this.config));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const reservedUntil = new Date(Math.min(Date.parse(dispatch.lease.expiresAt), Date.now() + 30_000)).toISOString();
|
|
302
|
+
this.reservations.set(sandbox.dispatchAttemptId, {
|
|
303
|
+
leaseId: dispatch.lease.id,
|
|
304
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
305
|
+
runnerInstanceId: sandbox.runnerInstanceId,
|
|
306
|
+
expiresAt: dispatch.lease.expiresAt,
|
|
307
|
+
reservedUntil,
|
|
308
|
+
sandboxed: sandbox.executionMode === "docker",
|
|
309
|
+
resources: requestedResources
|
|
310
|
+
});
|
|
311
|
+
await this.sendOfferDecision(dispatch, true, null, null, reservedUntil, requestedResources, sandboxConfigCommitSha);
|
|
312
|
+
}
|
|
313
|
+
async handleStart(rawDispatch) {
|
|
314
|
+
const envelope = parseStartEnvelope(rawDispatch);
|
|
315
|
+
if (!envelope) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
let dispatch = this.parseDispatchOrReport(envelope.dispatch);
|
|
319
|
+
if (!dispatch?.sandbox
|
|
320
|
+
|| dispatch.sandbox.dispatchAttemptId !== envelope.dispatchAttemptId
|
|
321
|
+
|| dispatch.lease.id !== envelope.leaseId
|
|
322
|
+
|| dispatch.lease.fencingToken !== envelope.fencingToken)
|
|
323
|
+
return;
|
|
324
|
+
const reservation = this.reservations.get(dispatch.sandbox.dispatchAttemptId);
|
|
325
|
+
if (!reservation
|
|
326
|
+
|| reservation.leaseId !== dispatch.lease.id
|
|
327
|
+
|| reservation.fencingToken !== dispatch.lease.fencingToken
|
|
328
|
+
|| reservation.runnerInstanceId !== dispatch.sandbox.runnerInstanceId
|
|
329
|
+
|| Date.parse(reservation.reservedUntil) <= Date.now()) {
|
|
330
|
+
await this.sendResult(dispatch, failureResult("Start rejected because its reservation is missing or stale.")).catch(() => undefined);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (dispatch.apiKeySecretGrant) {
|
|
334
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
335
|
+
await this.sendResult(dispatch, failureResult("Start rejected because protocol-v2 packets may not contain a plaintext provider grant.")).catch(() => undefined);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (dispatch.apiKeySecretGrantHandle) {
|
|
339
|
+
const response = await this.redeemSecretGrant(dispatch);
|
|
340
|
+
const grant = response.grant;
|
|
341
|
+
if (!response.accepted
|
|
342
|
+
|| !grant
|
|
343
|
+
|| grant.grantId !== dispatch.apiKeySecretGrantHandle.grantId
|
|
344
|
+
|| grant.provider !== dispatch.apiKeySecretGrantHandle.provider
|
|
345
|
+
|| Date.parse(grant.expiresAt) <= Date.now()) {
|
|
346
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
347
|
+
await this.sendResult(dispatch, failureResult(response.message ?? "Secret grant redemption was rejected.")).catch(() => undefined);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
dispatch = { ...dispatch, apiKeySecretGrant: grant, apiKeySecretGrantHandle: null };
|
|
351
|
+
}
|
|
352
|
+
else if (dispatch.execution.requiresApiKeySecretGrant) {
|
|
353
|
+
this.reservations.delete(dispatch.sandbox.dispatchAttemptId);
|
|
354
|
+
await this.sendResult(dispatch, failureResult("Start rejected because the provider secret grant handle is missing.")).catch(() => undefined);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
dispatch = {
|
|
358
|
+
...dispatch,
|
|
359
|
+
sandbox: { ...dispatch.sandbox, requestedResources: reservation.resources },
|
|
360
|
+
};
|
|
361
|
+
this.reservations.delete(envelope.dispatchAttemptId);
|
|
362
|
+
await this.handleDispatch(dispatch);
|
|
363
|
+
}
|
|
364
|
+
async redeemSecretGrant(dispatch) {
|
|
365
|
+
const sandbox = dispatch.sandbox;
|
|
366
|
+
const handle = dispatch.apiKeySecretGrantHandle;
|
|
367
|
+
const connection = this.connection;
|
|
368
|
+
if (!connection)
|
|
369
|
+
return { accepted: false, code: "bridge.disconnected", message: "Bridge is disconnected.", grant: null };
|
|
370
|
+
return await connection.invoke("RedeemSecretGrant", {
|
|
371
|
+
dispatchAttemptId: sandbox.dispatchAttemptId,
|
|
372
|
+
leaseId: dispatch.lease.id,
|
|
373
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
374
|
+
runnerInstanceId: sandbox.runnerInstanceId,
|
|
375
|
+
grantId: handle.grantId
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async redeemPublishGrant(request, signal) {
|
|
379
|
+
if (signal?.aborted)
|
|
380
|
+
throw new Error(String(signal.reason ?? "publication cancelled"));
|
|
381
|
+
const connection = this.connection;
|
|
382
|
+
if (!connection)
|
|
383
|
+
throw new Error("sandbox_output_publish_forbidden: bridge is disconnected.");
|
|
384
|
+
const response = await connection.invoke("RedeemPublishGrant", request);
|
|
385
|
+
if (!response.accepted || !response.credential) {
|
|
386
|
+
throw new Error(`sandbox_output_publish_forbidden: ${response.message ?? response.code ?? "publication grant redemption was rejected"}`);
|
|
387
|
+
}
|
|
388
|
+
return response.credential;
|
|
389
|
+
}
|
|
390
|
+
async handleLeaseUpdate(request) {
|
|
391
|
+
if (request.runnerInstanceId !== this.config.runnerInstanceId) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const reservation = this.reservations.get(request.dispatchAttemptId);
|
|
395
|
+
if (reservation && reservation.leaseId === request.leaseId && reservation.fencingToken === request.fencingToken) {
|
|
396
|
+
reservation.expiresAt = request.expiresAt;
|
|
397
|
+
}
|
|
398
|
+
const active = this.activeSessions.get(request.leaseId);
|
|
399
|
+
if (active && active.fencingToken === request.fencingToken && active.dispatchAttemptId === request.dispatchAttemptId) {
|
|
400
|
+
active.leaseExpiresAt = request.expiresAt;
|
|
401
|
+
if (active.sandboxed) {
|
|
402
|
+
await this.sandboxRuntime.updateLease(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
await this.sessions.updateLease?.(request.dispatchAttemptId, request.fencingToken, request.expiresAt);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async sendOfferDecision(dispatch, accepted, code = null, message = null, reservedUntil = null, reservedResources = null, sandboxConfigCommitSha = null) {
|
|
410
|
+
if (!dispatch.sandbox)
|
|
411
|
+
return;
|
|
412
|
+
const payload = {
|
|
413
|
+
dispatchAttemptId: dispatch.sandbox.dispatchAttemptId,
|
|
414
|
+
leaseId: dispatch.lease.id,
|
|
415
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
416
|
+
runnerInstanceId: dispatch.sandbox.runnerInstanceId,
|
|
417
|
+
accepted,
|
|
418
|
+
code,
|
|
419
|
+
message,
|
|
420
|
+
reservedUntil: accepted ? reservedUntil ?? dispatch.lease.expiresAt : null,
|
|
421
|
+
reservedResources: accepted ? reservedResources : null,
|
|
422
|
+
sandboxConfigCommitSha: accepted ? sandboxConfigCommitSha : null,
|
|
423
|
+
};
|
|
424
|
+
await this.invokeAck("OfferDecision", payload);
|
|
425
|
+
}
|
|
130
426
|
parseDispatchOrReport(rawDispatch) {
|
|
131
427
|
try {
|
|
132
428
|
return parseRunOnceDispatchPacket(rawDispatch);
|
|
@@ -137,7 +433,7 @@ export class ForgeBridgeService {
|
|
|
137
433
|
return null;
|
|
138
434
|
}
|
|
139
435
|
const summary = invalidDispatchSummary(error);
|
|
140
|
-
void this.
|
|
436
|
+
void this.sendLegacyResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
|
|
141
437
|
{
|
|
142
438
|
role: "runner",
|
|
143
439
|
kind: "error",
|
|
@@ -155,6 +451,14 @@ export class ForgeBridgeService {
|
|
|
155
451
|
}
|
|
156
452
|
active.controller.abort(request.reason ?? "cancelled by server");
|
|
157
453
|
}
|
|
454
|
+
async handleReap(request) {
|
|
455
|
+
if (!this.config.dockerEnabled
|
|
456
|
+
|| request.runnerId !== this.config.runnerId
|
|
457
|
+
|| request.runnerInstanceId !== this.config.runnerInstanceId)
|
|
458
|
+
return;
|
|
459
|
+
await this.runSandboxMaintenance();
|
|
460
|
+
await this.sendHeartbeat();
|
|
461
|
+
}
|
|
158
462
|
abortActiveSessions(reason) {
|
|
159
463
|
for (const active of this.activeSessions.values()) {
|
|
160
464
|
active.controller.abort(reason);
|
|
@@ -185,12 +489,20 @@ export class ForgeBridgeService {
|
|
|
185
489
|
: dispatch.qaBrowser
|
|
186
490
|
};
|
|
187
491
|
}
|
|
188
|
-
async sendResult(
|
|
492
|
+
async sendResult(dispatch, result) {
|
|
189
493
|
const payload = {
|
|
190
|
-
leaseId,
|
|
191
|
-
fencingToken,
|
|
494
|
+
leaseId: dispatch.lease.id,
|
|
495
|
+
fencingToken: dispatch.lease.fencingToken,
|
|
496
|
+
dispatchAttemptId: dispatch.sandbox?.dispatchAttemptId ?? null,
|
|
497
|
+
runnerInstanceId: dispatch.sandbox?.runnerInstanceId ?? null,
|
|
192
498
|
result
|
|
193
499
|
};
|
|
500
|
+
await this.sendResultPayload(payload);
|
|
501
|
+
}
|
|
502
|
+
async sendLegacyResult(leaseId, fencingToken, result) {
|
|
503
|
+
await this.sendResultPayload({ leaseId, fencingToken, result });
|
|
504
|
+
}
|
|
505
|
+
async sendResultPayload(payload) {
|
|
194
506
|
try {
|
|
195
507
|
await this.invokeAck("DispatchResult", payload);
|
|
196
508
|
}
|
|
@@ -209,12 +521,90 @@ export class ForgeBridgeService {
|
|
|
209
521
|
}, intervalMs);
|
|
210
522
|
this.heartbeatTimer.unref?.();
|
|
211
523
|
}
|
|
524
|
+
startLeaseWatchdog() {
|
|
525
|
+
this.stopLeaseWatchdog();
|
|
526
|
+
this.leaseWatchdogTimer = setInterval(() => this.expireLeases(), 1000);
|
|
527
|
+
this.leaseWatchdogTimer.unref?.();
|
|
528
|
+
}
|
|
529
|
+
stopLeaseWatchdog() {
|
|
530
|
+
if (this.leaseWatchdogTimer) {
|
|
531
|
+
clearInterval(this.leaseWatchdogTimer);
|
|
532
|
+
this.leaseWatchdogTimer = null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
expireLeases() {
|
|
536
|
+
const now = Date.now();
|
|
537
|
+
for (const [attemptId, reservation] of this.reservations) {
|
|
538
|
+
if (Date.parse(reservation.expiresAt) <= now || Date.parse(reservation.reservedUntil) <= now) {
|
|
539
|
+
this.reservations.delete(attemptId);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (const active of this.activeSessions.values()) {
|
|
543
|
+
if (Date.parse(active.leaseExpiresAt) <= now && !active.controller.signal.aborted) {
|
|
544
|
+
active.controller.abort("dispatch lease expired");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
212
548
|
stopHeartbeat() {
|
|
213
549
|
if (this.heartbeatTimer) {
|
|
214
550
|
clearInterval(this.heartbeatTimer);
|
|
215
551
|
this.heartbeatTimer = null;
|
|
216
552
|
}
|
|
217
553
|
}
|
|
554
|
+
startSandboxMaintenance() {
|
|
555
|
+
this.stopSandboxMaintenance();
|
|
556
|
+
if (!this.config.dockerEnabled)
|
|
557
|
+
return;
|
|
558
|
+
this.sandboxMaintenanceTimer = setInterval(() => {
|
|
559
|
+
this.runSandboxMaintenance().catch(() => undefined);
|
|
560
|
+
}, 60_000);
|
|
561
|
+
this.sandboxMaintenanceTimer.unref?.();
|
|
562
|
+
}
|
|
563
|
+
stopSandboxMaintenance() {
|
|
564
|
+
if (this.sandboxMaintenanceTimer) {
|
|
565
|
+
clearInterval(this.sandboxMaintenanceTimer);
|
|
566
|
+
this.sandboxMaintenanceTimer = null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async runSandboxMaintenance() {
|
|
570
|
+
if (this.sandboxMaintenanceRunning)
|
|
571
|
+
return;
|
|
572
|
+
this.sandboxMaintenanceRunning = true;
|
|
573
|
+
try {
|
|
574
|
+
const preflight = await this.preflight.refresh();
|
|
575
|
+
if (canReapManagedSandboxes(preflight)) {
|
|
576
|
+
const attemptSnapshot = await this.readAttemptSnapshot();
|
|
577
|
+
await this.sandboxRuntime.reap({ diskLow: preflight.diskLow, attemptSnapshot });
|
|
578
|
+
await this.flushCleanupReports();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
this.sandboxMaintenanceRunning = false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async readAttemptSnapshot() {
|
|
586
|
+
const connection = this.connection;
|
|
587
|
+
if (!connection)
|
|
588
|
+
throw new Error("Bridge is not connected.");
|
|
589
|
+
const response = await connection.invoke("AttemptSnapshot", {
|
|
590
|
+
runnerInstanceId: this.config.runnerInstanceId
|
|
591
|
+
});
|
|
592
|
+
if (!response.accepted) {
|
|
593
|
+
throw new BridgeCallbackRejectedError(response.code ?? null, response.message ?? response.code ?? "Attempt snapshot was rejected.");
|
|
594
|
+
}
|
|
595
|
+
return response.attempts ?? [];
|
|
596
|
+
}
|
|
597
|
+
async flushCleanupReports() {
|
|
598
|
+
if (!this.sandboxRuntime.pendingCleanupReports || !this.sandboxRuntime.acknowledgeCleanupReport)
|
|
599
|
+
return;
|
|
600
|
+
for (const report of await this.sandboxRuntime.pendingCleanupReports()) {
|
|
601
|
+
await this.invokeAck("CleanupReport", {
|
|
602
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
603
|
+
report
|
|
604
|
+
});
|
|
605
|
+
await this.sandboxRuntime.acknowledgeCleanupReport(report.reportId);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
218
608
|
async sendHeartbeat() {
|
|
219
609
|
await this.invokeAck("Heartbeat", {
|
|
220
610
|
status: "online",
|
|
@@ -224,8 +614,14 @@ export class ForgeBridgeService {
|
|
|
224
614
|
});
|
|
225
615
|
}
|
|
226
616
|
capabilities() {
|
|
617
|
+
const preflight = this.preflight.current();
|
|
618
|
+
const runtimeState = this.sandboxRuntime.snapshot();
|
|
619
|
+
const reserved = [...this.reservations.values()].filter(reservation => reservation.sandboxed).length;
|
|
620
|
+
const used = this.usedSandboxResources(runtimeState.degraded);
|
|
621
|
+
const activeResources = this.activeSandboxResources(runtimeState.degraded);
|
|
622
|
+
const capacity = this.sandboxResourceCapacity(preflight, activeResources);
|
|
227
623
|
return {
|
|
228
|
-
schemaVersion:
|
|
624
|
+
schemaVersion: 3,
|
|
229
625
|
activeSessionCount: this.activeSessions.size,
|
|
230
626
|
cliProviders: this.config.cliProviders,
|
|
231
627
|
providerStatuses: this.config.providerStatuses,
|
|
@@ -233,11 +629,85 @@ export class ForgeBridgeService {
|
|
|
233
629
|
repoMappings: repoMappingsFor(this.config),
|
|
234
630
|
os: this.config.os,
|
|
235
631
|
sandboxProfiles: this.config.sandboxProfiles,
|
|
632
|
+
runnerInstanceId: this.config.runnerInstanceId,
|
|
633
|
+
trustDomainId: this.config.trustDomainId,
|
|
634
|
+
maxConcurrentSandboxes: capacity.sandboxSlots,
|
|
635
|
+
reservedSandboxCount: reserved,
|
|
636
|
+
activeSandboxCount: runtimeState.active,
|
|
637
|
+
startingSandboxCount: runtimeState.starting,
|
|
638
|
+
degradedReservationCount: runtimeState.degraded,
|
|
639
|
+
sandboxRuntimes: preflight?.healthy ? ["docker"] : [],
|
|
640
|
+
sandboxRuntimeVersion: preflight?.runtimeVersion ?? null,
|
|
641
|
+
sandboxFeatures: preflight?.features ?? null,
|
|
642
|
+
sandboxPublicationModes: [this.config.sandboxPublicationMode ?? "shared_filesystem"],
|
|
643
|
+
sandboxAuthModes: sandboxAuthModesFor(this.config),
|
|
644
|
+
availableMemoryBytes: preflight ? Math.max(0, capacity.memoryBytes - used.memoryBytes) : null,
|
|
645
|
+
availableCpuMillis: preflight ? Math.max(0, capacity.cpuMillis - used.cpuMillis) : null,
|
|
646
|
+
availableWorkspaceBytes: preflight ? Math.max(0, capacity.workspaceBytes - used.workspaceBytes) : null,
|
|
647
|
+
diskLow: preflight?.diskLow ?? null,
|
|
648
|
+
preflightGeneration: preflight?.generation ?? 0,
|
|
649
|
+
sandboxUnavailableCode: this.config.dockerEnabled ? preflight?.unavailableCode ?? null : "docker_disabled",
|
|
650
|
+
sandboxUnavailableReason: this.config.dockerEnabled ? preflight?.unavailableReason ?? null : "Docker sandboxing is disabled by runner configuration.",
|
|
651
|
+
sandboxMemoryCapacity: preflight ? {
|
|
652
|
+
defaultWorkerMemoryLimitBytes: this.config.defaultSandboxWorkerMemoryLimitBytes,
|
|
653
|
+
defaultServiceMemoryLimitBytes: this.config.defaultSandboxServiceMemoryLimitBytes,
|
|
654
|
+
runtimeOverheadBytes: this.config.sandboxRuntimeOverheadBytes,
|
|
655
|
+
hostReserveBytes: preflight.hostReserveBytes,
|
|
656
|
+
activeReservationBytes: used.memoryBytes,
|
|
657
|
+
daemonTotalBytes: preflight.totalMemoryBytes,
|
|
658
|
+
daemonAvailableBytes: preflight.availableMemoryBytes,
|
|
659
|
+
availableForAdmissionBytes: Math.max(0, capacity.memoryBytes - used.memoryBytes),
|
|
660
|
+
} : null,
|
|
661
|
+
hostRuntime: this.sessions.runtimeCapacity?.() ?? null,
|
|
236
662
|
supportsApiSecretGrant: this.config.supportsApiSecretGrant,
|
|
237
663
|
supportsManagedWorktrees: this.config.supportsManagedWorktrees,
|
|
238
664
|
supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
|
|
239
665
|
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
|
|
240
|
-
supportsDraftRunOnce: this.config.supportsDraftRunOnce
|
|
666
|
+
supportsDraftRunOnce: this.config.supportsDraftRunOnce,
|
|
667
|
+
supportsDraftAttachments: this.config.supportsDraftAttachments,
|
|
668
|
+
supportsDraftImageAttachments: this.config.supportsDraftImageAttachments
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
usedSandboxResources(degradedReservations) {
|
|
672
|
+
const resources = [
|
|
673
|
+
...[...this.reservations.values()]
|
|
674
|
+
.filter(reservation => reservation.sandboxed)
|
|
675
|
+
.map(reservation => reservation.resources),
|
|
676
|
+
...[...this.activeSessions.values()]
|
|
677
|
+
.filter(session => session.sandboxed)
|
|
678
|
+
.map(session => session.resources),
|
|
679
|
+
...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
|
|
680
|
+
];
|
|
681
|
+
return sumSandboxResources(resources);
|
|
682
|
+
}
|
|
683
|
+
activeSandboxResources(degradedReservations) {
|
|
684
|
+
return sumSandboxResources([
|
|
685
|
+
...[...this.activeSessions.values()]
|
|
686
|
+
.filter(session => session.sandboxed)
|
|
687
|
+
.map(session => session.resources),
|
|
688
|
+
...Array.from({ length: degradedReservations }, () => defaultSandboxResources())
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
sandboxResourceCapacity(preflight, activeResources) {
|
|
692
|
+
if (!preflight)
|
|
693
|
+
return zeroSandboxResources();
|
|
694
|
+
return {
|
|
695
|
+
// Host free memory and disk already reflect active containers. Add their
|
|
696
|
+
// commitments back to form a stable total capacity, then subtract them
|
|
697
|
+
// exactly once in usedSandboxResources.
|
|
698
|
+
sandboxSlots: Math.min(this.config.maxConcurrentSandboxes, preflight.maxConcurrentSandboxes + activeResources.sandboxSlots),
|
|
699
|
+
cpuMillis: preflight.availableCpuMillis,
|
|
700
|
+
workerMemoryLimitBytes: 0,
|
|
701
|
+
serviceMemoryLimitBytes: 0,
|
|
702
|
+
runtimeOverheadBytes: 0,
|
|
703
|
+
memoryBytes: Math.max(0, preflight.availableMemoryBytes
|
|
704
|
+
- (this.config.sandboxHostMemoryReserveBytes ?? 2 * 1024 ** 3)
|
|
705
|
+
+ activeResources.memoryBytes),
|
|
706
|
+
workspaceBytes: Math.max(0, preflight.availableWorkspaceBytes
|
|
707
|
+
- this.config.minimumWorkspaceBytes
|
|
708
|
+
+ activeResources.workspaceBytes),
|
|
709
|
+
pids: this.config.sandboxPidCapacity,
|
|
710
|
+
buildSlots: this.config.maxConcurrentSandboxBuilds
|
|
241
711
|
};
|
|
242
712
|
}
|
|
243
713
|
async invokeAck(methodName, payload) {
|
|
@@ -259,6 +729,8 @@ export class ForgeBridgeService {
|
|
|
259
729
|
}
|
|
260
730
|
async stopConnection() {
|
|
261
731
|
this.stopHeartbeat();
|
|
732
|
+
this.stopLeaseWatchdog();
|
|
733
|
+
this.stopSandboxMaintenance();
|
|
262
734
|
if (this.connection) {
|
|
263
735
|
await this.connection.stop().catch(() => undefined);
|
|
264
736
|
this.connection = null;
|
|
@@ -279,6 +751,12 @@ function isDispatchNoLongerPending(error) {
|
|
|
279
751
|
}
|
|
280
752
|
return error instanceof Error && error.message === "Dispatch is no longer pending.";
|
|
281
753
|
}
|
|
754
|
+
function createDefaultSessionService(sandboxStateRoot) {
|
|
755
|
+
const rawProcessRunner = new StreamingProviderProcessRunner();
|
|
756
|
+
const processSupervisors = new AttemptProcessSupervisorRegistry();
|
|
757
|
+
const coordinatedProcessRunner = new AttemptCoordinatedProviderProcessRunner(processSupervisors, rawProcessRunner);
|
|
758
|
+
return new BridgeSessionService(new GitWorktreeService(undefined, new RepositoryCoordinatorLock(sandboxStateRoot)), new RoutingProviderCliService(new ProcessBackedProviderCliService(coordinatedProcessRunner)), new HostRuntimeLeaseManager(sandboxStateRoot, undefined, undefined, processSupervisors, rawProcessRunner));
|
|
759
|
+
}
|
|
282
760
|
export class SignalRBridgeConnectionFactory {
|
|
283
761
|
create(config) {
|
|
284
762
|
return new SignalRBridgeConnection(config);
|
|
@@ -293,10 +771,19 @@ export class SignalRBridgeConnection {
|
|
|
293
771
|
url.searchParams.set("runnerId", config.runnerId);
|
|
294
772
|
this.connection = new HubConnectionBuilder()
|
|
295
773
|
.withUrl(url.toString(), {
|
|
296
|
-
|
|
774
|
+
// Query-string access tokens are intentionally rejected by the API. The bridge is
|
|
775
|
+
// a Node CLI, so use long polling where Authorization headers are carried on each
|
|
776
|
+
// HTTP request instead of relying on browser/WebSocket token fallbacks.
|
|
777
|
+
transport: HttpTransportType.LongPolling,
|
|
778
|
+
headers: {
|
|
779
|
+
Authorization: `Bearer ${config.deviceToken}`
|
|
780
|
+
}
|
|
297
781
|
})
|
|
298
782
|
.configureLogging(LogLevel.Warning)
|
|
299
783
|
.build();
|
|
784
|
+
this.connection.on("Superseded", () => {
|
|
785
|
+
void this.connection.stop();
|
|
786
|
+
});
|
|
300
787
|
}
|
|
301
788
|
start() {
|
|
302
789
|
return this.connection.start();
|
|
@@ -312,11 +799,31 @@ export class SignalRBridgeConnection {
|
|
|
312
799
|
void handler(raw).catch(() => undefined);
|
|
313
800
|
});
|
|
314
801
|
}
|
|
802
|
+
onOffer(handler) {
|
|
803
|
+
this.connection.on("Offer", (raw) => {
|
|
804
|
+
void handler(raw).catch(() => undefined);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
onStart(handler) {
|
|
808
|
+
this.connection.on("Start", (raw) => {
|
|
809
|
+
void handler(raw).catch(() => undefined);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
onLeaseUpdate(handler) {
|
|
813
|
+
this.connection.on("LeaseUpdate", (request) => {
|
|
814
|
+
void handler(request).catch(() => undefined);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
315
817
|
onCancel(handler) {
|
|
316
818
|
this.connection.on("Cancel", (request) => {
|
|
317
819
|
void handler(request).catch(() => undefined);
|
|
318
820
|
});
|
|
319
821
|
}
|
|
822
|
+
onReap(handler) {
|
|
823
|
+
this.connection.on("Reap", (request) => {
|
|
824
|
+
void handler(request).catch(() => undefined);
|
|
825
|
+
});
|
|
826
|
+
}
|
|
320
827
|
invoke(methodName, payload) {
|
|
321
828
|
return this.connection.invoke(methodName, payload);
|
|
322
829
|
}
|
|
@@ -324,9 +831,37 @@ export class SignalRBridgeConnection {
|
|
|
324
831
|
export function defaultBridgeServiceConfig(overrides = {}) {
|
|
325
832
|
const defaults = defaultForgeBridgeConfig();
|
|
326
833
|
const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
|
|
834
|
+
const sandboxHostMemoryReserveDefault = process.platform === "linux"
|
|
835
|
+
? 2 * 1024 ** 3
|
|
836
|
+
: 1024 ** 3;
|
|
837
|
+
const sandboxPublicationMode = process.env.FORGE_SANDBOX_PUBLICATION_MODE === "bridge_push" ? "bridge_push" : "shared_filesystem";
|
|
327
838
|
const config = {
|
|
328
839
|
serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
|
|
329
840
|
runnerId: process.env.FORGE_RUNNER_ID ?? "",
|
|
841
|
+
runnerInstanceId: process.env.FORGE_RUNNER_INSTANCE_ID ?? randomUUID(),
|
|
842
|
+
trustDomainId: process.env.FORGE_TRUST_DOMAIN_ID ?? "default",
|
|
843
|
+
maxConcurrentSandboxes: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOXES ?? 3), 3),
|
|
844
|
+
maxConcurrentSandboxBuilds: positiveFiniteNumber(Number(process.env.FORGE_MAX_CONCURRENT_SANDBOX_BUILDS ?? 1), 1),
|
|
845
|
+
sandboxPidCapacity: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_PID_CAPACITY ?? 2048), 2048),
|
|
846
|
+
dockerEnabled: process.env.FORGE_DOCKER_SANDBOX !== "false",
|
|
847
|
+
allowOpenPublicSandbox: process.env.FORGE_ALLOW_OPEN_PUBLIC_SANDBOX === "true",
|
|
848
|
+
sandboxPublicationMode,
|
|
849
|
+
dockerExecutable: process.env.FORGE_DOCKER_EXECUTABLE ?? "docker",
|
|
850
|
+
dockerContext: process.env.FORGE_DOCKER_CONTEXT ?? "",
|
|
851
|
+
sandboxStateRoot: process.env.FORGE_SANDBOX_STATE_ROOT ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"),
|
|
852
|
+
requireRootlessDocker: process.env.FORGE_REQUIRE_ROOTLESS_DOCKER === "true" || process.platform === "linux",
|
|
853
|
+
diskHighWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_HIGH_WATERMARK_PERCENT ?? 85), 85),
|
|
854
|
+
diskLowWatermarkPercent: positiveFiniteNumber(Number(process.env.FORGE_DISK_LOW_WATERMARK_PERCENT ?? 75), 75),
|
|
855
|
+
minimumWorkspaceBytes: positiveFiniteNumber(Number(process.env.FORGE_MINIMUM_WORKSPACE_BYTES ?? 10 * 1024 ** 3), 10 * 1024 ** 3),
|
|
856
|
+
sandboxHostMemoryReserveBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_HOST_MEMORY_RESERVE_BYTES ?? sandboxHostMemoryReserveDefault), sandboxHostMemoryReserveDefault),
|
|
857
|
+
defaultSandboxWorkerMemoryLimitBytes: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_WORKER_MEMORY_LIMIT_BYTES ?? 2 * 1024 ** 3), 2 * 1024 ** 3),
|
|
858
|
+
defaultSandboxServiceMemoryLimitBytes: nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_DEFAULT_SERVICE_MEMORY_LIMIT_BYTES ?? 0), 0),
|
|
859
|
+
sandboxRuntimeOverheadBytes: Math.max(128 * 1024 ** 2, nonNegativeFiniteNumber(Number(process.env.FORGE_SANDBOX_RUNTIME_OVERHEAD_BYTES ?? 128 * 1024 ** 2), 128 * 1024 ** 2)),
|
|
860
|
+
sandboxMemorySampleIntervalMs: Math.min(30_000, Math.max(250, positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_MEMORY_SAMPLE_INTERVAL_MS ?? 1000), 1000))),
|
|
861
|
+
imageRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_COUNT ?? 3), 3),
|
|
862
|
+
imageRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_IMAGE_RETENTION_TTL_MS ?? 7 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000),
|
|
863
|
+
auditRetentionCount: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_COUNT ?? 200), 200),
|
|
864
|
+
auditRetentionTtlMs: positiveFiniteNumber(Number(process.env.FORGE_SANDBOX_AUDIT_RETENTION_TTL_MS ?? 30 * 24 * 60 * 60 * 1000), 30 * 24 * 60 * 60 * 1000),
|
|
330
865
|
deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
|
|
331
866
|
deviceName: process.env.FORGE_DEVICE_NAME ?? "",
|
|
332
867
|
defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
|
|
@@ -342,8 +877,10 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
342
877
|
supportsManagedForgeArtifacts: true,
|
|
343
878
|
supportsRunnerAuthoredPhaseArtifacts: true,
|
|
344
879
|
supportsDraftRunOnce: true,
|
|
880
|
+
supportsDraftAttachments: true,
|
|
881
|
+
supportsDraftImageAttachments: supportsDraftImageAttachmentsFor(cliProviders),
|
|
345
882
|
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
346
|
-
protocolVersion:
|
|
883
|
+
protocolVersion: 2,
|
|
347
884
|
minServerVersion: null,
|
|
348
885
|
heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
|
|
349
886
|
reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
|
|
@@ -351,7 +888,9 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
351
888
|
};
|
|
352
889
|
return {
|
|
353
890
|
...config,
|
|
891
|
+
providerStatuses: overrides.providerStatuses ?? providerStatusesFor(config.cliProviders),
|
|
354
892
|
sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
|
|
893
|
+
supportsDraftImageAttachments: (overrides.supportsDraftImageAttachments ?? true) && supportsDraftImageAttachmentsFor(config.cliProviders),
|
|
355
894
|
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
356
895
|
};
|
|
357
896
|
}
|
|
@@ -367,9 +906,36 @@ function normalizeServerUrl(serverUrl) {
|
|
|
367
906
|
function positiveFiniteNumber(value, fallback) {
|
|
368
907
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
369
908
|
}
|
|
909
|
+
function nonNegativeFiniteNumber(value, fallback) {
|
|
910
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
911
|
+
}
|
|
370
912
|
function providerStatusesFor(providers) {
|
|
371
913
|
return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
|
|
372
914
|
}
|
|
915
|
+
function sandboxAuthModesFor(config) {
|
|
916
|
+
return Object.fromEntries(config.cliProviders.map((provider) => {
|
|
917
|
+
const modes = ["api_key"];
|
|
918
|
+
if (sandboxBridgeCliAuthAvailable(config, provider)) {
|
|
919
|
+
modes.push("bridge_cli");
|
|
920
|
+
}
|
|
921
|
+
return [provider, modes];
|
|
922
|
+
}));
|
|
923
|
+
}
|
|
924
|
+
function sandboxBridgeCliAuthAvailable(config, provider) {
|
|
925
|
+
if (provider.trim().toLowerCase() !== "codex") {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
const status = providerStatus(config, provider);
|
|
929
|
+
if (status?.signedIn === false) {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
return codexSandboxProviderAuthAdapter.doctor().available;
|
|
933
|
+
}
|
|
934
|
+
function providerStatus(config, provider) {
|
|
935
|
+
const normalized = provider.trim().toLowerCase();
|
|
936
|
+
return Object.entries(config.providerStatuses)
|
|
937
|
+
.find(([key]) => key.trim().toLowerCase() === normalized)?.[1];
|
|
938
|
+
}
|
|
373
939
|
function defaultSandboxProfiles(providers, platform = process.platform) {
|
|
374
940
|
if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
|
|
375
941
|
return ["none"];
|
|
@@ -533,9 +1099,217 @@ function abortPromise(signal) {
|
|
|
533
1099
|
}
|
|
534
1100
|
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
535
1101
|
}
|
|
1102
|
+
export function canReapManagedSandboxes(preflight) {
|
|
1103
|
+
return canRunDockerMaintenance(preflight);
|
|
1104
|
+
}
|
|
1105
|
+
function sandboxNetworkPolicyFailure(dispatch, config) {
|
|
1106
|
+
const sandbox = dispatch.sandbox;
|
|
1107
|
+
if (!sandbox)
|
|
1108
|
+
return "Protocol-v2 sandbox context is missing.";
|
|
1109
|
+
if (sandbox.networkPolicy.mode === "open_public"
|
|
1110
|
+
&& (sandbox.executionPolicy === "sandbox_required" || !config.allowOpenPublicSandbox)) {
|
|
1111
|
+
return "open_public sandbox networking is not permitted by this runner policy.";
|
|
1112
|
+
}
|
|
1113
|
+
if (sandbox.networkPolicy.mode === "public_allowlist"
|
|
1114
|
+
&& sandbox.networkPolicy.allowedHosts.length > 0
|
|
1115
|
+
&& process.env.FORGE_SANDBOX_EGRESS_ENFORCED !== "true") {
|
|
1116
|
+
return "public_allowlist requires an operator-enforced Forge egress proxy or firewall.";
|
|
1117
|
+
}
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
function sandboxAuthFailure(dispatch, config) {
|
|
1121
|
+
const unsupported = sandboxProviderAuthUnavailableMessage(dispatch.execution.provider, dispatch.execution.authKind);
|
|
1122
|
+
if (unsupported) {
|
|
1123
|
+
return unsupported;
|
|
1124
|
+
}
|
|
1125
|
+
if (dispatch.execution.authKind.trim().toLowerCase() !== "bridge_cli") {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
const status = providerStatus(config, dispatch.execution.provider);
|
|
1129
|
+
if (status?.signedIn === false) {
|
|
1130
|
+
return `${dispatch.execution.provider} Local CLI is not signed in on this runner.`;
|
|
1131
|
+
}
|
|
1132
|
+
const adapter = sandboxProviderAuthAdapterFor(dispatch.execution.provider, dispatch.execution.authKind);
|
|
1133
|
+
if (!adapter) {
|
|
1134
|
+
return `${dispatch.execution.provider} Local CLI auth is not portable into Docker yet.`;
|
|
1135
|
+
}
|
|
1136
|
+
const doctor = adapter.doctor();
|
|
1137
|
+
return doctor.available ? null : doctor.message;
|
|
1138
|
+
}
|
|
1139
|
+
export function canReserveSandboxResources(used, requested, capacity) {
|
|
1140
|
+
return used.sandboxSlots + requested.sandboxSlots <= capacity.sandboxSlots
|
|
1141
|
+
&& used.cpuMillis + requested.cpuMillis <= capacity.cpuMillis
|
|
1142
|
+
&& used.memoryBytes + requested.memoryBytes <= capacity.memoryBytes
|
|
1143
|
+
&& used.workspaceBytes + requested.workspaceBytes <= capacity.workspaceBytes
|
|
1144
|
+
&& used.pids + requested.pids <= capacity.pids
|
|
1145
|
+
&& used.buildSlots + requested.buildSlots <= capacity.buildSlots;
|
|
1146
|
+
}
|
|
1147
|
+
export function sandboxCapacityFailureMessage(used, requested, capacity, preflight, config) {
|
|
1148
|
+
const availableSlots = Math.max(0, capacity.sandboxSlots - used.sandboxSlots);
|
|
1149
|
+
if (requested.sandboxSlots > availableSlots) {
|
|
1150
|
+
return `Sandbox slot capacity is insufficient: requested ${requested.sandboxSlots}, available ${availableSlots}, ${used.sandboxSlots} already reserved.`;
|
|
1151
|
+
}
|
|
1152
|
+
const availableMemoryBytes = Math.max(0, capacity.memoryBytes - used.memoryBytes);
|
|
1153
|
+
if (requested.memoryBytes > availableMemoryBytes) {
|
|
1154
|
+
return [
|
|
1155
|
+
`Sandbox memory capacity is insufficient: requested ${formatBytes(requested.memoryBytes)} total`,
|
|
1156
|
+
`(worker ${formatBytes(requested.workerMemoryLimitBytes)}, services ${formatBytes(requested.serviceMemoryLimitBytes)}, runtime overhead ${formatBytes(requested.runtimeOverheadBytes)}),`,
|
|
1157
|
+
`but ${formatBytes(availableMemoryBytes)} is available for admission.`,
|
|
1158
|
+
`Docker reports ${formatBytes(preflight?.availableMemoryBytes ?? 0)} available of ${formatBytes(preflight?.totalMemoryBytes ?? 0)};`,
|
|
1159
|
+
`Forge reserves ${formatBytes(preflight?.hostReserveBytes ?? config.sandboxHostMemoryReserveBytes ?? 0)} for the host and tracks ${formatBytes(used.memoryBytes)} in active or reserved attempts.`,
|
|
1160
|
+
].join(" ");
|
|
1161
|
+
}
|
|
1162
|
+
const availableCpuMillis = Math.max(0, capacity.cpuMillis - used.cpuMillis);
|
|
1163
|
+
if (requested.cpuMillis > availableCpuMillis) {
|
|
1164
|
+
return `Sandbox CPU capacity is insufficient: requested ${requested.cpuMillis} millis, available ${availableCpuMillis} millis, ${used.cpuMillis} millis already reserved.`;
|
|
1165
|
+
}
|
|
1166
|
+
const availableWorkspaceBytes = Math.max(0, capacity.workspaceBytes - used.workspaceBytes);
|
|
1167
|
+
if (requested.workspaceBytes > availableWorkspaceBytes) {
|
|
1168
|
+
return `Sandbox workspace capacity is insufficient: requested ${formatBytes(requested.workspaceBytes)}, available ${formatBytes(availableWorkspaceBytes)}, ${formatBytes(used.workspaceBytes)} already reserved.`;
|
|
1169
|
+
}
|
|
1170
|
+
const availablePids = Math.max(0, capacity.pids - used.pids);
|
|
1171
|
+
if (requested.pids > availablePids) {
|
|
1172
|
+
return `Sandbox PID capacity is insufficient: requested ${requested.pids}, available ${availablePids}, ${used.pids} already reserved.`;
|
|
1173
|
+
}
|
|
1174
|
+
const availableBuildSlots = Math.max(0, capacity.buildSlots - used.buildSlots);
|
|
1175
|
+
return `Sandbox build capacity is insufficient: requested ${requested.buildSlots}, available ${availableBuildSlots}, ${used.buildSlots} already reserved.`;
|
|
1176
|
+
}
|
|
1177
|
+
function formatBytes(bytes) {
|
|
1178
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
1179
|
+
return "0 B";
|
|
1180
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
1181
|
+
const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
1182
|
+
const value = bytes / 1024 ** power;
|
|
1183
|
+
return `${value >= 10 || power === 0 ? value.toFixed(0) : value.toFixed(2)} ${units[power]}`;
|
|
1184
|
+
}
|
|
1185
|
+
function sumSandboxResources(resources) {
|
|
1186
|
+
return resources.reduce((total, resource) => ({
|
|
1187
|
+
sandboxSlots: total.sandboxSlots + resource.sandboxSlots,
|
|
1188
|
+
cpuMillis: total.cpuMillis + resource.cpuMillis,
|
|
1189
|
+
workerMemoryLimitBytes: total.workerMemoryLimitBytes + resource.workerMemoryLimitBytes,
|
|
1190
|
+
serviceMemoryLimitBytes: total.serviceMemoryLimitBytes + resource.serviceMemoryLimitBytes,
|
|
1191
|
+
runtimeOverheadBytes: total.runtimeOverheadBytes + resource.runtimeOverheadBytes,
|
|
1192
|
+
memoryBytes: total.memoryBytes + resource.memoryBytes,
|
|
1193
|
+
workspaceBytes: total.workspaceBytes + resource.workspaceBytes,
|
|
1194
|
+
pids: total.pids + resource.pids,
|
|
1195
|
+
buildSlots: total.buildSlots + resource.buildSlots
|
|
1196
|
+
}), zeroSandboxResources());
|
|
1197
|
+
}
|
|
1198
|
+
function zeroSandboxResources() {
|
|
1199
|
+
return {
|
|
1200
|
+
sandboxSlots: 0,
|
|
1201
|
+
cpuMillis: 0,
|
|
1202
|
+
workerMemoryLimitBytes: 0,
|
|
1203
|
+
serviceMemoryLimitBytes: 0,
|
|
1204
|
+
runtimeOverheadBytes: 0,
|
|
1205
|
+
memoryBytes: 0,
|
|
1206
|
+
workspaceBytes: 0,
|
|
1207
|
+
pids: 0,
|
|
1208
|
+
buildSlots: 0,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function defaultSandboxResources() {
|
|
1212
|
+
return {
|
|
1213
|
+
sandboxSlots: 1,
|
|
1214
|
+
cpuMillis: 2000,
|
|
1215
|
+
workerMemoryLimitBytes: 2 * 1024 ** 3,
|
|
1216
|
+
serviceMemoryLimitBytes: 0,
|
|
1217
|
+
runtimeOverheadBytes: 128 * 1024 ** 2,
|
|
1218
|
+
memoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
|
|
1219
|
+
workspaceBytes: 10 * 1024 ** 3,
|
|
1220
|
+
pids: 512,
|
|
1221
|
+
buildSlots: 0
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
export function resolveDynamicSandboxResources(requested, serviceCount, networkMode, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
|
|
1225
|
+
const serviceMemoryLimitBytes = Math.max(0, serviceCount) * 1024 ** 3;
|
|
1226
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, baseRuntimeOverheadBytes)
|
|
1227
|
+
+ (networkMode === "public_allowlist" ? 256 * 1024 ** 2 : 0);
|
|
1228
|
+
return {
|
|
1229
|
+
...requested,
|
|
1230
|
+
serviceMemoryLimitBytes,
|
|
1231
|
+
runtimeOverheadBytes,
|
|
1232
|
+
memoryBytes: requested.workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
export async function resolveSandboxResourcesFromRepo(dispatch, baseRuntimeOverheadBytes = 128 * 1024 ** 2) {
|
|
1236
|
+
const repoPath = dispatch.repo.serverLocalPath;
|
|
1237
|
+
const sandbox = dispatch.sandbox;
|
|
1238
|
+
if (!repoPath || !sandbox) {
|
|
1239
|
+
throw new Error("Sandbox admission needs a mapped repository.");
|
|
1240
|
+
}
|
|
1241
|
+
const selection = selectSandboxConfigRefs(sandbox.executionPolicy, sandbox.sandboxConfigCommitSha, dispatch.repo.defaultBranch);
|
|
1242
|
+
const configCommitSha = await resolveFirstGitCommit(repoPath, selection.refs);
|
|
1243
|
+
if (!configCommitSha) {
|
|
1244
|
+
throw new Error("Sandbox admission could not resolve the immutable sandbox config commit.");
|
|
1245
|
+
}
|
|
1246
|
+
const contractResult = await runSandboxProcess({
|
|
1247
|
+
command: "git",
|
|
1248
|
+
args: ["-C", repoPath, "show", `${configCommitSha}:.forge/forge.sandbox.yml`],
|
|
1249
|
+
timeoutMs: 30_000,
|
|
1250
|
+
maxOutputBytes: 1024 * 1024,
|
|
1251
|
+
});
|
|
1252
|
+
if (contractResult.exitCode !== 0) {
|
|
1253
|
+
throw new Error("The approved immutable sandbox contract is unavailable in the mapped repository.");
|
|
1254
|
+
}
|
|
1255
|
+
const contract = validateSandboxContract(contractResult.stdout);
|
|
1256
|
+
return {
|
|
1257
|
+
resources: resolveDynamicSandboxResources(sandbox.requestedResources, contract.contract.services.length, sandbox.networkPolicy.mode, baseRuntimeOverheadBytes),
|
|
1258
|
+
sandboxConfigCommitSha: configCommitSha,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
async function resolveFirstGitCommit(repoPath, refs) {
|
|
1262
|
+
for (const ref of refs) {
|
|
1263
|
+
const result = await runSandboxProcess({
|
|
1264
|
+
command: "git",
|
|
1265
|
+
args: ["-C", repoPath, "rev-parse", "--verify", `${ref}^{commit}`],
|
|
1266
|
+
timeoutMs: 30_000,
|
|
1267
|
+
maxOutputBytes: 1024 * 1024,
|
|
1268
|
+
});
|
|
1269
|
+
const commitSha = result.stdout.trim().toLowerCase();
|
|
1270
|
+
if (result.exitCode === 0 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commitSha)) {
|
|
1271
|
+
return commitSha;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
function parseOfferEnvelope(value) {
|
|
1277
|
+
return parseDispatchEnvelope(value);
|
|
1278
|
+
}
|
|
1279
|
+
function parseStartEnvelope(value) {
|
|
1280
|
+
return parseDispatchEnvelope(value);
|
|
1281
|
+
}
|
|
1282
|
+
function parseDispatchEnvelope(value) {
|
|
1283
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1284
|
+
return null;
|
|
1285
|
+
const envelope = value;
|
|
1286
|
+
if (envelope.schemaVersion !== 1
|
|
1287
|
+
|| typeof envelope.dispatchAttemptId !== "string"
|
|
1288
|
+
|| typeof envelope.leaseId !== "string"
|
|
1289
|
+
|| typeof envelope.fencingToken !== "string"
|
|
1290
|
+
|| typeof envelope.runnerInstanceId !== "string"
|
|
1291
|
+
|| typeof envelope.expiresAt !== "string"
|
|
1292
|
+
|| !envelope.dispatch)
|
|
1293
|
+
return null;
|
|
1294
|
+
return {
|
|
1295
|
+
dispatchAttemptId: envelope.dispatchAttemptId,
|
|
1296
|
+
leaseId: envelope.leaseId,
|
|
1297
|
+
fencingToken: envelope.fencingToken,
|
|
1298
|
+
runnerInstanceId: envelope.runnerInstanceId,
|
|
1299
|
+
expiresAt: envelope.expiresAt,
|
|
1300
|
+
dispatch: envelope.dispatch
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
const maxAttachmentExactSecretLength = 8 * 1024;
|
|
536
1304
|
function exactSecrets(dispatch, config) {
|
|
537
1305
|
return [
|
|
538
1306
|
dispatch.apiKeySecretGrant?.secret,
|
|
539
|
-
config.deviceToken
|
|
1307
|
+
config.deviceToken,
|
|
1308
|
+
...boundedAttachmentExactSecrets(dispatch)
|
|
540
1309
|
].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
541
1310
|
}
|
|
1311
|
+
function boundedAttachmentExactSecrets(dispatch) {
|
|
1312
|
+
return (dispatch.draft?.attachments ?? [])
|
|
1313
|
+
.flatMap((attachment) => [attachment.textContent, attachment.contentBase64])
|
|
1314
|
+
.filter((secret) => typeof secret === "string" && secret.length > 0 && secret.length <= maxAttachmentExactSecretLength);
|
|
1315
|
+
}
|