@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.
- package/README.md +34 -0
- package/dist/bridge/bridge.base.d.ts +2 -1
- package/dist/bridge/bridge.service.d.ts +22 -3
- package/dist/bridge/bridge.service.js +416 -40
- package/dist/bridge/bridge.types.d.ts +59 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +37 -3
- package/dist/cli/cli.service.d.ts +30 -2
- package/dist/cli/cli.service.js +182 -23
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +41 -1
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +343 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/preflight.d.ts +14 -0
- package/dist/sandbox/preflight.js +91 -12
- package/dist/sandbox/process.js +125 -40
- package/dist/sandbox/repository-lock.js +37 -1
- package/dist/sandbox/runtime.d.ts +66 -3
- package/dist/sandbox/runtime.js +670 -105
- package/dist/sandbox/sandbox.types.d.ts +3 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.js +1 -1
- package/dist/sandbox/worker.js +2 -5
- package/dist/sandbox/workspace.service.d.ts +1 -0
- package/dist/sandbox/workspace.service.js +28 -5
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +350 -0
- package/dist/session/phase-completion.artifacts.js +9 -2
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +19 -5
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +127 -7
- package/dist/session/phase-completion.schemas.js +108 -3
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.process.js +212 -25
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.service.js +254 -71
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +436 -41
- package/dist/session/session.types.d.ts +55 -0
- package/dist/session/session.types.js +69 -2
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/redaction/redaction.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +10 -1
- package/dist/worktree/dependency-warmer.js +94 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +3 -2
- package/runtime/windows-job-supervisor.ps1 +356 -0
- 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(
|
|
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
|
|
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 =
|
|
112
|
-
const
|
|
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
|
|
118
|
-
const
|
|
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
|
|
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({
|
|
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 (
|
|
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
|
|
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);
|