@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 0.1.3-dev.bca7d0ccee53

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.
Files changed (150) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +357 -206
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +47 -3
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  24. package/dist/runtime/attempt-process-supervisor.js +350 -0
  25. package/dist/runtime/generated-output-reset.d.ts +10 -0
  26. package/dist/runtime/generated-output-reset.js +103 -0
  27. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  28. package/dist/runtime/host-listener-inspector.js +324 -0
  29. package/dist/runtime/host-port-broker.d.ts +76 -0
  30. package/dist/runtime/host-port-broker.js +333 -0
  31. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  32. package/dist/runtime/host-runtime-contract.js +700 -0
  33. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  34. package/dist/runtime/host-runtime-lease.js +1462 -0
  35. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  36. package/dist/runtime/host-runtime-promotion.js +159 -0
  37. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  38. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  40. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  41. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  42. package/dist/runtime/runtime-isolation-lease.js +158 -0
  43. package/dist/sandbox/command-runner.d.ts +4 -0
  44. package/dist/sandbox/command-runner.js +47 -0
  45. package/dist/sandbox/contract.d.ts +5 -0
  46. package/dist/sandbox/contract.js +276 -0
  47. package/dist/sandbox/event-budget.d.ts +11 -0
  48. package/dist/sandbox/event-budget.js +27 -0
  49. package/dist/sandbox/image-policy.d.ts +35 -0
  50. package/dist/sandbox/image-policy.js +247 -0
  51. package/dist/sandbox/index.d.ts +5 -0
  52. package/dist/sandbox/index.js +5 -0
  53. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  54. package/dist/sandbox/lease-watchdog.js +67 -0
  55. package/dist/sandbox/preflight.d.ts +54 -0
  56. package/dist/sandbox/preflight.js +335 -0
  57. package/dist/sandbox/process.d.ts +20 -0
  58. package/dist/sandbox/process.js +209 -0
  59. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  60. package/dist/sandbox/provider-auth/codex.js +212 -0
  61. package/dist/sandbox/repository-lock.d.ts +5 -0
  62. package/dist/sandbox/repository-lock.js +119 -0
  63. package/dist/sandbox/runtime.d.ts +297 -0
  64. package/dist/sandbox/runtime.js +2338 -0
  65. package/dist/sandbox/sandbox.types.d.ts +86 -0
  66. package/dist/sandbox/sandbox.types.js +1 -0
  67. package/dist/sandbox/smoke.d.ts +76 -0
  68. package/dist/sandbox/smoke.js +380 -0
  69. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  70. package/dist/sandbox/worker-supervisor.js +36 -0
  71. package/dist/sandbox/worker.d.ts +1 -0
  72. package/dist/sandbox/worker.js +225 -0
  73. package/dist/sandbox/workspace.service.d.ts +13 -0
  74. package/dist/sandbox/workspace.service.js +72 -0
  75. package/dist/session/managed-attempt-tools.d.ts +17 -0
  76. package/dist/session/managed-attempt-tools.js +712 -0
  77. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  78. package/dist/session/phase-completion.artifacts.js +627 -0
  79. package/dist/session/phase-completion.d.ts +43 -46
  80. package/dist/session/phase-completion.helpers.d.ts +117 -0
  81. package/dist/session/phase-completion.helpers.js +701 -0
  82. package/dist/session/phase-completion.js +40 -3488
  83. package/dist/session/phase-completion.prompts.d.ts +14 -0
  84. package/dist/session/phase-completion.prompts.js +235 -0
  85. package/dist/session/phase-completion.runtime.d.ts +48 -0
  86. package/dist/session/phase-completion.runtime.js +786 -0
  87. package/dist/session/phase-completion.schemas.d.ts +3 -0
  88. package/dist/session/phase-completion.schemas.js +812 -0
  89. package/dist/session/phase-completion.validation.d.ts +37 -0
  90. package/dist/session/phase-completion.validation.js +850 -0
  91. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  92. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  93. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  94. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  95. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  96. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  97. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  98. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  99. package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
  100. package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
  101. package/dist/session/provider-cli/provider-cli.process.js +474 -0
  102. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  103. package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
  104. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  105. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  106. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  107. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  108. package/dist/session/provider-cli/provider-cli.service.d.ts +26 -53
  109. package/dist/session/provider-cli/provider-cli.service.js +524 -2571
  110. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  111. package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
  112. package/dist/session/provider-cli/provider-output-state.js +473 -0
  113. package/dist/session/session-redaction.d.ts +3 -0
  114. package/dist/session/session-redaction.js +23 -0
  115. package/dist/session/session.base.d.ts +6 -1
  116. package/dist/session/session.service.d.ts +8 -2
  117. package/dist/session/session.service.js +480 -58
  118. package/dist/session/session.types.d.ts +161 -1
  119. package/dist/session/session.types.js +318 -3
  120. package/dist/shared/path/path.js +3 -1
  121. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  122. package/dist/shared/process/host-process-recovery.js +78 -0
  123. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  124. package/dist/shared/process/host-process-supervisor.js +282 -0
  125. package/dist/shared/process/process.d.ts +1 -0
  126. package/dist/shared/process/process.js +1 -0
  127. package/dist/shared/process/windows-launch.d.ts +8 -0
  128. package/dist/shared/process/windows-launch.js +41 -0
  129. package/dist/shared/provider-capabilities.d.ts +1 -0
  130. package/dist/shared/provider-capabilities.js +3 -0
  131. package/dist/shared/redaction/redaction.js +10 -3
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/worktree/artifact-injector.d.ts +3 -2
  135. package/dist/worktree/artifact-injector.js +82 -10
  136. package/dist/worktree/dependency-warmer.d.ts +21 -1
  137. package/dist/worktree/dependency-warmer.js +138 -5
  138. package/dist/worktree/index.d.ts +1 -0
  139. package/dist/worktree/index.js +1 -0
  140. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  141. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  142. package/dist/worktree/worktree-paths.d.ts +4 -0
  143. package/dist/worktree/worktree-paths.js +30 -0
  144. package/dist/worktree/worktree.base.d.ts +3 -0
  145. package/dist/worktree/worktree.service.d.ts +9 -3
  146. package/dist/worktree/worktree.service.js +100 -52
  147. package/dist/worktree/worktree.types.d.ts +3 -1
  148. package/package.json +6 -3
  149. package/runtime/windows-job-supervisor.ps1 +356 -0
  150. 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,54 @@
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 cacheAt;
23
+ private readonly options;
24
+ private readonly docker;
25
+ private readonly ownership;
26
+ private diskPressureLatched;
27
+ constructor(options?: DockerPreflightOptions | string, commands?: CommandRunner, cacheMs?: number);
28
+ current(): SandboxPreflightResult | null;
29
+ refresh(): Promise<SandboxPreflightResult>;
30
+ release(): Promise<void>;
31
+ run(force?: boolean): Promise<SandboxPreflightResult>;
32
+ private inspect;
33
+ }
34
+ export declare function canRunDockerMaintenance(preflight: {
35
+ readonly healthy: boolean;
36
+ readonly unavailableCode?: string | null;
37
+ }): boolean;
38
+ export declare function evaluateDiskPressure(availableBytes: number, totalBytes: number, minimumAvailableBytes: number, highWatermarkPercent: number, lowWatermarkPercent: number, pressureLatched: boolean): boolean;
39
+ export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
40
+ export declare class DockerContextOwnershipLock {
41
+ private lockPath;
42
+ private lockToken;
43
+ acquire(contextName?: string): Promise<void>;
44
+ release(): Promise<void>;
45
+ }
46
+ export declare function parseDockerMemoryUsage(value: string): number;
47
+ interface DockerContextLockOwner {
48
+ readonly token?: string;
49
+ readonly pid?: number;
50
+ readonly processIdentity?: string | null;
51
+ readonly acquiredAt?: string;
52
+ }
53
+ export declare function dockerContextOwnerIsActive(owner: DockerContextLockOwner | null): boolean;
54
+ export {};
@@ -0,0 +1,335 @@
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 { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
6
+ import { BoundedCommandRunner } from "./command-runner.js";
7
+ const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
8
+ const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
9
+ export class DockerPreflight {
10
+ commands;
11
+ cacheMs;
12
+ generation = 0;
13
+ cached = null;
14
+ cacheAt = 0;
15
+ options;
16
+ docker;
17
+ ownership = new DockerContextOwnershipLock();
18
+ diskPressureLatched = false;
19
+ constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
20
+ this.commands = commands;
21
+ this.cacheMs = cacheMs;
22
+ this.options = typeof options === "string" ? { dockerExecutable: options } : options;
23
+ this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
24
+ }
25
+ current() {
26
+ return this.cached;
27
+ }
28
+ async refresh() {
29
+ await fs.mkdir(this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes"), { recursive: true, mode: 0o700 });
30
+ const result = await this.run(true);
31
+ if (canRunDockerMaintenance(result)) {
32
+ await this.ownership.acquire(this.options.dockerContext);
33
+ }
34
+ return result;
35
+ }
36
+ async release() {
37
+ await this.ownership.release();
38
+ }
39
+ async run(force = false) {
40
+ if (!force && this.cached && Date.now() - this.cacheAt < this.cacheMs)
41
+ return this.cached;
42
+ this.generation += 1;
43
+ const result = await this.inspect().catch((error) => unavailable(this.generation, "docker_unreachable", error instanceof Error ? error.message : String(error)));
44
+ this.cached = result;
45
+ this.cacheAt = Date.now();
46
+ return result;
47
+ }
48
+ async inspect() {
49
+ if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
50
+ return unavailable(this.generation, "platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
51
+ }
52
+ const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
53
+ const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
54
+ if (infoResult.exitCode !== 0)
55
+ return unavailable(this.generation, "docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
56
+ const info = parseInfo(infoResult.stdout);
57
+ if (String(info.OSType ?? "").toLowerCase() !== "linux")
58
+ return unavailable(this.generation, "docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
59
+ const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
60
+ const rootless = securityOptions.some((value) => value.includes("rootless"));
61
+ const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
62
+ const cgroupV2 = String(info.CgroupVersion ?? "") === "2";
63
+ const seccomp = securityOptions.some((value) => value.includes("seccomp"));
64
+ const lsm = securityOptions.some((value) => value.includes("apparmor") || value.includes("selinux"));
65
+ const storageDriver = String(info.Driver ?? "").toLowerCase();
66
+ const storageQuota = storageDriver === "overlay2" && Boolean(info.DriverStatus?.some?.((entry) => JSON.stringify(entry).toLowerCase().includes("project quota")));
67
+ const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
68
+ const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
69
+ if (!rootless && !desktopDeveloperMode && requireRootless) {
70
+ return unavailable(this.generation, "docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
71
+ rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
72
+ });
73
+ }
74
+ const composeResult = await this.commands.run(this.docker, [...dockerArgs, "compose", "version", "--short"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
75
+ const compose = composeResult?.exitCode === 0;
76
+ const buildxResult = await this.commands.run(this.docker, [...dockerArgs, "buildx", "version"], { timeoutMs: 5_000, maxOutputBytes: 16 * 1024 }).catch(() => null);
77
+ const buildxHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "build", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
78
+ const buildxPruneHelp = await this.commands.run(this.docker, [...dockerArgs, "buildx", "prune", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
79
+ const containerUpdateHelp = await this.commands.run(this.docker, [...dockerArgs, "container", "update", "--help"], { timeoutMs: 5_000, maxOutputBytes: 256 * 1024 }).catch(() => null);
80
+ const buildkit = buildxResult?.exitCode === 0
81
+ && buildxHelp?.exitCode === 0
82
+ && ["--ulimit", "--shm-size"].every(flag => buildxHelp.stdout.includes(flag))
83
+ && buildxPruneHelp?.exitCode === 0
84
+ && ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
85
+ && containerUpdateHelp?.exitCode === 0
86
+ && ["--pids-limit", "--memory", "--memory-swap"].every(flag => containerUpdateHelp.stdout.includes(flag));
87
+ const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
88
+ && Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
89
+ && Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
90
+ && Boolean(process.env.FORGE_SANDBOX_PROVENANCE_SIGNING_KEY?.trim())
91
+ && Boolean(process.env.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim())
92
+ && Boolean(process.env.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim())
93
+ && process.env.FORGE_SANDBOX_BUILD_EGRESS_ENFORCED === "true";
94
+ const securityProfile = Boolean(process.env.FORGE_SANDBOX_SECCOMP_PROFILE?.trim())
95
+ && Boolean(process.env.FORGE_SANDBOX_LSM_SECURITY_OPT?.trim())
96
+ && seccomp && lsm;
97
+ const egressProxy = process.env.FORGE_SANDBOX_EGRESS_ENFORCED === "true"
98
+ && /@sha256:[0-9a-f]{64}$/iu.test(process.env.FORGE_SANDBOX_EGRESS_PROXY_IMAGE?.trim() ?? "");
99
+ const storagePolicy = storagePolicyAvailable(storageQuota);
100
+ const configuredStateRoot = this.options.stateRoot ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
101
+ const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
102
+ ? info.DockerRootDir
103
+ : configuredStateRoot;
104
+ const disk = await diskSpace(dockerRoot);
105
+ const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
106
+ const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
107
+ const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
108
+ const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
109
+ this.diskPressureLatched = diskLow;
110
+ const hostAvailableMemoryBytes = os.freemem();
111
+ let totalMemoryBytes = os.totalmem();
112
+ let availableMemoryBytes = hostAvailableMemoryBytes;
113
+ const dockerDesktop = process.platform !== "linux"
114
+ && String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
115
+ const dockerMemoryBytes = Number(info.MemTotal);
116
+ if (dockerDesktop && Number.isFinite(dockerMemoryBytes) && dockerMemoryBytes > 0) {
117
+ totalMemoryBytes = dockerMemoryBytes;
118
+ const stats = await this.commands.run(this.docker, [...dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
119
+ if (stats?.exitCode === 0) {
120
+ const dockerUsedMemoryBytes = stats.stdout
121
+ .split(/\r?\n/gu)
122
+ .map(line => parseDockerMemoryUsage(line))
123
+ .reduce((total, value) => total + value, 0);
124
+ availableMemoryBytes = Math.max(0, dockerMemoryBytes - dockerUsedMemoryBytes);
125
+ }
126
+ }
127
+ const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
128
+ const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
129
+ const memoryReserveBytes = positiveInteger(this.options.hostMemoryReserveBytes, defaultHostMemoryReserveBytes);
130
+ const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
131
+ const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
132
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
133
+ const sandboxMemoryBytes = workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
134
+ const minimumAdmissionMemoryBytes = 128 * 1024 ** 2 + Math.max(128 * 1024 ** 2, runtimeOverheadBytes);
135
+ const sandboxCpuMillis = 2_000;
136
+ const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
137
+ const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
138
+ const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
139
+ const capacityLowCode = !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
140
+ ? "docker_memory_low"
141
+ : !diskLow && cpuSlots <= 0
142
+ ? "docker_cpu_low"
143
+ : null;
144
+ const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
145
+ const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
146
+ const unavailableReason = diskLow
147
+ ? `Docker workspace has only ${disk.available} bytes available.`
148
+ : unavailableCode === "docker_memory_low"
149
+ ? `Docker reports ${availableMemoryBytes} bytes available and Forge reserves ${memoryReserveBytes} host bytes, leaving ${availableForAdmissionBytes} bytes for admission; the smallest supported sandbox requires ${minimumAdmissionMemoryBytes} bytes. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
150
+ : unavailableCode === "docker_cpu_low"
151
+ ? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
152
+ : null;
153
+ const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
154
+ const capacityAvailable = unavailableCode === null;
155
+ return {
156
+ available: capacityAvailable,
157
+ healthy: capacityAvailable,
158
+ generation: this.generation,
159
+ runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
160
+ maxConcurrentSandboxes,
161
+ totalMemoryBytes,
162
+ availableMemoryBytes,
163
+ hostReserveBytes: memoryReserveBytes,
164
+ admissionMemoryBytes: sandboxMemoryBytes,
165
+ availableCpuMillis,
166
+ availableWorkspaceBytes: disk.available,
167
+ diskLow,
168
+ unavailableCode,
169
+ unavailableReason,
170
+ features,
171
+ };
172
+ }
173
+ }
174
+ const dockerMaintenanceUnavailableCodes = new Set(["docker_disk_low", "docker_memory_low", "docker_cpu_low"]);
175
+ export function canRunDockerMaintenance(preflight) {
176
+ return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
177
+ }
178
+ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailableBytes, highWatermarkPercent, lowWatermarkPercent, pressureLatched) {
179
+ if (availableBytes < minimumAvailableBytes)
180
+ return true;
181
+ if (!(totalBytes > 0))
182
+ return true;
183
+ const usedPercent = (1 - availableBytes / totalBytes) * 100;
184
+ if (usedPercent >= highWatermarkPercent)
185
+ return true;
186
+ return pressureLatched && usedPercent > lowWatermarkPercent;
187
+ }
188
+ export function storagePolicyAvailable(storageQuota, environment = process.env) {
189
+ return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
190
+ }
191
+ export class DockerContextOwnershipLock {
192
+ lockPath = null;
193
+ lockToken = null;
194
+ async acquire(contextName) {
195
+ if (this.lockPath)
196
+ return;
197
+ contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
198
+ const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
199
+ const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
200
+ const token = randomUUID();
201
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
202
+ try {
203
+ const handle = await fs.open(lockPath, "wx", 0o600);
204
+ await handle.writeFile(JSON.stringify({
205
+ schemaVersion: 2,
206
+ token,
207
+ pid: process.pid,
208
+ processIdentity: captureHostProcessIdentity(process.pid),
209
+ contextName,
210
+ acquiredAt: new Date().toISOString(),
211
+ }));
212
+ await handle.sync();
213
+ await handle.close();
214
+ }
215
+ catch (error) {
216
+ const owner = await readLock(lockPath);
217
+ if (!dockerContextOwnerIsActive(owner)) {
218
+ await fs.rm(lockPath, { force: true });
219
+ return await this.acquire(contextName);
220
+ }
221
+ throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
222
+ }
223
+ this.lockPath = lockPath;
224
+ this.lockToken = token;
225
+ }
226
+ async release() {
227
+ if (!this.lockPath)
228
+ return;
229
+ const owner = await readLock(this.lockPath);
230
+ if (owner?.token === this.lockToken) {
231
+ await fs.rm(this.lockPath, { force: true });
232
+ }
233
+ this.lockPath = null;
234
+ this.lockToken = null;
235
+ }
236
+ }
237
+ function parseInfo(stdout) {
238
+ try {
239
+ const value = JSON.parse(stdout.trim());
240
+ if (!value || typeof value !== "object" || Array.isArray(value))
241
+ throw new Error("not an object");
242
+ return value;
243
+ }
244
+ catch (error) {
245
+ throw new Error("Docker returned an invalid info document.", { cause: error });
246
+ }
247
+ }
248
+ function unavailable(generation, code, reason, features = {
249
+ 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
250
+ }) {
251
+ return {
252
+ available: false,
253
+ healthy: false,
254
+ generation,
255
+ runtimeVersion: null,
256
+ maxConcurrentSandboxes: 0,
257
+ totalMemoryBytes: os.totalmem(),
258
+ availableMemoryBytes: os.freemem(),
259
+ hostReserveBytes: defaultHostMemoryReserveBytes,
260
+ admissionMemoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
261
+ availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
262
+ availableWorkspaceBytes: 0,
263
+ diskLow: code === "docker_disk_low",
264
+ unavailableCode: code,
265
+ unavailableReason: singleLine(reason),
266
+ features,
267
+ };
268
+ }
269
+ async function diskSpace(inputPath) {
270
+ const stats = await fs.statfs(inputPath, { bigint: true }).catch(() => null);
271
+ if (!stats)
272
+ return { available: 0, total: 0 };
273
+ return {
274
+ available: safeNumber(stats.bavail * stats.bsize),
275
+ total: safeNumber(stats.blocks * stats.bsize),
276
+ };
277
+ }
278
+ function safeNumber(value) {
279
+ return Number(value > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : value);
280
+ }
281
+ function strings(value) {
282
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
283
+ }
284
+ function positiveInteger(value, fallback) {
285
+ const parsed = Number(value);
286
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
287
+ }
288
+ function singleLine(value) {
289
+ return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
290
+ }
291
+ function nonNegativeInteger(value, fallback) {
292
+ const parsed = Number(value);
293
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
294
+ }
295
+ export function parseDockerMemoryUsage(value) {
296
+ const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
297
+ const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
298
+ if (!match)
299
+ return 0;
300
+ const amount = Number(match[1]);
301
+ const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
302
+ return Number.isFinite(amount) ? Math.round(amount * multiplier) : 0;
303
+ }
304
+ async function readLock(lockPath) {
305
+ return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
306
+ }
307
+ export function dockerContextOwnerIsActive(owner) {
308
+ if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
309
+ return false;
310
+ }
311
+ const liveIdentity = captureHostProcessIdentity(owner.pid);
312
+ if (owner.processIdentity) {
313
+ // A live but unverifiable process must remain protected. Windows and Linux
314
+ // normally provide a birth marker, so a mismatch is positive PID-reuse proof.
315
+ return !liveIdentity || liveIdentity === owner.processIdentity;
316
+ }
317
+ if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
318
+ const ticks = Number(liveIdentity.slice("win32:".length));
319
+ const acquiredAt = Date.parse(owner.acquiredAt);
320
+ const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
321
+ if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
322
+ return false;
323
+ }
324
+ }
325
+ return true;
326
+ }
327
+ function processExists(pid) {
328
+ try {
329
+ process.kill(pid, 0);
330
+ return true;
331
+ }
332
+ catch {
333
+ return false;
334
+ }
335
+ }
@@ -0,0 +1,20 @@
1
+ export interface SandboxProcessRequest {
2
+ readonly command: string;
3
+ readonly args: readonly string[];
4
+ readonly cwd?: string;
5
+ readonly input?: string | Buffer;
6
+ readonly timeoutMs?: number;
7
+ readonly maxOutputBytes?: number;
8
+ readonly signal?: AbortSignal;
9
+ readonly env?: NodeJS.ProcessEnv;
10
+ readonly onStdoutLine?: (line: string) => void | Promise<void>;
11
+ readonly terminateOnOutputLimit?: boolean;
12
+ }
13
+ export interface SandboxProcessResult {
14
+ readonly exitCode: number;
15
+ readonly stdout: string;
16
+ readonly stderr: string;
17
+ readonly truncated: boolean;
18
+ }
19
+ export declare function runSandboxProcess(request: SandboxProcessRequest): Promise<SandboxProcessResult>;
20
+ export declare function requireSandboxProcess(request: SandboxProcessRequest): Promise<SandboxProcessResult>;