@loucompanion/forge-bridge 0.1.3-dev.f2b8822ce953 → 0.1.3-dev.fbe15023cdfe
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 +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +970 -24
- package/dist/bridge/bridge.types.d.ts +172 -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 +47 -1
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +351 -22
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- 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 +350 -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/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +561 -0
- package/dist/session/phase-completion.artifacts.js +32 -4
- 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 +20 -6
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +129 -7
- package/dist/session/phase-completion.schemas.js +132 -5
- 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.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
- package/dist/session/provider-cli/provider-cli.process.js +223 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +17 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +226 -18
- 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 +19 -3
- package/dist/session/provider-cli/provider-cli.service.js +503 -98
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -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 +463 -58
- package/dist/session/session.types.d.ts +144 -1
- package/dist/session/session.types.js +279 -3
- 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/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- 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 +21 -1
- package/dist/worktree/dependency-warmer.js +138 -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 +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const generatedDirectoryNames = new Set(["TestResults", "test-results", "playwright-report"]);
|
|
8
|
+
const skippedDirectoryNames = new Set([
|
|
9
|
+
".git",
|
|
10
|
+
".forge",
|
|
11
|
+
".idea",
|
|
12
|
+
".next",
|
|
13
|
+
".pnpm",
|
|
14
|
+
".vs",
|
|
15
|
+
".vscode",
|
|
16
|
+
"bin",
|
|
17
|
+
"build",
|
|
18
|
+
"coverage",
|
|
19
|
+
"dist",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"obj",
|
|
22
|
+
"target",
|
|
23
|
+
"vendor",
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Moves only known, untracked test-output directories out of a worktree before
|
|
27
|
+
* a managed attempt starts. The quarantine lives in the attempt-owned output
|
|
28
|
+
* directory, so cleanup is recoverable and cannot cross the attempt boundary.
|
|
29
|
+
*/
|
|
30
|
+
export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
|
|
31
|
+
const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
|
|
32
|
+
const outputRoot = await fs.realpath(path.resolve(testOutputDirectory));
|
|
33
|
+
if (isContainedPath(worktreeRoot, outputRoot)) {
|
|
34
|
+
throw new Error("generated_output_reset_invalid: attempt output must be outside the source worktree.");
|
|
35
|
+
}
|
|
36
|
+
const candidates = await findGeneratedDirectories(worktreeRoot);
|
|
37
|
+
const quarantineDirectory = path.join(outputRoot, "preflight-reset");
|
|
38
|
+
const quarantinedPaths = [];
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const relativePath = path.relative(worktreeRoot, candidate);
|
|
41
|
+
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
42
|
+
throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
|
|
43
|
+
}
|
|
44
|
+
await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
|
|
45
|
+
await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
|
|
46
|
+
const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
|
|
47
|
+
const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
|
|
48
|
+
try {
|
|
49
|
+
await fs.rename(candidate, destination);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const code = error.code ?? "unknown";
|
|
53
|
+
throw new Error(`generated_output_reset_failed: could not quarantine ${relativePath} (${code}).`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
quarantinedPaths.push(relativePath.split(path.sep).join("/"));
|
|
56
|
+
}
|
|
57
|
+
if (quarantinedPaths.length > 0) {
|
|
58
|
+
await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
return { quarantineDirectory, quarantinedPaths };
|
|
61
|
+
}
|
|
62
|
+
async function findGeneratedDirectories(root) {
|
|
63
|
+
const candidates = [];
|
|
64
|
+
const pending = [root];
|
|
65
|
+
while (pending.length > 0) {
|
|
66
|
+
const current = pending.pop();
|
|
67
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
70
|
+
continue;
|
|
71
|
+
if (skippedDirectoryNames.has(entry.name))
|
|
72
|
+
continue;
|
|
73
|
+
const candidate = path.join(current, entry.name);
|
|
74
|
+
if (generatedDirectoryNames.has(entry.name)) {
|
|
75
|
+
const realCandidate = await fs.realpath(candidate);
|
|
76
|
+
if (!isContainedPath(root, realCandidate)) {
|
|
77
|
+
throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
|
|
78
|
+
}
|
|
79
|
+
candidates.push(realCandidate);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
pending.push(candidate);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return candidates.sort((left, right) => left.localeCompare(right));
|
|
86
|
+
}
|
|
87
|
+
async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
|
|
88
|
+
const pathspec = relativePath.split(path.sep).join("/");
|
|
89
|
+
let stdout;
|
|
90
|
+
try {
|
|
91
|
+
({ stdout } = await execFileAsync("git", ["-C", worktreeRoot, "ls-files", "-z", "--", pathspec], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 }));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
if (stdout.length > 0) {
|
|
97
|
+
throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isContainedPath(root, candidate) {
|
|
101
|
+
const relative = path.relative(root, candidate);
|
|
102
|
+
return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AttemptProcessChildHandle } from "./attempt-process-supervisor.js";
|
|
2
|
+
import type { HostPortLeaseOwner, HostPortReservation } from "./host-port-broker.js";
|
|
3
|
+
import type { HostListenerOwnership } from "./node-host-runtime-adapter.js";
|
|
4
|
+
export interface HostInspectionCommandResult {
|
|
5
|
+
readonly exitCode: number;
|
|
6
|
+
readonly stdout: string;
|
|
7
|
+
readonly stderr: string;
|
|
8
|
+
readonly errorCode?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HostInspectionCommandRunner {
|
|
11
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
12
|
+
}
|
|
13
|
+
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
14
|
+
export declare class HostListenerInspector {
|
|
15
|
+
private readonly commands;
|
|
16
|
+
private readonly platform;
|
|
17
|
+
constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
|
|
18
|
+
inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
|
|
19
|
+
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
20
|
+
inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
|
|
21
|
+
private inspectWindows;
|
|
22
|
+
private inspectAllWindows;
|
|
23
|
+
private inspectUnix;
|
|
24
|
+
private inspectAllUnix;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseWindowsLoopbackListeners(output: string): readonly {
|
|
27
|
+
pid: number;
|
|
28
|
+
port: number;
|
|
29
|
+
}[];
|
|
30
|
+
export declare function parseLsofLoopbackListeners(output: string): readonly {
|
|
31
|
+
pid: number;
|
|
32
|
+
port: number;
|
|
33
|
+
}[];
|
|
34
|
+
export declare function parseSsLoopbackListeners(output: string): readonly {
|
|
35
|
+
pid: number;
|
|
36
|
+
port: number;
|
|
37
|
+
}[];
|
|
38
|
+
export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
|
|
39
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
40
|
+
}
|
|
41
|
+
export declare function parseWindowsListenerPids(output: string, port: number): readonly number[];
|
|
42
|
+
export declare function parseLsofPids(output: string): readonly number[];
|
|
43
|
+
export declare function parseSsPids(output: string, port: number): readonly number[];
|
|
44
|
+
export declare function parseProcessGroups(output: string): ReadonlyMap<number, number>;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const windowsListenerOwnerPath = fileURLToPath(new URL("../../runtime/windows-listener-owner.ps1", import.meta.url));
|
|
6
|
+
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
7
|
+
export class HostListenerInspector {
|
|
8
|
+
commands;
|
|
9
|
+
platform;
|
|
10
|
+
constructor(commands = new ExecFileInspectionCommandRunner(), platform = process.platform) {
|
|
11
|
+
this.commands = commands;
|
|
12
|
+
this.platform = platform;
|
|
13
|
+
}
|
|
14
|
+
async inspect(child, reservation, owner) {
|
|
15
|
+
if (reservation.hostIp !== "127.0.0.1" || reservation.protocol !== "tcp") {
|
|
16
|
+
throw new Error("host_listener_inspection_invalid: only TCP IPv4 loopback listeners are supported.");
|
|
17
|
+
}
|
|
18
|
+
const metadata = await loadMetadata(child.metadataPath);
|
|
19
|
+
if (!metadata)
|
|
20
|
+
return "unbound";
|
|
21
|
+
if (!matchesOwner(metadata, child, owner))
|
|
22
|
+
return "foreign";
|
|
23
|
+
if (this.platform === "win32") {
|
|
24
|
+
return await this.inspectWindows(metadata.rootPid, reservation.hostPort);
|
|
25
|
+
}
|
|
26
|
+
return await this.inspectUnix(metadata.rootPid, reservation.hostPort);
|
|
27
|
+
}
|
|
28
|
+
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
29
|
+
async inspectOwnedLoopbackListeners(child, owner) {
|
|
30
|
+
const metadata = await loadMetadata(child.metadataPath);
|
|
31
|
+
if (!metadata || !matchesOwner(metadata, child, owner)) {
|
|
32
|
+
throw new Error("host_listener_inspection_invalid: supervised provider ownership metadata is unavailable.");
|
|
33
|
+
}
|
|
34
|
+
return this.platform === "win32"
|
|
35
|
+
? await this.inspectAllWindows(metadata.rootPid)
|
|
36
|
+
: await this.inspectAllUnix(metadata.rootPid);
|
|
37
|
+
}
|
|
38
|
+
async inspectWindows(rootPid, port) {
|
|
39
|
+
const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
|
|
40
|
+
if (netstat.exitCode !== 0) {
|
|
41
|
+
throw commandError("netstat", netstat);
|
|
42
|
+
}
|
|
43
|
+
const listenerPids = parseWindowsListenerPids(netstat.stdout, port);
|
|
44
|
+
if (listenerPids.length === 0)
|
|
45
|
+
return "unbound";
|
|
46
|
+
const powershell = windowsPowerShellPath();
|
|
47
|
+
const ownership = await this.commands.run(powershell, [
|
|
48
|
+
"-NoLogo",
|
|
49
|
+
"-NoProfile",
|
|
50
|
+
"-NonInteractive",
|
|
51
|
+
"-ExecutionPolicy",
|
|
52
|
+
"Bypass",
|
|
53
|
+
"-File",
|
|
54
|
+
windowsListenerOwnerPath,
|
|
55
|
+
"-RootProcessId",
|
|
56
|
+
String(rootPid),
|
|
57
|
+
"-ListenerProcessIds",
|
|
58
|
+
listenerPids.join(","),
|
|
59
|
+
]);
|
|
60
|
+
if (ownership.exitCode !== 0)
|
|
61
|
+
throw commandError("Windows process ancestry", ownership);
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(ownership.stdout.trim());
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new Error("host_listener_inspection_failed: Windows ancestry output was invalid.", { cause: error });
|
|
68
|
+
}
|
|
69
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
70
|
+
return values.length === listenerPids.length && values.every(value => value === true) ? "owned" : "foreign";
|
|
71
|
+
}
|
|
72
|
+
async inspectAllWindows(rootPid) {
|
|
73
|
+
const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
|
|
74
|
+
if (netstat.exitCode !== 0)
|
|
75
|
+
throw commandError("netstat", netstat);
|
|
76
|
+
const listeners = parseWindowsLoopbackListeners(netstat.stdout);
|
|
77
|
+
if (listeners.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const pids = [...new Set(listeners.map(listener => listener.pid))];
|
|
80
|
+
const ownership = await this.commands.run(windowsPowerShellPath(), [
|
|
81
|
+
"-NoLogo",
|
|
82
|
+
"-NoProfile",
|
|
83
|
+
"-NonInteractive",
|
|
84
|
+
"-ExecutionPolicy",
|
|
85
|
+
"Bypass",
|
|
86
|
+
"-File",
|
|
87
|
+
windowsListenerOwnerPath,
|
|
88
|
+
"-RootProcessId",
|
|
89
|
+
String(rootPid),
|
|
90
|
+
"-ListenerProcessIds",
|
|
91
|
+
pids.join(","),
|
|
92
|
+
]);
|
|
93
|
+
if (ownership.exitCode !== 0)
|
|
94
|
+
throw commandError("Windows process ancestry", ownership);
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(ownership.stdout.trim());
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw new Error("host_listener_inspection_failed: Windows ancestry output was invalid.", { cause: error });
|
|
101
|
+
}
|
|
102
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
103
|
+
if (values.length !== pids.length || values.some(value => typeof value !== "boolean")) {
|
|
104
|
+
throw new Error("host_listener_inspection_failed: Windows ancestry output was incomplete.");
|
|
105
|
+
}
|
|
106
|
+
const ownedPids = new Set(pids.filter((_pid, index) => values[index] === true));
|
|
107
|
+
return uniqueSorted(listeners.filter(listener => ownedPids.has(listener.pid)).map(listener => listener.port));
|
|
108
|
+
}
|
|
109
|
+
async inspectUnix(rootPid, port) {
|
|
110
|
+
let listenerPids = null;
|
|
111
|
+
const lsof = await this.commands.run("lsof", [
|
|
112
|
+
"-nP",
|
|
113
|
+
"-a",
|
|
114
|
+
`-iTCP@127.0.0.1:${port}`,
|
|
115
|
+
"-sTCP:LISTEN",
|
|
116
|
+
"-Fp",
|
|
117
|
+
]);
|
|
118
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
119
|
+
listenerPids = parseLsofPids(lsof.stdout);
|
|
120
|
+
}
|
|
121
|
+
else if (lsof.errorCode !== "ENOENT") {
|
|
122
|
+
throw commandError("lsof", lsof);
|
|
123
|
+
}
|
|
124
|
+
if (listenerPids === null) {
|
|
125
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp", "sport", "=", `:${port}`]);
|
|
126
|
+
if (ss.exitCode !== 0)
|
|
127
|
+
throw commandError("ss", ss);
|
|
128
|
+
listenerPids = parseSsPids(ss.stdout, port);
|
|
129
|
+
}
|
|
130
|
+
if (listenerPids.length === 0)
|
|
131
|
+
return "unbound";
|
|
132
|
+
const processGroups = await this.commands.run("ps", [
|
|
133
|
+
"-o",
|
|
134
|
+
"pid=,pgid=",
|
|
135
|
+
"-p",
|
|
136
|
+
listenerPids.join(","),
|
|
137
|
+
]);
|
|
138
|
+
if (processGroups.exitCode !== 0)
|
|
139
|
+
throw commandError("ps", processGroups);
|
|
140
|
+
const groups = parseProcessGroups(processGroups.stdout);
|
|
141
|
+
return listenerPids.every(pid => groups.get(pid) === rootPid) ? "owned" : "foreign";
|
|
142
|
+
}
|
|
143
|
+
async inspectAllUnix(rootPid) {
|
|
144
|
+
const lsof = await this.commands.run("lsof", [
|
|
145
|
+
"-nP",
|
|
146
|
+
"-a",
|
|
147
|
+
"-iTCP@127.0.0.1",
|
|
148
|
+
"-sTCP:LISTEN",
|
|
149
|
+
"-Fpn",
|
|
150
|
+
]);
|
|
151
|
+
let listeners;
|
|
152
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
153
|
+
listeners = parseLsofLoopbackListeners(lsof.stdout);
|
|
154
|
+
}
|
|
155
|
+
else if (lsof.errorCode === "ENOENT") {
|
|
156
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp"]);
|
|
157
|
+
if (ss.exitCode !== 0)
|
|
158
|
+
throw commandError("ss", ss);
|
|
159
|
+
listeners = parseSsLoopbackListeners(ss.stdout);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
throw commandError("lsof", lsof);
|
|
163
|
+
}
|
|
164
|
+
if (listeners.length === 0)
|
|
165
|
+
return [];
|
|
166
|
+
const pids = [...new Set(listeners.map(listener => listener.pid))];
|
|
167
|
+
const processGroups = await this.commands.run("ps", ["-o", "pid=,pgid=", "-p", pids.join(",")]);
|
|
168
|
+
if (processGroups.exitCode !== 0)
|
|
169
|
+
throw commandError("ps", processGroups);
|
|
170
|
+
const groups = parseProcessGroups(processGroups.stdout);
|
|
171
|
+
return uniqueSorted(listeners
|
|
172
|
+
.filter(listener => groups.get(listener.pid) === rootPid)
|
|
173
|
+
.map(listener => listener.port));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function parseWindowsLoopbackListeners(output) {
|
|
177
|
+
return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
|
|
178
|
+
const fields = line.trim().split(/\s+/u);
|
|
179
|
+
if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
|
|
180
|
+
return [];
|
|
181
|
+
const endpoint = parseIpv4LoopbackEndpoint(fields[1]);
|
|
182
|
+
const pids = validPid(fields[4]);
|
|
183
|
+
return endpoint && pids.length === 1 ? [{ pid: pids[0], port: endpoint }] : [];
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
export function parseLsofLoopbackListeners(output) {
|
|
187
|
+
const listeners = [];
|
|
188
|
+
let pid = null;
|
|
189
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
190
|
+
if (line.startsWith("p"))
|
|
191
|
+
pid = validPid(line.slice(1))[0] ?? null;
|
|
192
|
+
if (!line.startsWith("n") || pid === null)
|
|
193
|
+
continue;
|
|
194
|
+
const port = parseIpv4LoopbackEndpoint(line.slice(1));
|
|
195
|
+
if (port !== null)
|
|
196
|
+
listeners.push({ pid, port });
|
|
197
|
+
}
|
|
198
|
+
return uniqueListeners(listeners);
|
|
199
|
+
}
|
|
200
|
+
export function parseSsLoopbackListeners(output) {
|
|
201
|
+
return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
|
|
202
|
+
const endpoint = line.match(/127\.0\.0\.1:(\d+)/u)?.[1];
|
|
203
|
+
const port = endpoint ? Number.parseInt(endpoint, 10) : 0;
|
|
204
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
205
|
+
return [];
|
|
206
|
+
return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => {
|
|
207
|
+
const pid = validPid(match[1])[0];
|
|
208
|
+
return pid ? [{ pid, port }] : [];
|
|
209
|
+
});
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
export class ExecFileInspectionCommandRunner {
|
|
213
|
+
run(command, args) {
|
|
214
|
+
return new Promise(resolve => {
|
|
215
|
+
const executable = process.platform === "win32" && command.toLowerCase() === "netstat.exe"
|
|
216
|
+
? windowsSystemExecutable("netstat.exe")
|
|
217
|
+
: command;
|
|
218
|
+
execFile(executable, args, {
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
timeout: 15_000,
|
|
221
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
222
|
+
}, (error, stdout, stderr) => {
|
|
223
|
+
const processError = error;
|
|
224
|
+
resolve({
|
|
225
|
+
exitCode: typeof processError?.code === "number" ? processError.code : error ? 1 : 0,
|
|
226
|
+
stdout: String(stdout),
|
|
227
|
+
stderr: String(stderr),
|
|
228
|
+
...(typeof processError?.code === "string" ? { errorCode: processError.code } : {}),
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export function parseWindowsListenerPids(output, port) {
|
|
235
|
+
const suffix = `:${port}`;
|
|
236
|
+
const pids = output.split(/\r?\n/u).flatMap(line => {
|
|
237
|
+
const fields = line.trim().split(/\s+/u);
|
|
238
|
+
if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
|
|
239
|
+
return [];
|
|
240
|
+
if (fields[1] !== `127.0.0.1${suffix}`)
|
|
241
|
+
return [];
|
|
242
|
+
return validPid(fields[4]);
|
|
243
|
+
});
|
|
244
|
+
return uniqueSorted(pids);
|
|
245
|
+
}
|
|
246
|
+
export function parseLsofPids(output) {
|
|
247
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => line.startsWith("p") ? validPid(line.slice(1)) : []));
|
|
248
|
+
}
|
|
249
|
+
export function parseSsPids(output, port) {
|
|
250
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => {
|
|
251
|
+
if (!line.includes(`127.0.0.1:${port}`))
|
|
252
|
+
return [];
|
|
253
|
+
return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => validPid(match[1]));
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
export function parseProcessGroups(output) {
|
|
257
|
+
const groups = new Map();
|
|
258
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
259
|
+
const fields = line.trim().split(/\s+/u);
|
|
260
|
+
const pid = validPid(fields[0]);
|
|
261
|
+
const group = validPid(fields[1]);
|
|
262
|
+
if (pid.length === 1 && group.length === 1)
|
|
263
|
+
groups.set(pid[0], group[0]);
|
|
264
|
+
}
|
|
265
|
+
return groups;
|
|
266
|
+
}
|
|
267
|
+
async function loadMetadata(metadataPath) {
|
|
268
|
+
try {
|
|
269
|
+
const parsed = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
270
|
+
if (parsed.schemaVersion !== 1 || !Number.isSafeInteger(parsed.rootPid) || parsed.rootPid <= 0) {
|
|
271
|
+
throw new Error("host_listener_inspection_invalid: process supervisor metadata is invalid.");
|
|
272
|
+
}
|
|
273
|
+
return parsed;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error.code === "ENOENT")
|
|
277
|
+
return null;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function matchesOwner(metadata, child, owner) {
|
|
282
|
+
return metadata.supervisorId === child.processSupervisorId
|
|
283
|
+
&& metadata.runtimeLeaseId === owner.executionLeaseId
|
|
284
|
+
&& metadata.dispatchAttemptId === owner.dispatchAttemptId
|
|
285
|
+
&& metadata.fencingToken === owner.fencingToken;
|
|
286
|
+
}
|
|
287
|
+
function validPid(value) {
|
|
288
|
+
if (!value || !/^\d+$/u.test(value))
|
|
289
|
+
return [];
|
|
290
|
+
const pid = Number.parseInt(value, 10);
|
|
291
|
+
return Number.isSafeInteger(pid) && pid > 0 ? [pid] : [];
|
|
292
|
+
}
|
|
293
|
+
function uniqueSorted(values) {
|
|
294
|
+
return [...new Set(values)].sort((left, right) => left - right);
|
|
295
|
+
}
|
|
296
|
+
function parseIpv4LoopbackEndpoint(value) {
|
|
297
|
+
const match = value?.match(/^127\.0\.0\.1:(\d+)$/u);
|
|
298
|
+
if (!match)
|
|
299
|
+
return null;
|
|
300
|
+
const port = Number.parseInt(match[1], 10);
|
|
301
|
+
return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
|
302
|
+
}
|
|
303
|
+
function uniqueListeners(values) {
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
return values.filter(value => {
|
|
306
|
+
const key = `${value.pid}:${value.port}`;
|
|
307
|
+
if (seen.has(key))
|
|
308
|
+
return false;
|
|
309
|
+
seen.add(key);
|
|
310
|
+
return true;
|
|
311
|
+
}).sort((left, right) => left.port - right.port || left.pid - right.pid);
|
|
312
|
+
}
|
|
313
|
+
function commandError(label, result) {
|
|
314
|
+
const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
315
|
+
const code = result.errorCode ? ` (${result.errorCode})` : "";
|
|
316
|
+
return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${code}${detail ? `: ${detail}` : ""}.`);
|
|
317
|
+
}
|
|
318
|
+
function windowsPowerShellPath() {
|
|
319
|
+
return path.win32.join(path.dirname(windowsSystemExecutable("netstat.exe")), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
320
|
+
}
|
|
321
|
+
function windowsSystemExecutable(name) {
|
|
322
|
+
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
|
|
323
|
+
return path.win32.join(systemRoot, "System32", name);
|
|
324
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RuntimeIsolationPort } from "./runtime-isolation-lease.js";
|
|
2
|
+
export interface HostPortLeaseOwner {
|
|
3
|
+
readonly dispatchAttemptId: string;
|
|
4
|
+
readonly executionLeaseId: string;
|
|
5
|
+
readonly fencingToken: string;
|
|
6
|
+
readonly processSupervisorId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface HostPortRequest {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly protocol?: "tcp";
|
|
11
|
+
readonly targetPort?: number;
|
|
12
|
+
}
|
|
13
|
+
export type HostPortReservationState = "reserved" | "handed_off" | "active";
|
|
14
|
+
export interface HostPortReservation extends RuntimeIsolationPort, HostPortLeaseOwner {
|
|
15
|
+
readonly schemaVersion: 1;
|
|
16
|
+
readonly reservationId: string;
|
|
17
|
+
readonly state: HostPortReservationState;
|
|
18
|
+
readonly reservedAt: string;
|
|
19
|
+
readonly handedOffAt: string | null;
|
|
20
|
+
readonly activatedAt: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface HostPortLaunchLifecycle<T> {
|
|
23
|
+
readonly launch: (port: HostPortReservation, signal?: AbortSignal) => Promise<T>;
|
|
24
|
+
readonly verifyOwnership: (value: T, port: HostPortReservation, signal?: AbortSignal) => Promise<boolean>;
|
|
25
|
+
readonly stop: (value: T) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface HostPortLaunch<T> {
|
|
28
|
+
readonly reservation: HostPortReservation;
|
|
29
|
+
readonly value: T;
|
|
30
|
+
}
|
|
31
|
+
export interface HostPortBrokerOptions {
|
|
32
|
+
readonly hostIp?: "127.0.0.1";
|
|
33
|
+
readonly minimumPort?: number;
|
|
34
|
+
readonly maximumPort?: number;
|
|
35
|
+
readonly maximumLaunchAttempts?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface HostPortBrokerDiagnostics {
|
|
38
|
+
readonly hostIp: "127.0.0.1";
|
|
39
|
+
readonly pool: {
|
|
40
|
+
readonly minimumPort: number;
|
|
41
|
+
readonly maximumPort: number;
|
|
42
|
+
} | null;
|
|
43
|
+
readonly reservations: readonly Omit<HostPortReservation, "fencingToken">[];
|
|
44
|
+
}
|
|
45
|
+
export declare class HostPortCollisionError extends Error {
|
|
46
|
+
constructor(message?: string);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Owns runner-local loopback ports for supervised host attempts.
|
|
50
|
+
*
|
|
51
|
+
* A reservation keeps a real listening socket open until an adapter is ready to
|
|
52
|
+
* launch. The adapter then performs a narrow handoff and must prove that the
|
|
53
|
+
* supervised child owns the assigned endpoint before the reservation is active.
|
|
54
|
+
*/
|
|
55
|
+
export declare class HostPortBroker {
|
|
56
|
+
private readonly hostIp;
|
|
57
|
+
private readonly minimumPort;
|
|
58
|
+
private readonly maximumPort;
|
|
59
|
+
private readonly maximumLaunchAttempts;
|
|
60
|
+
private readonly reservations;
|
|
61
|
+
private readonly claimedPorts;
|
|
62
|
+
private nextPort;
|
|
63
|
+
private launchGate;
|
|
64
|
+
constructor(options?: HostPortBrokerOptions);
|
|
65
|
+
reserve(owner: HostPortLeaseOwner, request: HostPortRequest, signal?: AbortSignal): Promise<HostPortReservation>;
|
|
66
|
+
handoff(reservationId: string, owner: HostPortLeaseOwner): Promise<HostPortReservation | null>;
|
|
67
|
+
activate(reservationId: string, owner: HostPortLeaseOwner): HostPortReservation | null;
|
|
68
|
+
release(reservationId: string, owner: HostPortLeaseOwner): Promise<boolean>;
|
|
69
|
+
snapshot(): readonly HostPortReservation[];
|
|
70
|
+
diagnostics(): HostPortBrokerDiagnostics;
|
|
71
|
+
launchWithRetry<T>(owner: HostPortLeaseOwner, request: HostPortRequest, lifecycle: HostPortLaunchLifecycle<T>, signal?: AbortSignal): Promise<HostPortLaunch<T>>;
|
|
72
|
+
private reserveExcluding;
|
|
73
|
+
private listen;
|
|
74
|
+
private owned;
|
|
75
|
+
private withLaunchGate;
|
|
76
|
+
}
|