@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.e7b62d8fdd33

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 (170) hide show
  1. package/README.md +127 -0
  2. package/dist/bridge/bridge.base.d.ts +8 -1
  3. package/dist/bridge/bridge.service.d.ts +93 -3
  4. package/dist/bridge/bridge.service.js +1358 -47
  5. package/dist/bridge/bridge.types.d.ts +202 -1
  6. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  7. package/dist/bridge/runner-host-capabilities.js +93 -0
  8. package/dist/cleanup/cleanup.service.d.ts +14 -2
  9. package/dist/cleanup/cleanup.service.js +496 -3
  10. package/dist/cleanup/cleanup.types.d.ts +10 -0
  11. package/dist/cleanup/index.d.ts +1 -1
  12. package/dist/cleanup/index.js +1 -1
  13. package/dist/cleanup/internals.d.ts +2 -2
  14. package/dist/cleanup/internals.js +1 -1
  15. package/dist/cli/cli.help.js +47 -1
  16. package/dist/cli/cli.service.d.ts +45 -2
  17. package/dist/cli/cli.service.js +378 -26
  18. package/dist/cli/local-config.d.ts +7 -0
  19. package/dist/cli/local-config.js +51 -3
  20. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  21. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  22. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  23. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  24. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  25. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  26. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  27. package/dist/runtime/attempt-process-supervisor.js +386 -0
  28. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  29. package/dist/runtime/docker-engine-probe.js +300 -0
  30. package/dist/runtime/generated-output-reset.d.ts +10 -0
  31. package/dist/runtime/generated-output-reset.js +103 -0
  32. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  33. package/dist/runtime/host-listener-inspector.js +349 -0
  34. package/dist/runtime/host-port-broker.d.ts +76 -0
  35. package/dist/runtime/host-port-broker.js +333 -0
  36. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  37. package/dist/runtime/host-runtime-contract.js +700 -0
  38. package/dist/runtime/host-runtime-lease.d.ts +197 -0
  39. package/dist/runtime/host-runtime-lease.js +2203 -0
  40. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  41. package/dist/runtime/host-runtime-promotion.js +159 -0
  42. package/dist/runtime/memory-capacity.d.ts +68 -0
  43. package/dist/runtime/memory-capacity.js +121 -0
  44. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  45. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  46. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  47. package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
  48. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  49. package/dist/runtime/runtime-isolation-lease.js +158 -0
  50. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  51. package/dist/runtime/workspace-resource-estimator.js +174 -0
  52. package/dist/sandbox/command-runner.d.ts +4 -0
  53. package/dist/sandbox/command-runner.js +47 -0
  54. package/dist/sandbox/contract.d.ts +5 -0
  55. package/dist/sandbox/contract.js +276 -0
  56. package/dist/sandbox/event-budget.d.ts +11 -0
  57. package/dist/sandbox/event-budget.js +27 -0
  58. package/dist/sandbox/image-policy.d.ts +35 -0
  59. package/dist/sandbox/image-policy.js +247 -0
  60. package/dist/sandbox/index.d.ts +5 -0
  61. package/dist/sandbox/index.js +5 -0
  62. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  63. package/dist/sandbox/lease-watchdog.js +67 -0
  64. package/dist/sandbox/preflight.d.ts +84 -0
  65. package/dist/sandbox/preflight.js +521 -0
  66. package/dist/sandbox/process.d.ts +20 -0
  67. package/dist/sandbox/process.js +209 -0
  68. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  69. package/dist/sandbox/provider-auth/codex.js +212 -0
  70. package/dist/sandbox/repository-lock.d.ts +5 -0
  71. package/dist/sandbox/repository-lock.js +121 -0
  72. package/dist/sandbox/runtime.d.ts +297 -0
  73. package/dist/sandbox/runtime.js +2338 -0
  74. package/dist/sandbox/sandbox.types.d.ts +93 -0
  75. package/dist/sandbox/sandbox.types.js +1 -0
  76. package/dist/sandbox/smoke.d.ts +76 -0
  77. package/dist/sandbox/smoke.js +380 -0
  78. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  79. package/dist/sandbox/worker-supervisor.js +36 -0
  80. package/dist/sandbox/worker.d.ts +1 -0
  81. package/dist/sandbox/worker.js +225 -0
  82. package/dist/sandbox/workspace.service.d.ts +13 -0
  83. package/dist/sandbox/workspace.service.js +72 -0
  84. package/dist/session/managed-attempt-tools.d.ts +21 -0
  85. package/dist/session/managed-attempt-tools.js +912 -0
  86. package/dist/session/managed-build-coordinator.d.ts +5 -0
  87. package/dist/session/managed-build-coordinator.js +105 -0
  88. package/dist/session/managed-build-receipt.d.ts +21 -0
  89. package/dist/session/managed-build-receipt.js +335 -0
  90. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  91. package/dist/session/phase-completion.artifacts.js +256 -26
  92. package/dist/session/phase-completion.d.ts +12 -2
  93. package/dist/session/phase-completion.helpers.d.ts +5 -0
  94. package/dist/session/phase-completion.helpers.js +51 -10
  95. package/dist/session/phase-completion.js +14 -3
  96. package/dist/session/phase-completion.prompts.d.ts +3 -3
  97. package/dist/session/phase-completion.prompts.js +24 -8
  98. package/dist/session/phase-completion.runtime.d.ts +13 -1
  99. package/dist/session/phase-completion.runtime.js +189 -8
  100. package/dist/session/phase-completion.schemas.js +117 -4
  101. package/dist/session/phase-completion.validation.d.ts +4 -1
  102. package/dist/session/phase-completion.validation.js +153 -22
  103. package/dist/session/pre-publication-validation.d.ts +46 -0
  104. package/dist/session/pre-publication-validation.js +208 -0
  105. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  106. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  107. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  108. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  109. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  110. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  111. package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
  112. package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
  113. package/dist/session/provider-cli/provider-cli.process.js +474 -23
  114. package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
  115. package/dist/session/provider-cli/provider-cli.prompts.js +224 -24
  116. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  117. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  118. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  119. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  120. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  121. package/dist/session/provider-cli/provider-cli.service.d.ts +26 -3
  122. package/dist/session/provider-cli/provider-cli.service.js +650 -108
  123. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  124. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  125. package/dist/session/provider-cli/provider-output-state.js +91 -16
  126. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  127. package/dist/session/provider-cli/provider-usage.js +28 -5
  128. package/dist/session/session-redaction.d.ts +3 -0
  129. package/dist/session/session-redaction.js +38 -0
  130. package/dist/session/session.base.d.ts +6 -1
  131. package/dist/session/session.service.d.ts +8 -2
  132. package/dist/session/session.service.js +549 -58
  133. package/dist/session/session.types.d.ts +124 -1
  134. package/dist/session/session.types.js +231 -4
  135. package/dist/shared/format/format-bytes.d.ts +1 -0
  136. package/dist/shared/format/format-bytes.js +9 -0
  137. package/dist/shared/path/path.d.ts +1 -0
  138. package/dist/shared/path/path.js +21 -1
  139. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  140. package/dist/shared/process/dotnet-toolchain.js +179 -0
  141. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  142. package/dist/shared/process/host-process-recovery.js +78 -0
  143. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  144. package/dist/shared/process/host-process-supervisor.js +330 -0
  145. package/dist/shared/process/process.d.ts +1 -0
  146. package/dist/shared/process/process.js +1 -0
  147. package/dist/shared/process/windows-launch.d.ts +1 -1
  148. package/dist/shared/process/windows-launch.js +6 -3
  149. package/dist/shared/provider-capabilities.d.ts +1 -0
  150. package/dist/shared/provider-capabilities.js +3 -0
  151. package/dist/shared/redaction/redaction.js +16 -4
  152. package/dist/version.d.ts +1 -1
  153. package/dist/version.js +1 -1
  154. package/dist/worktree/artifact-injector.d.ts +3 -2
  155. package/dist/worktree/artifact-injector.js +43 -4
  156. package/dist/worktree/dependency-warmer.d.ts +39 -1
  157. package/dist/worktree/dependency-warmer.js +351 -8
  158. package/dist/worktree/index.d.ts +2 -1
  159. package/dist/worktree/index.js +1 -0
  160. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  161. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  162. package/dist/worktree/worktree-paths.d.ts +4 -0
  163. package/dist/worktree/worktree-paths.js +30 -0
  164. package/dist/worktree/worktree.base.d.ts +3 -0
  165. package/dist/worktree/worktree.service.d.ts +13 -2
  166. package/dist/worktree/worktree.service.js +238 -47
  167. package/dist/worktree/worktree.types.d.ts +5 -1
  168. package/package.json +8 -4
  169. package/runtime/windows-job-supervisor.ps1 +444 -0
  170. 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
+ }