@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

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 (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -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.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,220 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ const maximumConfigBytes = 1024 * 1024;
6
+ /**
7
+ * Prepares a finite Playwright test boundary for a managed-host attempt.
8
+ * The generated overlay points tests at Forge's verified listener, disables
9
+ * Playwright-managed web servers, and keeps writable artifacts attempt-local.
10
+ */
11
+ export class PlaywrightHostRuntimeAdapter {
12
+ async prepare(config, context) {
13
+ const worktreePath = path.resolve(context.worktreePath);
14
+ const sourceConfig = path.resolve(worktreePath, config.config);
15
+ const [realWorktree, realSource] = await Promise.all([
16
+ fs.realpath(worktreePath),
17
+ fs.realpath(sourceConfig),
18
+ ]);
19
+ assertContained(realWorktree, realSource, "project Playwright config");
20
+ const sourceStat = await fs.stat(realSource);
21
+ if (!sourceStat.isFile() || sourceStat.size < 1 || sourceStat.size > maximumConfigBytes) {
22
+ throw new Error(`playwright_runtime_adapter_invalid: project config must be a regular 1-${maximumConfigBytes} byte file.`);
23
+ }
24
+ const temporaryDirectory = await realContainedDirectory(context.temporaryDirectory, realWorktree, "temporary directory");
25
+ const testOutputDirectory = await realContainedDirectory(context.testOutputDirectory, realWorktree, "test output directory");
26
+ const browserProfileDirectory = await realContainedDirectory(context.browserProfileDirectory, realWorktree, "browser profile directory");
27
+ const attemptRoot = path.dirname(temporaryDirectory);
28
+ if (path.dirname(testOutputDirectory) !== attemptRoot || path.dirname(browserProfileDirectory) !== attemptRoot) {
29
+ throw new Error("playwright_runtime_adapter_invalid: writable directories must belong to the same attempt root.");
30
+ }
31
+ const baseUrl = loopbackHttpUrl(context.baseUrl);
32
+ const overlayPath = path.join(temporaryDirectory, "forge.playwright.config.mjs");
33
+ const browserCache = await resolvePlaywrightBrowserCache(attemptRoot);
34
+ const browserInstallCommand = await resolvePlaywrightBrowserInstallCommand(context.worktreePath, path.dirname(realSource));
35
+ await fs.writeFile(overlayPath, renderOverlay({
36
+ projectConfigUrl: pathToFileURL(realSource).href,
37
+ projectConfigDirectory: path.dirname(realSource),
38
+ testOutputDirectory,
39
+ baseUrl,
40
+ workers: config.workers,
41
+ }), { encoding: "utf8", mode: 0o600 });
42
+ return {
43
+ configPath: overlayPath,
44
+ environment: {
45
+ FORGE_PLAYWRIGHT_CONFIG: overlayPath,
46
+ FORGE_PLAYWRIGHT_BASE_URL: baseUrl,
47
+ FORGE_E2E_WEB_URL: baseUrl,
48
+ FORGE_PLAYWRIGHT_BROWSER_READY: browserCache.ready ? "true" : "false",
49
+ FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND: browserInstallCommand,
50
+ PLAYWRIGHT_BROWSERS_PATH: browserCache.path,
51
+ PLAYWRIGHT_HTML_OUTPUT_DIR: path.join(testOutputDirectory, "html-report"),
52
+ PLAYWRIGHT_BLOB_OUTPUT_DIR: path.join(testOutputDirectory, "blob-report"),
53
+ PLAYWRIGHT_JUNIT_OUTPUT_NAME: path.join(testOutputDirectory, "results.xml"),
54
+ PLAYWRIGHT_JSON_OUTPUT_NAME: path.join(testOutputDirectory, "results.json"),
55
+ FORGE_BROWSER_PROFILE_DIR: browserProfileDirectory,
56
+ FORGE_TEST_OUTPUT_DIR: testOutputDirectory,
57
+ },
58
+ };
59
+ }
60
+ }
61
+ async function resolvePlaywrightBrowserInstallCommand(worktreePath, configDirectory) {
62
+ const relativeDirectory = path.relative(worktreePath, configDirectory).replaceAll(path.sep, "/") || ".";
63
+ if (await isFile(path.join(configDirectory, "pnpm-lock.yaml"))) {
64
+ return `pnpm --dir ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
65
+ }
66
+ if (await isFile(path.join(configDirectory, "yarn.lock"))) {
67
+ return `yarn --cwd ${quoteCommandArgument(relativeDirectory)} playwright install chromium`;
68
+ }
69
+ return `npm --prefix ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
70
+ }
71
+ async function isFile(candidate) {
72
+ try {
73
+ return (await fs.stat(candidate)).isFile();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function quoteCommandArgument(value) {
80
+ return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
81
+ }
82
+ async function resolvePlaywrightBrowserCache(attemptRoot) {
83
+ const candidates = [
84
+ process.env.PLAYWRIGHT_BROWSERS_PATH,
85
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "ms-playwright") : null,
86
+ path.join(os.homedir(), ".cache", "ms-playwright"),
87
+ ].filter((candidate) => Boolean(candidate?.trim()));
88
+ for (const candidate of [...new Set(candidates.map(candidate => path.resolve(candidate)))]) {
89
+ if (await hasChromiumExecutable(candidate)) {
90
+ return { path: candidate, ready: true };
91
+ }
92
+ }
93
+ const attemptCache = path.join(attemptRoot, "cache", "ms-playwright");
94
+ await fs.mkdir(attemptCache, { recursive: true });
95
+ return { path: attemptCache, ready: false };
96
+ }
97
+ async function hasChromiumExecutable(cachePath) {
98
+ let entries;
99
+ try {
100
+ entries = await fs.readdir(cachePath, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ const executableSuffixes = process.platform === "win32"
106
+ ? [path.join("chrome-win64", "chrome.exe"), path.join("chrome-win", "chrome.exe")]
107
+ : process.platform === "darwin"
108
+ ? [path.join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")]
109
+ : [path.join("chrome-linux", "chrome"), path.join("chrome-linux64", "chrome")];
110
+ for (const entry of entries) {
111
+ if (!entry.isDirectory() || !entry.name.startsWith("chromium-"))
112
+ continue;
113
+ for (const suffix of executableSuffixes) {
114
+ try {
115
+ const stat = await fs.stat(path.join(cachePath, entry.name, suffix));
116
+ if (stat.isFile())
117
+ return true;
118
+ }
119
+ catch {
120
+ // Continue through known Playwright layouts.
121
+ }
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+ function renderOverlay(values) {
127
+ return [
128
+ "// Generated by forge-bridge for one fenced host attempt.",
129
+ "import path from \"node:path\";",
130
+ `import projectConfig from ${JSON.stringify(values.projectConfigUrl)};`,
131
+ `const projectConfigDirectory = ${JSON.stringify(values.projectConfigDirectory)};`,
132
+ "const resolveConfigPath = value => typeof value === \"string\" && !path.isAbsolute(value)",
133
+ " ? path.resolve(projectConfigDirectory, value)",
134
+ " : value;",
135
+ "const resolveModulePath = value => typeof value === \"string\" && value.startsWith(\".\")",
136
+ " ? path.resolve(projectConfigDirectory, value)",
137
+ " : value;",
138
+ "const resolveUse = use => ({",
139
+ " ...(use ?? {}),",
140
+ " storageState: typeof use?.storageState === \"string\" ? resolveConfigPath(use.storageState) : use?.storageState,",
141
+ " baseURL: " + JSON.stringify(values.baseUrl) + ",",
142
+ "});",
143
+ "const forgeUse = resolveUse(projectConfig.use);",
144
+ "const forgeProjects = Array.isArray(projectConfig.projects)",
145
+ " ? projectConfig.projects.map(project => ({",
146
+ " ...project,",
147
+ " testDir: resolveConfigPath(project.testDir),",
148
+ " snapshotDir: resolveConfigPath(project.snapshotDir),",
149
+ " use: resolveUse(project.use),",
150
+ " }))",
151
+ " : projectConfig.projects;",
152
+ "const forgeReporter = Array.isArray(projectConfig.reporter)",
153
+ " ? projectConfig.reporter.map(reporter => Array.isArray(reporter)",
154
+ " ? [resolveModulePath(reporter[0]), ...reporter.slice(1)]",
155
+ " : resolveModulePath(reporter))",
156
+ " : resolveModulePath(projectConfig.reporter);",
157
+ "const forgeProviderOnlySpecs = (() => {",
158
+ " try {",
159
+ " const parsed = JSON.parse(process.env.FORGE_CI_REPAIR_PROVIDER_ONLY_SPECS ?? '[]');",
160
+ " return Array.isArray(parsed) ? parsed.filter(value => typeof value === 'string' && value.length > 0) : [];",
161
+ " } catch {",
162
+ " return [];",
163
+ " }",
164
+ "})();",
165
+ "const forgeTestIgnore = forgeProviderOnlySpecs.length === 0",
166
+ " ? projectConfig.testIgnore",
167
+ " : [...(Array.isArray(projectConfig.testIgnore) ? projectConfig.testIgnore : projectConfig.testIgnore ? [projectConfig.testIgnore] : []), ...forgeProviderOnlySpecs];",
168
+ "export default {",
169
+ " ...projectConfig,",
170
+ " testDir: resolveConfigPath(projectConfig.testDir),",
171
+ " snapshotDir: resolveConfigPath(projectConfig.snapshotDir),",
172
+ " globalSetup: resolveModulePath(projectConfig.globalSetup),",
173
+ " globalTeardown: resolveModulePath(projectConfig.globalTeardown),",
174
+ " reporter: forgeReporter,",
175
+ " testIgnore: forgeTestIgnore,",
176
+ ` outputDir: ${JSON.stringify(values.testOutputDirectory)},`,
177
+ ` workers: ${values.workers},`,
178
+ " webServer: undefined,",
179
+ " use: forgeUse,",
180
+ " projects: forgeProjects,",
181
+ "};",
182
+ "",
183
+ ].join("\n");
184
+ }
185
+ async function realContainedDirectory(target, realWorktree, label) {
186
+ const realTarget = await fs.realpath(path.resolve(target));
187
+ if (realTarget === realWorktree || isContained(realWorktree, realTarget)) {
188
+ throw new Error(`playwright_runtime_adapter_invalid: ${label} must be attempt-private and outside the worktree.`);
189
+ }
190
+ const stat = await fs.stat(realTarget);
191
+ if (!stat.isDirectory())
192
+ throw new Error(`playwright_runtime_adapter_invalid: ${label} must be a directory.`);
193
+ return realTarget;
194
+ }
195
+ function assertContained(parent, child, label) {
196
+ if (!isContained(parent, child)) {
197
+ throw new Error(`playwright_runtime_adapter_invalid: ${label} must stay inside the prepared worktree.`);
198
+ }
199
+ }
200
+ function isContained(parent, child) {
201
+ const relative = path.relative(parent, child);
202
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
203
+ }
204
+ function loopbackHttpUrl(value) {
205
+ let url;
206
+ try {
207
+ url = new URL(value);
208
+ }
209
+ catch {
210
+ throw new Error("playwright_runtime_adapter_invalid: base URL must be an HTTP loopback URL.");
211
+ }
212
+ if ((url.protocol !== "http:" && url.protocol !== "https:")
213
+ || url.hostname !== "127.0.0.1"
214
+ || !url.port
215
+ || url.username
216
+ || url.password) {
217
+ throw new Error("playwright_runtime_adapter_invalid: base URL must be an HTTP loopback URL.");
218
+ }
219
+ return url.href.replace(/\/$/u, "");
220
+ }
@@ -0,0 +1,67 @@
1
+ export declare const runtimeIsolationLeaseStates: readonly ["reserved", "starting", "active", "releasing", "released", "degraded"];
2
+ export type RuntimeIsolationLeaseState = typeof runtimeIsolationLeaseStates[number];
3
+ export type RuntimeIsolationMode = "docker" | "host";
4
+ export interface RuntimeIsolationPort {
5
+ readonly name: string;
6
+ readonly protocol: "tcp" | "udp";
7
+ readonly hostIp: "127.0.0.1" | "::1";
8
+ readonly hostPort: number;
9
+ readonly targetPort: number;
10
+ }
11
+ export interface RuntimeIsolationPath {
12
+ readonly path: string;
13
+ readonly role: string;
14
+ }
15
+ export interface RuntimeIsolationDockerResource {
16
+ readonly type: "container" | "network" | "volume";
17
+ readonly name: string;
18
+ readonly role: string;
19
+ readonly id?: string | null;
20
+ }
21
+ export interface RuntimeIsolationResources {
22
+ readonly memoryReservationBytes: number;
23
+ readonly cpuMillis: number;
24
+ readonly pids: number;
25
+ readonly ports: readonly RuntimeIsolationPort[];
26
+ readonly paths: readonly RuntimeIsolationPath[];
27
+ readonly processSupervisorId: string | null;
28
+ readonly dockerResources: readonly RuntimeIsolationDockerResource[];
29
+ readonly singletonKeys: readonly string[];
30
+ }
31
+ export interface RuntimeIsolationLease {
32
+ readonly schemaVersion: 1;
33
+ readonly leaseId: string;
34
+ readonly dispatchAttemptId: string;
35
+ readonly executionLeaseId: string;
36
+ readonly fencingToken: string;
37
+ readonly runnerId: string;
38
+ readonly runnerInstanceId: string;
39
+ readonly worktreeKey: string;
40
+ readonly worktreePath: string;
41
+ readonly mode: RuntimeIsolationMode;
42
+ readonly state: RuntimeIsolationLeaseState;
43
+ readonly createdAt: string;
44
+ readonly stateChangedAt: string;
45
+ readonly expiresAt: string;
46
+ readonly releasedAt: string | null;
47
+ readonly resources: RuntimeIsolationResources;
48
+ }
49
+ export interface RuntimeIsolationLeaseInput {
50
+ readonly leaseId: string;
51
+ readonly dispatchAttemptId: string;
52
+ readonly executionLeaseId: string;
53
+ readonly fencingToken: string;
54
+ readonly runnerId: string;
55
+ readonly runnerInstanceId: string;
56
+ readonly worktreeKey: string;
57
+ readonly worktreePath: string;
58
+ readonly mode: RuntimeIsolationMode;
59
+ readonly expiresAt: string;
60
+ readonly resources: RuntimeIsolationResources;
61
+ readonly createdAt?: string;
62
+ }
63
+ export declare function createRuntimeIsolationLease(input: RuntimeIsolationLeaseInput): RuntimeIsolationLease;
64
+ export declare function transitionRuntimeIsolationLease(lease: RuntimeIsolationLease, nextState: RuntimeIsolationLeaseState, occurredAt?: string): RuntimeIsolationLease;
65
+ export declare function renewRuntimeIsolationLease(lease: RuntimeIsolationLease, fencingToken: string, expiresAt: string): RuntimeIsolationLease | null;
66
+ export declare function withRuntimeIsolationResources(lease: RuntimeIsolationLease, resources: RuntimeIsolationResources): RuntimeIsolationLease;
67
+ export declare function validRuntimeIsolationLease(value: unknown): value is RuntimeIsolationLease;
@@ -0,0 +1,158 @@
1
+ export const runtimeIsolationLeaseStates = [
2
+ "reserved",
3
+ "starting",
4
+ "active",
5
+ "releasing",
6
+ "released",
7
+ "degraded",
8
+ ];
9
+ const allowedTransitions = {
10
+ reserved: new Set(["starting", "releasing", "degraded"]),
11
+ starting: new Set(["active", "releasing", "degraded"]),
12
+ active: new Set(["releasing", "degraded"]),
13
+ releasing: new Set(["released", "degraded"]),
14
+ released: new Set(),
15
+ degraded: new Set(["releasing"]),
16
+ };
17
+ export function createRuntimeIsolationLease(input) {
18
+ const createdAt = input.createdAt ?? new Date().toISOString();
19
+ const lease = {
20
+ schemaVersion: 1,
21
+ leaseId: input.leaseId,
22
+ dispatchAttemptId: input.dispatchAttemptId,
23
+ executionLeaseId: input.executionLeaseId,
24
+ fencingToken: input.fencingToken,
25
+ runnerId: input.runnerId,
26
+ runnerInstanceId: input.runnerInstanceId,
27
+ worktreeKey: input.worktreeKey,
28
+ worktreePath: input.worktreePath,
29
+ mode: input.mode,
30
+ state: "reserved",
31
+ createdAt,
32
+ stateChangedAt: createdAt,
33
+ expiresAt: input.expiresAt,
34
+ releasedAt: null,
35
+ resources: input.resources,
36
+ };
37
+ if (!validRuntimeIsolationLease(lease)) {
38
+ throw new Error("runtime_lease_invalid: lease identity, lifetime, or resources are invalid.");
39
+ }
40
+ return lease;
41
+ }
42
+ export function transitionRuntimeIsolationLease(lease, nextState, occurredAt = new Date().toISOString()) {
43
+ if (lease.state === nextState)
44
+ return lease;
45
+ if (!allowedTransitions[lease.state].has(nextState) || !validTimestamp(occurredAt)) {
46
+ throw new Error(`runtime_lease_transition_invalid: cannot transition ${lease.state} to ${nextState}.`);
47
+ }
48
+ const transitioned = {
49
+ ...lease,
50
+ state: nextState,
51
+ stateChangedAt: occurredAt,
52
+ releasedAt: nextState === "released" ? occurredAt : null,
53
+ };
54
+ if (!validRuntimeIsolationLease(transitioned)) {
55
+ throw new Error("runtime_lease_invalid: transitioned lease is invalid.");
56
+ }
57
+ return transitioned;
58
+ }
59
+ export function renewRuntimeIsolationLease(lease, fencingToken, expiresAt) {
60
+ if (lease.fencingToken !== fencingToken || lease.state === "released" || !validTimestamp(expiresAt))
61
+ return null;
62
+ const renewed = { ...lease, expiresAt };
63
+ return validRuntimeIsolationLease(renewed) ? renewed : null;
64
+ }
65
+ export function withRuntimeIsolationResources(lease, resources) {
66
+ if (lease.state === "released") {
67
+ throw new Error("runtime_lease_transition_invalid: released leases cannot acquire resources.");
68
+ }
69
+ const updated = { ...lease, resources };
70
+ if (!validRuntimeIsolationLease(updated)) {
71
+ throw new Error("runtime_lease_invalid: resources are invalid.");
72
+ }
73
+ return updated;
74
+ }
75
+ export function validRuntimeIsolationLease(value) {
76
+ if (!isRecord(value)
77
+ || value.schemaVersion !== 1
78
+ || !isUuid(value.leaseId)
79
+ || !isUuid(value.dispatchAttemptId)
80
+ || !isUuid(value.executionLeaseId)
81
+ || !isUuid(value.fencingToken)
82
+ || !isUuid(value.runnerId)
83
+ || !isUuid(value.runnerInstanceId)
84
+ || !boundedText(value.worktreeKey, 255)
85
+ || !boundedText(value.worktreePath, 4096)
86
+ || (value.mode !== "docker" && value.mode !== "host")
87
+ || !runtimeIsolationLeaseStates.includes(value.state)
88
+ || !validTimestamp(value.createdAt)
89
+ || !validTimestamp(value.stateChangedAt)
90
+ || !validTimestamp(value.expiresAt)
91
+ || Date.parse(value.stateChangedAt) < Date.parse(value.createdAt)
92
+ || Date.parse(value.expiresAt) < Date.parse(value.createdAt)
93
+ || (value.state === "released") !== validTimestamp(value.releasedAt)) {
94
+ return false;
95
+ }
96
+ if (value.releasedAt !== null && Date.parse(value.releasedAt) < Date.parse(value.createdAt))
97
+ return false;
98
+ return validResources(value.resources, value.mode);
99
+ }
100
+ function validResources(value, mode) {
101
+ if (!isRecord(value)
102
+ || !nonNegativeInteger(value.memoryReservationBytes)
103
+ || !nonNegativeInteger(value.cpuMillis)
104
+ || !nonNegativeInteger(value.pids)
105
+ || !Array.isArray(value.ports) || value.ports.length > 64
106
+ || !Array.isArray(value.paths) || value.paths.length > 64
107
+ || !Array.isArray(value.dockerResources) || value.dockerResources.length > 128
108
+ || !Array.isArray(value.singletonKeys) || value.singletonKeys.length > 64
109
+ || (value.processSupervisorId !== null && !boundedText(value.processSupervisorId, 255))) {
110
+ return false;
111
+ }
112
+ if (value.ports.some(port => !validPort(port))
113
+ || value.paths.some(entry => !isRecord(entry) || !boundedText(entry.path, 4096) || !boundedText(entry.role, 128))
114
+ || value.singletonKeys.some(key => !boundedText(key, 255))
115
+ || value.dockerResources.some(resource => !validDockerResource(resource))) {
116
+ return false;
117
+ }
118
+ return mode === "docker" || value.dockerResources.length === 0;
119
+ }
120
+ function validPort(value) {
121
+ return isRecord(value)
122
+ && boundedText(value.name, 128)
123
+ && (value.protocol === "tcp" || value.protocol === "udp")
124
+ && (value.hostIp === "127.0.0.1" || value.hostIp === "::1")
125
+ && validPortNumber(value.hostPort)
126
+ && validPortNumber(value.targetPort);
127
+ }
128
+ function validDockerResource(value) {
129
+ return isRecord(value)
130
+ && (value.type === "container" || value.type === "network" || value.type === "volume")
131
+ && boundedText(value.name, 255)
132
+ && boundedText(value.role, 128)
133
+ && (value.id === undefined || value.id === null || boundedText(value.id, 255));
134
+ }
135
+ function validPortNumber(value) {
136
+ return Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= 65535;
137
+ }
138
+ function nonNegativeInteger(value) {
139
+ return Number.isSafeInteger(value) && Number(value) >= 0;
140
+ }
141
+ function validTimestamp(value) {
142
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
143
+ }
144
+ function boundedText(value, maximumLength) {
145
+ return typeof value === "string"
146
+ && value.length > 0
147
+ && value.length <= maximumLength
148
+ && Array.from(value).every((character) => {
149
+ const codePoint = character.codePointAt(0) ?? 0;
150
+ return codePoint > 31 && codePoint !== 127;
151
+ });
152
+ }
153
+ function isUuid(value) {
154
+ return typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu.test(value);
155
+ }
156
+ function isRecord(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
@@ -8,6 +8,10 @@ export interface DockerPreflightOptions {
8
8
  readonly diskHighWatermarkPercent?: number;
9
9
  readonly diskLowWatermarkPercent?: number;
10
10
  readonly minimumWorkspaceBytes?: number;
11
+ readonly hostMemoryReserveBytes?: number;
12
+ readonly defaultWorkerMemoryLimitBytes?: number;
13
+ readonly defaultServiceMemoryLimitBytes?: number;
14
+ readonly runtimeOverheadBytes?: number;
11
15
  readonly requireRootless?: boolean;
12
16
  }
13
17
  export declare class DockerPreflight {
@@ -35,6 +39,16 @@ export declare function evaluateDiskPressure(availableBytes: number, totalBytes:
35
39
  export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
36
40
  export declare class DockerContextOwnershipLock {
37
41
  private lockPath;
42
+ private lockToken;
38
43
  acquire(contextName?: string): Promise<void>;
39
44
  release(): Promise<void>;
40
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 {};
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
5
6
  import { BoundedCommandRunner } from "./command-runner.js";
6
7
  const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
8
+ const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
7
9
  export class DockerPreflight {
8
10
  commands;
9
11
  cacheMs;
@@ -81,7 +83,7 @@ export class DockerPreflight {
81
83
  && buildxPruneHelp?.exitCode === 0
82
84
  && ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
83
85
  && containerUpdateHelp?.exitCode === 0
84
- && containerUpdateHelp.stdout.includes("--pids-limit");
86
+ && ["--pids-limit", "--memory", "--memory-swap"].every(flag => containerUpdateHelp.stdout.includes(flag));
85
87
  const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
86
88
  && Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
87
89
  && Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
@@ -105,22 +107,46 @@ export class DockerPreflight {
105
107
  const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
106
108
  const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
107
109
  this.diskPressureLatched = diskLow;
108
- const availableMemoryBytes = os.freemem();
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
+ }
109
127
  const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
110
128
  const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
111
- const memoryReserveBytes = 2 * 1024 ** 3;
112
- const sandboxMemoryBytes = 2 * 1024 ** 3;
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);
113
135
  const sandboxCpuMillis = 2_000;
114
- const memorySlots = Math.max(0, Math.floor((availableMemoryBytes - memoryReserveBytes) / sandboxMemoryBytes));
115
136
  const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
116
137
  const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
117
- const capacityLowCode = !diskLow && memorySlots <= 0 ? "docker_memory_low" : !diskLow && cpuSlots <= 0 ? "docker_cpu_low" : null;
118
- const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots, memorySlots);
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);
119
145
  const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
120
146
  const unavailableReason = diskLow
121
147
  ? `Docker workspace has only ${disk.available} bytes available.`
122
148
  : unavailableCode === "docker_memory_low"
123
- ? `Docker host has ${availableMemoryBytes} bytes free; at least ${memoryReserveBytes + sandboxMemoryBytes} bytes are needed for one sandbox plus reserve.`
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.`
124
150
  : unavailableCode === "docker_cpu_low"
125
151
  ? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
126
152
  : null;
@@ -132,7 +158,10 @@ export class DockerPreflight {
132
158
  generation: this.generation,
133
159
  runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
134
160
  maxConcurrentSandboxes,
161
+ totalMemoryBytes,
135
162
  availableMemoryBytes,
163
+ hostReserveBytes: memoryReserveBytes,
164
+ admissionMemoryBytes: sandboxMemoryBytes,
136
165
  availableCpuMillis,
137
166
  availableWorkspaceBytes: disk.available,
138
167
  diskLow,
@@ -161,34 +190,48 @@ export function storagePolicyAvailable(storageQuota, environment = process.env)
161
190
  }
162
191
  export class DockerContextOwnershipLock {
163
192
  lockPath = null;
193
+ lockToken = null;
164
194
  async acquire(contextName) {
165
195
  if (this.lockPath)
166
196
  return;
167
197
  contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
168
198
  const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
169
199
  const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
200
+ const token = randomUUID();
170
201
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
171
202
  try {
172
203
  const handle = await fs.open(lockPath, "wx", 0o600);
173
- await handle.writeFile(JSON.stringify({ pid: process.pid, contextName, acquiredAt: new Date().toISOString() }));
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
+ }));
174
212
  await handle.sync();
175
213
  await handle.close();
176
214
  }
177
215
  catch (error) {
178
216
  const owner = await readLock(lockPath);
179
- if (owner?.pid && !processExists(owner.pid)) {
217
+ if (!dockerContextOwnerIsActive(owner)) {
180
218
  await fs.rm(lockPath, { force: true });
181
219
  return await this.acquire(contextName);
182
220
  }
183
221
  throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
184
222
  }
185
223
  this.lockPath = lockPath;
224
+ this.lockToken = token;
186
225
  }
187
226
  async release() {
188
227
  if (!this.lockPath)
189
228
  return;
190
- await fs.rm(this.lockPath, { force: true });
229
+ const owner = await readLock(this.lockPath);
230
+ if (owner?.token === this.lockToken) {
231
+ await fs.rm(this.lockPath, { force: true });
232
+ }
191
233
  this.lockPath = null;
234
+ this.lockToken = null;
192
235
  }
193
236
  }
194
237
  function parseInfo(stdout) {
@@ -211,7 +254,10 @@ function unavailable(generation, code, reason, features = {
211
254
  generation,
212
255
  runtimeVersion: null,
213
256
  maxConcurrentSandboxes: 0,
257
+ totalMemoryBytes: os.totalmem(),
214
258
  availableMemoryBytes: os.freemem(),
259
+ hostReserveBytes: defaultHostMemoryReserveBytes,
260
+ admissionMemoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
215
261
  availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
216
262
  availableWorkspaceBytes: 0,
217
263
  diskLow: code === "docker_disk_low",
@@ -242,9 +288,42 @@ function positiveInteger(value, fallback) {
242
288
  function singleLine(value) {
243
289
  return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
244
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
+ }
245
304
  async function readLock(lockPath) {
246
305
  return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
247
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
+ }
248
327
  function processExists(pid) {
249
328
  try {
250
329
  process.kill(pid, 0);