@loucompanion/forge-bridge 0.1.3-dev.9bc5014a2270 → 0.1.3-dev.a58d7cd1092f
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 +127 -0
- package/dist/bridge/bridge.base.d.ts +8 -1
- package/dist/bridge/bridge.service.d.ts +93 -3
- package/dist/bridge/bridge.service.js +1358 -47
- package/dist/bridge/bridge.types.d.ts +202 -1
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -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.js +47 -1
- package/dist/cli/cli.service.d.ts +45 -2
- package/dist/cli/cli.service.js +378 -26
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +105 -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 +97 -0
- package/dist/runtime/attempt-process-supervisor.js +386 -0
- package/dist/runtime/docker-engine-probe.d.ts +83 -0
- package/dist/runtime/docker-engine-probe.js +300 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +46 -0
- package/dist/runtime/host-listener-inspector.js +349 -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 +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +197 -0
- package/dist/runtime/host-runtime-lease.js +2203 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/memory-capacity.d.ts +68 -0
- package/dist/runtime/memory-capacity.js +121 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +284 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -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 +84 -0
- package/dist/sandbox/preflight.js +521 -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 +121 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -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 +72 -0
- package/dist/session/managed-attempt-tools.d.ts +21 -0
- package/dist/session/managed-attempt-tools.js +912 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +335 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +256 -26
- package/dist/session/phase-completion.d.ts +12 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +51 -10
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +24 -8
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +189 -8
- package/dist/session/phase-completion.schemas.js +117 -4
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +153 -22
- package/dist/session/pre-publication-validation.d.ts +46 -0
- package/dist/session/pre-publication-validation.js +208 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
- 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 +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
- package/dist/session/provider-cli/provider-cli.process.js +474 -23
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +224 -24
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +26 -3
- package/dist/session/provider-cli/provider-cli.service.js +646 -90
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +91 -16
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +38 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +530 -42
- package/dist/session/session.types.d.ts +124 -1
- package/dist/session/session.types.js +231 -4
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
- package/dist/shared/process/dotnet-toolchain.js +179 -0
- 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 +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- 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 +16 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +36 -2
- package/dist/worktree/dependency-warmer.js +347 -11
- package/dist/worktree/index.d.ts +2 -1
- 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 +13 -2
- package/dist/worktree/worktree.service.js +238 -47
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +8 -4
- package/runtime/windows-job-supervisor.ps1 +444 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function createLeaseUpdate(dispatchAttemptId, fencingToken, expiresAt, controlKey) {
|
|
3
|
+
return {
|
|
4
|
+
schemaVersion: 1,
|
|
5
|
+
dispatchAttemptId,
|
|
6
|
+
fencingToken,
|
|
7
|
+
expiresAt,
|
|
8
|
+
mac: leaseMac(dispatchAttemptId, fencingToken, expiresAt, controlKey)
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export class MonotonicLeaseDeadline {
|
|
12
|
+
dispatchAttemptId;
|
|
13
|
+
fencingToken;
|
|
14
|
+
controlKey;
|
|
15
|
+
expiresAtMs;
|
|
16
|
+
deadlineMs;
|
|
17
|
+
constructor(dispatchAttemptId, fencingToken, initialExpiresAt, controlKey, wallNowMs = Date.now(), monotonicNowMs = performance.now()) {
|
|
18
|
+
this.dispatchAttemptId = dispatchAttemptId;
|
|
19
|
+
this.fencingToken = fencingToken;
|
|
20
|
+
this.controlKey = controlKey;
|
|
21
|
+
this.expiresAtMs = parsedExpiry(initialExpiresAt);
|
|
22
|
+
this.deadlineMs = monotonicNowMs + Math.max(0, this.expiresAtMs - wallNowMs);
|
|
23
|
+
}
|
|
24
|
+
apply(update, wallNowMs = Date.now(), monotonicNowMs = performance.now()) {
|
|
25
|
+
if (!isSignedLeaseUpdate(update)
|
|
26
|
+
|| update.dispatchAttemptId !== this.dispatchAttemptId
|
|
27
|
+
|| update.fencingToken !== this.fencingToken
|
|
28
|
+
|| parsedExpiry(update.expiresAt) <= this.expiresAtMs)
|
|
29
|
+
return false;
|
|
30
|
+
const expected = leaseMac(update.dispatchAttemptId, update.fencingToken, update.expiresAt, this.controlKey);
|
|
31
|
+
if (!safeEqual(expected, update.mac))
|
|
32
|
+
return false;
|
|
33
|
+
this.expiresAtMs = parsedExpiry(update.expiresAt);
|
|
34
|
+
this.deadlineMs = monotonicNowMs + Math.max(0, this.expiresAtMs - wallNowMs);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
expired(monotonicNowMs = performance.now()) {
|
|
38
|
+
return monotonicNowMs >= this.deadlineMs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function leaseMac(dispatchAttemptId, fencingToken, expiresAt, controlKey) {
|
|
42
|
+
return createHmac("sha256", controlKey)
|
|
43
|
+
.update(`${dispatchAttemptId}\n${fencingToken}\n${expiresAt}`, "utf8")
|
|
44
|
+
.digest("base64url");
|
|
45
|
+
}
|
|
46
|
+
function safeEqual(left, right) {
|
|
47
|
+
const leftBytes = Buffer.from(left, "utf8");
|
|
48
|
+
const rightBytes = Buffer.from(right, "utf8");
|
|
49
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
50
|
+
}
|
|
51
|
+
function parsedExpiry(value) {
|
|
52
|
+
const parsed = Date.parse(value);
|
|
53
|
+
if (!Number.isFinite(parsed))
|
|
54
|
+
throw new Error("Lease expiry is invalid.");
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
function isSignedLeaseUpdate(value) {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
59
|
+
return false;
|
|
60
|
+
const update = value;
|
|
61
|
+
return update.schemaVersion === 1
|
|
62
|
+
&& typeof update.dispatchAttemptId === "string"
|
|
63
|
+
&& typeof update.fencingToken === "string"
|
|
64
|
+
&& typeof update.expiresAt === "string"
|
|
65
|
+
&& Number.isFinite(Date.parse(update.expiresAt))
|
|
66
|
+
&& typeof update.mac === "string";
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { CommandRunner, SandboxPreflightResult } from "./sandbox.types.js";
|
|
2
|
+
export interface DockerPreflightOptions {
|
|
3
|
+
readonly dockerExecutable?: string;
|
|
4
|
+
readonly dockerContext?: string;
|
|
5
|
+
readonly stateRoot?: string;
|
|
6
|
+
readonly runnerInstanceId?: string;
|
|
7
|
+
readonly maxConcurrentSandboxes?: number;
|
|
8
|
+
readonly diskHighWatermarkPercent?: number;
|
|
9
|
+
readonly diskLowWatermarkPercent?: number;
|
|
10
|
+
readonly minimumWorkspaceBytes?: number;
|
|
11
|
+
readonly hostMemoryReserveBytes?: number;
|
|
12
|
+
readonly defaultWorkerMemoryLimitBytes?: number;
|
|
13
|
+
readonly defaultServiceMemoryLimitBytes?: number;
|
|
14
|
+
readonly runtimeOverheadBytes?: number;
|
|
15
|
+
readonly requireRootless?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare class DockerPreflight {
|
|
18
|
+
private readonly commands;
|
|
19
|
+
private readonly cacheMs;
|
|
20
|
+
private generation;
|
|
21
|
+
private cached;
|
|
22
|
+
private fullPreflightAt;
|
|
23
|
+
private staticPreflight;
|
|
24
|
+
private inspectionInFlight;
|
|
25
|
+
private lastCapacitySample;
|
|
26
|
+
private readonly options;
|
|
27
|
+
private readonly docker;
|
|
28
|
+
private readonly ownership;
|
|
29
|
+
private diskPressureLatched;
|
|
30
|
+
private readonly memoryAvailability;
|
|
31
|
+
constructor(options?: DockerPreflightOptions | string, commands?: CommandRunner, cacheMs?: number);
|
|
32
|
+
private unavailable;
|
|
33
|
+
private memoryReserveBytes;
|
|
34
|
+
private defaultAdmissionMemoryBytes;
|
|
35
|
+
current(): SandboxPreflightResult | null;
|
|
36
|
+
refresh(): Promise<SandboxPreflightResult>;
|
|
37
|
+
release(): Promise<void>;
|
|
38
|
+
run(force?: boolean): Promise<SandboxPreflightResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Refreshes only the admission inputs that change while the daemon's static
|
|
41
|
+
* capability snapshot remains valid. Heartbeats use this path so periodic
|
|
42
|
+
* liveness reporting does not wake Docker/WSL to rerun feature probes.
|
|
43
|
+
*/
|
|
44
|
+
refreshCapacity(): Promise<SandboxPreflightResult>;
|
|
45
|
+
/**
|
|
46
|
+
* When the cached static preflight remains usable, produces a capacity sample
|
|
47
|
+
* that began after this admission request. An older inspection is awaited,
|
|
48
|
+
* not preempted or reused. Later admission callers may share the next sample
|
|
49
|
+
* only when it began after they entered this method. Without a usable static
|
|
50
|
+
* snapshot, the existing fail-closed result is returned without probing.
|
|
51
|
+
*/
|
|
52
|
+
refreshCapacityForAdmission(): Promise<SandboxPreflightResult>;
|
|
53
|
+
private startInspection;
|
|
54
|
+
private inspect;
|
|
55
|
+
private inspectCapacity;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Uses the same reviewed reserve vector as host-runtime admission. The input
|
|
59
|
+
* is always the physical host total; Docker Desktop's VM total is tracked as
|
|
60
|
+
* a separate daemon commitment domain.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveSandboxHostMemoryReserveBytes(totalMemoryBytes: number, explicitBytes?: number, environment?: NodeJS.ProcessEnv): number;
|
|
63
|
+
export declare function canRunDockerMaintenance(preflight: {
|
|
64
|
+
readonly healthy: boolean;
|
|
65
|
+
readonly unavailableCode?: string | null;
|
|
66
|
+
}): boolean;
|
|
67
|
+
export declare function evaluateDiskPressure(availableBytes: number, totalBytes: number, minimumAvailableBytes: number, highWatermarkPercent: number, lowWatermarkPercent: number, pressureLatched: boolean): boolean;
|
|
68
|
+
export declare function requiredAvailableBytesToClearDiskPressure(totalBytes: number, minimumAvailableBytes: number, lowWatermarkPercent: number): number;
|
|
69
|
+
export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
|
|
70
|
+
export declare class DockerContextOwnershipLock {
|
|
71
|
+
private lockPath;
|
|
72
|
+
private lockToken;
|
|
73
|
+
acquire(contextName?: string): Promise<void>;
|
|
74
|
+
release(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export declare function parseDockerMemoryUsage(value: string): number;
|
|
77
|
+
interface DockerContextLockOwner {
|
|
78
|
+
readonly token?: string;
|
|
79
|
+
readonly pid?: number;
|
|
80
|
+
readonly processIdentity?: string | null;
|
|
81
|
+
readonly acquiredAt?: string;
|
|
82
|
+
}
|
|
83
|
+
export declare function dockerContextOwnerIsActive(owner: DockerContextLockOwner | null): boolean;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { formatBytes } from "../shared/format/format-bytes.js";
|
|
6
|
+
import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
|
|
7
|
+
import { RecoveryStabilizedMemory, resolveAdaptiveMemoryReserveBytes } from "../runtime/memory-capacity.js";
|
|
8
|
+
import { BoundedCommandRunner } from "./command-runner.js";
|
|
9
|
+
const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
|
|
10
|
+
export class DockerPreflight {
|
|
11
|
+
commands;
|
|
12
|
+
cacheMs;
|
|
13
|
+
generation = 0;
|
|
14
|
+
cached = null;
|
|
15
|
+
fullPreflightAt = 0;
|
|
16
|
+
staticPreflight = null;
|
|
17
|
+
inspectionInFlight = null;
|
|
18
|
+
lastCapacitySample = null;
|
|
19
|
+
options;
|
|
20
|
+
docker;
|
|
21
|
+
ownership = new DockerContextOwnershipLock();
|
|
22
|
+
diskPressureLatched = false;
|
|
23
|
+
memoryAvailability = new RecoveryStabilizedMemory();
|
|
24
|
+
constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
|
|
25
|
+
this.commands = commands;
|
|
26
|
+
this.cacheMs = cacheMs;
|
|
27
|
+
this.options = typeof options === "string" ? { dockerExecutable: options } : options;
|
|
28
|
+
this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
|
|
29
|
+
}
|
|
30
|
+
unavailable(code, reason, features) {
|
|
31
|
+
const totalMemoryBytes = os.totalmem();
|
|
32
|
+
this.memoryAvailability.observe(0);
|
|
33
|
+
return unavailable(this.generation, code, reason, this.memoryReserveBytes(totalMemoryBytes), this.defaultAdmissionMemoryBytes(), totalMemoryBytes, features);
|
|
34
|
+
}
|
|
35
|
+
memoryReserveBytes(totalMemoryBytes) {
|
|
36
|
+
return resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, this.options.hostMemoryReserveBytes);
|
|
37
|
+
}
|
|
38
|
+
defaultAdmissionMemoryBytes() {
|
|
39
|
+
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
40
|
+
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
41
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
42
|
+
return workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
|
|
43
|
+
}
|
|
44
|
+
current() {
|
|
45
|
+
return this.cached;
|
|
46
|
+
}
|
|
47
|
+
async refresh() {
|
|
48
|
+
await fs.mkdir(this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"), { recursive: true, mode: 0o700 });
|
|
49
|
+
const result = await this.run(true);
|
|
50
|
+
if (canRunDockerMaintenance(result)) {
|
|
51
|
+
await this.ownership.acquire(this.options.dockerContext);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
async release() {
|
|
56
|
+
await this.ownership.release();
|
|
57
|
+
}
|
|
58
|
+
async run(force = false) {
|
|
59
|
+
const inFlight = this.inspectionInFlight;
|
|
60
|
+
if (inFlight) {
|
|
61
|
+
const result = await inFlight.promise;
|
|
62
|
+
if (inFlight.kind === "full" || (!force && Date.now() - this.fullPreflightAt < this.cacheMs)) {
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
return await this.run(force);
|
|
66
|
+
}
|
|
67
|
+
if (!force && this.cached && Date.now() - this.fullPreflightAt < this.cacheMs)
|
|
68
|
+
return this.cached;
|
|
69
|
+
return await this.startInspection("full", () => this.inspect().catch((error) => this.unavailable("docker_unreachable", error instanceof Error ? error.message : String(error))), true);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Refreshes only the admission inputs that change while the daemon's static
|
|
73
|
+
* capability snapshot remains valid. Heartbeats use this path so periodic
|
|
74
|
+
* liveness reporting does not wake Docker/WSL to rerun feature probes.
|
|
75
|
+
*/
|
|
76
|
+
async refreshCapacity() {
|
|
77
|
+
if (this.inspectionInFlight) {
|
|
78
|
+
return await this.inspectionInFlight.promise;
|
|
79
|
+
}
|
|
80
|
+
const staticPreflight = this.staticPreflight;
|
|
81
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
82
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
83
|
+
}
|
|
84
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* When the cached static preflight remains usable, produces a capacity sample
|
|
88
|
+
* that began after this admission request. An older inspection is awaited,
|
|
89
|
+
* not preempted or reused. Later admission callers may share the next sample
|
|
90
|
+
* only when it began after they entered this method. Without a usable static
|
|
91
|
+
* snapshot, the existing fail-closed result is returned without probing.
|
|
92
|
+
*/
|
|
93
|
+
async refreshCapacityForAdmission() {
|
|
94
|
+
const generationAtRequest = this.generation;
|
|
95
|
+
while (true) {
|
|
96
|
+
const inFlight = this.inspectionInFlight;
|
|
97
|
+
if (inFlight) {
|
|
98
|
+
if (inFlight.kind === "capacity" && inFlight.generation > generationAtRequest) {
|
|
99
|
+
return await inFlight.promise;
|
|
100
|
+
}
|
|
101
|
+
await inFlight.promise;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const lastCapacitySample = this.lastCapacitySample;
|
|
105
|
+
if (lastCapacitySample
|
|
106
|
+
&& lastCapacitySample.generation > generationAtRequest
|
|
107
|
+
&& lastCapacitySample.generation === this.generation
|
|
108
|
+
&& lastCapacitySample.result === this.cached) {
|
|
109
|
+
return lastCapacitySample.result;
|
|
110
|
+
}
|
|
111
|
+
const staticPreflight = this.staticPreflight;
|
|
112
|
+
if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
|
|
113
|
+
return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
|
|
114
|
+
}
|
|
115
|
+
return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
startInspection(kind, inspect, full) {
|
|
119
|
+
this.generation += 1;
|
|
120
|
+
const generation = this.generation;
|
|
121
|
+
const operation = Promise.resolve()
|
|
122
|
+
.then(inspect)
|
|
123
|
+
.then((result) => {
|
|
124
|
+
this.cached = result;
|
|
125
|
+
if (kind === "capacity")
|
|
126
|
+
this.lastCapacitySample = { generation, result };
|
|
127
|
+
if (full)
|
|
128
|
+
this.fullPreflightAt = Date.now();
|
|
129
|
+
return result;
|
|
130
|
+
})
|
|
131
|
+
.finally(() => {
|
|
132
|
+
if (this.inspectionInFlight?.promise === operation) {
|
|
133
|
+
this.inspectionInFlight = null;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
this.inspectionInFlight = { kind, generation, promise: operation };
|
|
137
|
+
return operation;
|
|
138
|
+
}
|
|
139
|
+
async inspect() {
|
|
140
|
+
if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
|
|
141
|
+
return this.unavailable("platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
|
|
142
|
+
}
|
|
143
|
+
const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
|
|
144
|
+
const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
|
|
145
|
+
if (infoResult.exitCode !== 0)
|
|
146
|
+
return this.unavailable("docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
|
|
147
|
+
const info = parseInfo(infoResult.stdout);
|
|
148
|
+
if (String(info.OSType ?? "").toLowerCase() !== "linux")
|
|
149
|
+
return this.unavailable("docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
|
|
150
|
+
const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
|
|
151
|
+
const rootless = securityOptions.some((value) => value.includes("rootless"));
|
|
152
|
+
const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
|
|
153
|
+
const cgroupV2 = String(info.CgroupVersion ?? "") === "2";
|
|
154
|
+
const seccomp = securityOptions.some((value) => value.includes("seccomp"));
|
|
155
|
+
const lsm = securityOptions.some((value) => value.includes("apparmor") || value.includes("selinux"));
|
|
156
|
+
const storageDriver = String(info.Driver ?? "").toLowerCase();
|
|
157
|
+
const storageQuota = storageDriver === "overlay2" && Boolean(info.DriverStatus?.some?.((entry) => JSON.stringify(entry).toLowerCase().includes("project quota")));
|
|
158
|
+
const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
|
|
159
|
+
const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
|
|
160
|
+
if (!rootless && !desktopDeveloperMode && requireRootless) {
|
|
161
|
+
return this.unavailable("docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
|
|
162
|
+
rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const composeResult = await this.commands.run(this.docker, [...dockerArgs, "compose", "version", "--short"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
|
|
166
|
+
const compose = composeResult?.exitCode === 0;
|
|
167
|
+
const buildxResult = await this.commands.run(this.docker, [...dockerArgs, "buildx", "version"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
|
|
168
|
+
const buildxHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "build", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
|
|
169
|
+
const buildxPruneHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "prune", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
|
|
170
|
+
const containerUpdateHelp = await this.commands.run(this.docker, [...dockerArgs, "container", "update", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
|
|
171
|
+
const buildkit = buildxResult?.exitCode === 0
|
|
172
|
+
&& buildxHelp?.exitCode === 0
|
|
173
|
+
&& ["--ulimit", "--shm-size"].every(flag => buildxHelp.stdout.includes(flag))
|
|
174
|
+
&& buildxPruneHelp?.exitCode === 0
|
|
175
|
+
&& ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
|
|
176
|
+
&& containerUpdateHelp?.exitCode === 0
|
|
177
|
+
&& ["--pids-limit", "--memory", "--memory-swap"].every(flag => containerUpdateHelp.stdout.includes(flag));
|
|
178
|
+
const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
|
|
179
|
+
&& Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
|
|
180
|
+
&& Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
|
|
181
|
+
&& Boolean(process.env.FORGE_SANDBOX_PROVENANCE_SIGNING_KEY?.trim())
|
|
182
|
+
&& Boolean(process.env.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim())
|
|
183
|
+
&& Boolean(process.env.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim())
|
|
184
|
+
&& process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED === "true";
|
|
185
|
+
const securityProfile = Boolean(process.env.FORGE_SANDBOX_SECCOMP_PROFILE?.trim())
|
|
186
|
+
&& Boolean(process.env.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim())
|
|
187
|
+
&& seccomp && lsm;
|
|
188
|
+
const egressProxy = process.env.FORGE_SANDBOX_EGRESS_ENFORCED === "true"
|
|
189
|
+
&& /@sha256:[0-9a-f]{64}$/iu.test(process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim() ?? "");
|
|
190
|
+
const storagePolicy = storagePolicyAvailable(storageQuota);
|
|
191
|
+
const configuredStateRoot = this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
|
|
192
|
+
const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
|
|
193
|
+
? info.DockerRootDir
|
|
194
|
+
: configuredStateRoot;
|
|
195
|
+
const dockerDesktop = process.platform !== "linux"
|
|
196
|
+
&& String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
|
|
197
|
+
const reportedDockerMemoryBytes = Number(info.MemTotal);
|
|
198
|
+
const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
|
|
199
|
+
const staticPreflight = {
|
|
200
|
+
dockerArgs,
|
|
201
|
+
dockerRoot,
|
|
202
|
+
dockerDesktop,
|
|
203
|
+
dockerMemoryBytes: Number.isFinite(reportedDockerMemoryBytes) && reportedDockerMemoryBytes > 0
|
|
204
|
+
? reportedDockerMemoryBytes
|
|
205
|
+
: null,
|
|
206
|
+
runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
|
|
207
|
+
features,
|
|
208
|
+
};
|
|
209
|
+
this.staticPreflight = staticPreflight;
|
|
210
|
+
return await this.inspectCapacity(staticPreflight);
|
|
211
|
+
}
|
|
212
|
+
async inspectCapacity(staticPreflight) {
|
|
213
|
+
const disk = await diskSpace(staticPreflight.dockerRoot);
|
|
214
|
+
const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
|
|
215
|
+
const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
|
|
216
|
+
const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
|
|
217
|
+
const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
|
|
218
|
+
this.diskPressureLatched = diskLow;
|
|
219
|
+
const requiredAvailableBytes = requiredAvailableBytesToClearDiskPressure(disk.total, requiredWorkspaceBytes, lowWatermark);
|
|
220
|
+
const rawHostAvailableMemoryBytesBefore = Math.max(0, os.freemem());
|
|
221
|
+
const hostTotalMemoryBytes = os.totalmem();
|
|
222
|
+
let totalMemoryBytes = hostTotalMemoryBytes;
|
|
223
|
+
// Native Docker shares the host capacity domain. Docker Desktop has a
|
|
224
|
+
// second VM/daemon domain, so neither domain may be treated as a substitute
|
|
225
|
+
// for the other when deciding whether new committed memory is safe.
|
|
226
|
+
let rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytesBefore;
|
|
227
|
+
let daemonMemorySampleFailure = null;
|
|
228
|
+
if (staticPreflight.dockerDesktop) {
|
|
229
|
+
rawDaemonAvailableMemoryBytes = null;
|
|
230
|
+
if (staticPreflight.dockerMemoryBytes) {
|
|
231
|
+
totalMemoryBytes = staticPreflight.dockerMemoryBytes;
|
|
232
|
+
const stats = await this.commands.run(this.docker, [...staticPreflight.dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
|
|
233
|
+
if (stats?.exitCode === 0) {
|
|
234
|
+
let dockerUsedMemoryBytes = 0;
|
|
235
|
+
for (const line of stats.stdout.split(/\r?\n/gu).map(value => value.trim()).filter(Boolean)) {
|
|
236
|
+
const usage = tryParseDockerMemoryUsage(line);
|
|
237
|
+
if (usage === null) {
|
|
238
|
+
daemonMemorySampleFailure = "Docker returned an invalid memory-usage sample.";
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
dockerUsedMemoryBytes += usage;
|
|
242
|
+
}
|
|
243
|
+
if (daemonMemorySampleFailure === null) {
|
|
244
|
+
rawDaemonAvailableMemoryBytes = Math.max(0, staticPreflight.dockerMemoryBytes - dockerUsedMemoryBytes);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
daemonMemorySampleFailure = singleLine(stats?.stderr || stats?.stdout || "Docker Desktop memory capacity could not be sampled.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
daemonMemorySampleFailure = "Docker Desktop did not report a valid memory-capacity limit.";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const rawHostAvailableMemoryBytesAfter = Math.max(0, os.freemem());
|
|
256
|
+
const rawHostAvailableMemoryBytes = Math.min(rawHostAvailableMemoryBytesBefore, rawHostAvailableMemoryBytesAfter);
|
|
257
|
+
if (!staticPreflight.dockerDesktop) {
|
|
258
|
+
rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytes;
|
|
259
|
+
}
|
|
260
|
+
const sampledAt = new Date().toISOString();
|
|
261
|
+
const rawAvailableMemoryBytes = rawDaemonAvailableMemoryBytes === null
|
|
262
|
+
? 0
|
|
263
|
+
: Math.min(rawHostAvailableMemoryBytes, rawDaemonAvailableMemoryBytes);
|
|
264
|
+
const availableMemoryBytes = this.memoryAvailability.observe(rawAvailableMemoryBytes);
|
|
265
|
+
const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
|
|
266
|
+
const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
|
|
267
|
+
// The reserve protects the physical host and its control plane. Docker
|
|
268
|
+
// Desktop's VM total remains the independent daemon commitment ceiling,
|
|
269
|
+
// but must not shrink the reserve derived from the Windows host.
|
|
270
|
+
const memoryReserveBytes = this.memoryReserveBytes(hostTotalMemoryBytes);
|
|
271
|
+
const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
|
|
272
|
+
const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
|
|
273
|
+
const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
|
|
274
|
+
const sandboxMemoryBytes = workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
|
|
275
|
+
const minimumAdmissionMemoryBytes = 128 * 1024 ** 2 + Math.max(128 * 1024 ** 2, runtimeOverheadBytes);
|
|
276
|
+
const sandboxCpuMillis = 2_000;
|
|
277
|
+
const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
|
|
278
|
+
const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
|
|
279
|
+
const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
|
|
280
|
+
const capacityLowCode = !diskLow && rawDaemonAvailableMemoryBytes === null
|
|
281
|
+
? "docker_memory_sample_unavailable"
|
|
282
|
+
: !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
|
|
283
|
+
? "docker_memory_low"
|
|
284
|
+
: !diskLow && cpuSlots <= 0
|
|
285
|
+
? "docker_cpu_low"
|
|
286
|
+
: null;
|
|
287
|
+
const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
|
|
288
|
+
const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
|
|
289
|
+
const unavailableReason = diskLow
|
|
290
|
+
? `Docker workspace has ${formatBytes(disk.available)} available · ${formatBytes(requiredAvailableBytes)} required to clear disk pressure.`
|
|
291
|
+
: unavailableCode === "docker_memory_sample_unavailable"
|
|
292
|
+
? `Docker Desktop memory capacity is unavailable (${daemonMemorySampleFailure ?? "unknown sampler failure"}); the host reports ${formatBytes(rawHostAvailableMemoryBytes)} free, but Forge will not admit a sandbox until both memory domains are known.`
|
|
293
|
+
: unavailableCode === "docker_memory_low"
|
|
294
|
+
? `${staticPreflight.dockerDesktop
|
|
295
|
+
? `The host reports ${formatBytes(rawHostAvailableMemoryBytes)} free and the Docker domain reports ${formatBytes(rawDaemonAvailableMemoryBytes ?? 0)} free; Forge uses the conservative ${formatBytes(rawAvailableMemoryBytes)} bound`
|
|
296
|
+
: `Docker reports ${formatBytes(rawAvailableMemoryBytes)} free`} and reserves ${formatBytes(memoryReserveBytes)} for the host, leaving ${formatBytes(availableForAdmissionBytes)} for admission; the smallest supported sandbox requires ${formatBytes(minimumAdmissionMemoryBytes)}. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
|
|
297
|
+
: unavailableCode === "docker_cpu_low"
|
|
298
|
+
? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
|
|
299
|
+
: null;
|
|
300
|
+
const capacityAvailable = unavailableCode === null;
|
|
301
|
+
return {
|
|
302
|
+
available: capacityAvailable,
|
|
303
|
+
healthy: capacityAvailable,
|
|
304
|
+
generation: this.generation,
|
|
305
|
+
sampledAt,
|
|
306
|
+
runtimeVersion: staticPreflight.runtimeVersion,
|
|
307
|
+
maxConcurrentSandboxes,
|
|
308
|
+
totalMemoryBytes,
|
|
309
|
+
rawHostAvailableMemoryBytes,
|
|
310
|
+
rawDaemonAvailableMemoryBytes,
|
|
311
|
+
rawAvailableMemoryBytes,
|
|
312
|
+
availableMemoryBytes,
|
|
313
|
+
hostReserveBytes: memoryReserveBytes,
|
|
314
|
+
admissionMemoryBytes: sandboxMemoryBytes,
|
|
315
|
+
availableCpuMillis,
|
|
316
|
+
availableWorkspaceBytes: disk.available,
|
|
317
|
+
diskLow,
|
|
318
|
+
unavailableCode,
|
|
319
|
+
unavailableReason,
|
|
320
|
+
features: staticPreflight.features,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Uses the same reviewed reserve vector as host-runtime admission. The input
|
|
326
|
+
* is always the physical host total; Docker Desktop's VM total is tracked as
|
|
327
|
+
* a separate daemon commitment domain.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, explicitBytes, environment = process.env) {
|
|
330
|
+
return resolveAdaptiveMemoryReserveBytes(totalMemoryBytes, {
|
|
331
|
+
explicitBytes,
|
|
332
|
+
floorBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_FLOOR_BYTES, 1024 ** 3),
|
|
333
|
+
ceilingBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_CEILING_BYTES, 4 * 1024 ** 3),
|
|
334
|
+
percent: positiveNumber(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_PERCENT, 12.5),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const dockerMaintenanceUnavailableCodes = new Set([
|
|
338
|
+
"docker_disk_low",
|
|
339
|
+
"docker_memory_low",
|
|
340
|
+
"docker_memory_sample_unavailable",
|
|
341
|
+
"docker_cpu_low",
|
|
342
|
+
]);
|
|
343
|
+
export function canRunDockerMaintenance(preflight) {
|
|
344
|
+
return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
|
|
345
|
+
}
|
|
346
|
+
export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailableBytes, highWatermarkPercent, lowWatermarkPercent, pressureLatched) {
|
|
347
|
+
if (availableBytes < minimumAvailableBytes)
|
|
348
|
+
return true;
|
|
349
|
+
if (!(totalBytes > 0))
|
|
350
|
+
return true;
|
|
351
|
+
const usedPercent = (1 - availableBytes / totalBytes) * 100;
|
|
352
|
+
if (usedPercent >= highWatermarkPercent)
|
|
353
|
+
return true;
|
|
354
|
+
return pressureLatched && usedPercent > lowWatermarkPercent;
|
|
355
|
+
}
|
|
356
|
+
export function requiredAvailableBytesToClearDiskPressure(totalBytes, minimumAvailableBytes, lowWatermarkPercent) {
|
|
357
|
+
const watermarkBytes = totalBytes > 0
|
|
358
|
+
? Math.ceil(totalBytes * (1 - Math.min(100, Math.max(0, lowWatermarkPercent)) / 100))
|
|
359
|
+
: 0;
|
|
360
|
+
return Math.max(0, minimumAvailableBytes, watermarkBytes);
|
|
361
|
+
}
|
|
362
|
+
export function storagePolicyAvailable(storageQuota, environment = process.env) {
|
|
363
|
+
return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
|
|
364
|
+
}
|
|
365
|
+
export class DockerContextOwnershipLock {
|
|
366
|
+
lockPath = null;
|
|
367
|
+
lockToken = null;
|
|
368
|
+
async acquire(contextName) {
|
|
369
|
+
if (this.lockPath)
|
|
370
|
+
return;
|
|
371
|
+
contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
|
|
372
|
+
const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
|
|
373
|
+
const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
|
|
374
|
+
const token = randomUUID();
|
|
375
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
376
|
+
try {
|
|
377
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
378
|
+
await handle.writeFile(JSON.stringify({
|
|
379
|
+
schemaVersion: 2,
|
|
380
|
+
token,
|
|
381
|
+
pid: process.pid,
|
|
382
|
+
processIdentity: captureHostProcessIdentity(process.pid),
|
|
383
|
+
contextName,
|
|
384
|
+
acquiredAt: new Date().toISOString(),
|
|
385
|
+
}));
|
|
386
|
+
await handle.sync();
|
|
387
|
+
await handle.close();
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
const owner = await readLock(lockPath);
|
|
391
|
+
if (!dockerContextOwnerIsActive(owner)) {
|
|
392
|
+
await fs.rm(lockPath, { force: true });
|
|
393
|
+
return await this.acquire(contextName);
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
|
|
396
|
+
}
|
|
397
|
+
this.lockPath = lockPath;
|
|
398
|
+
this.lockToken = token;
|
|
399
|
+
}
|
|
400
|
+
async release() {
|
|
401
|
+
if (!this.lockPath)
|
|
402
|
+
return;
|
|
403
|
+
const owner = await readLock(this.lockPath);
|
|
404
|
+
if (owner?.token === this.lockToken) {
|
|
405
|
+
await fs.rm(this.lockPath, { force: true });
|
|
406
|
+
}
|
|
407
|
+
this.lockPath = null;
|
|
408
|
+
this.lockToken = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function parseInfo(stdout) {
|
|
412
|
+
try {
|
|
413
|
+
const value = JSON.parse(stdout.trim());
|
|
414
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
415
|
+
throw new Error("not an object");
|
|
416
|
+
return value;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
throw new Error("Docker returned an invalid info document.", { cause: error });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function unavailable(generation, code, reason, hostReserveBytes, admissionMemoryBytes, totalMemoryBytes, features = {
|
|
423
|
+
rootless: false, userNamespaces: false, cgroupV2: false, seccomp: false, lsm: false, storageQuota: false, storagePolicy: false, compose: false, internalNetwork: false, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
|
|
424
|
+
}) {
|
|
425
|
+
const rawHostAvailableMemoryBytes = Math.max(0, os.freemem());
|
|
426
|
+
return {
|
|
427
|
+
available: false,
|
|
428
|
+
healthy: false,
|
|
429
|
+
generation,
|
|
430
|
+
sampledAt: new Date().toISOString(),
|
|
431
|
+
runtimeVersion: null,
|
|
432
|
+
maxConcurrentSandboxes: 0,
|
|
433
|
+
totalMemoryBytes,
|
|
434
|
+
rawHostAvailableMemoryBytes,
|
|
435
|
+
rawDaemonAvailableMemoryBytes: null,
|
|
436
|
+
rawAvailableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
437
|
+
availableMemoryBytes: rawHostAvailableMemoryBytes,
|
|
438
|
+
hostReserveBytes,
|
|
439
|
+
admissionMemoryBytes,
|
|
440
|
+
availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
|
|
441
|
+
availableWorkspaceBytes: 0,
|
|
442
|
+
diskLow: code === "docker_disk_low",
|
|
443
|
+
unavailableCode: code,
|
|
444
|
+
unavailableReason: singleLine(reason),
|
|
445
|
+
features,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async function diskSpace(inputPath) {
|
|
449
|
+
const stats = await fs.statfs(inputPath, { bigint: true }).catch(() => null);
|
|
450
|
+
if (!stats)
|
|
451
|
+
return { available: 0, total: 0 };
|
|
452
|
+
return {
|
|
453
|
+
available: safeNumber(stats.bavail * stats.bsize),
|
|
454
|
+
total: safeNumber(stats.blocks * stats.bsize),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function safeNumber(value) {
|
|
458
|
+
return Number(value > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : value);
|
|
459
|
+
}
|
|
460
|
+
function strings(value) {
|
|
461
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
462
|
+
}
|
|
463
|
+
function positiveInteger(value, fallback) {
|
|
464
|
+
const parsed = Number(value);
|
|
465
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
466
|
+
}
|
|
467
|
+
function singleLine(value) {
|
|
468
|
+
return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
|
|
469
|
+
}
|
|
470
|
+
function nonNegativeInteger(value, fallback) {
|
|
471
|
+
const parsed = Number(value);
|
|
472
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
473
|
+
}
|
|
474
|
+
function positiveNumber(value, fallback) {
|
|
475
|
+
const parsed = Number(value);
|
|
476
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
477
|
+
}
|
|
478
|
+
function tryParseDockerMemoryUsage(value) {
|
|
479
|
+
const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
|
|
480
|
+
const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
|
|
481
|
+
if (!match)
|
|
482
|
+
return null;
|
|
483
|
+
const amount = Number(match[1]);
|
|
484
|
+
const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
|
|
485
|
+
return Number.isFinite(amount) ? Math.round(amount * multiplier) : null;
|
|
486
|
+
}
|
|
487
|
+
export function parseDockerMemoryUsage(value) {
|
|
488
|
+
return tryParseDockerMemoryUsage(value) ?? 0;
|
|
489
|
+
}
|
|
490
|
+
async function readLock(lockPath) {
|
|
491
|
+
return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
|
|
492
|
+
}
|
|
493
|
+
export function dockerContextOwnerIsActive(owner) {
|
|
494
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
const liveIdentity = captureHostProcessIdentity(owner.pid);
|
|
498
|
+
if (owner.processIdentity) {
|
|
499
|
+
// A live but unverifiable process must remain protected. Windows and Linux
|
|
500
|
+
// normally provide a birth marker, so a mismatch is positive PID-reuse proof.
|
|
501
|
+
return !liveIdentity || liveIdentity === owner.processIdentity;
|
|
502
|
+
}
|
|
503
|
+
if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
|
|
504
|
+
const ticks = Number(liveIdentity.slice("win32:".length));
|
|
505
|
+
const acquiredAt = Date.parse(owner.acquiredAt);
|
|
506
|
+
const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
|
|
507
|
+
if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
function processExists(pid) {
|
|
514
|
+
try {
|
|
515
|
+
process.kill(pid, 0);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|