@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba7d36726eda
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 +5 -1
- package/dist/bridge/bridge.service.d.ts +52 -3
- package/dist/bridge/bridge.service.js +776 -19
- package/dist/bridge/bridge.types.d.ts +131 -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 +46 -0
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +350 -23
- package/dist/cli/local-config.d.ts +4 -0
- package/dist/cli/local-config.js +39 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +50 -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 +74 -0
- package/dist/runtime/attempt-process-supervisor.js +290 -0
- package/dist/runtime/host-listener-inspector.d.ts +28 -0
- package/dist/runtime/host-listener-inspector.js +185 -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 +39 -0
- package/dist/runtime/host-runtime-contract.js +295 -0
- package/dist/runtime/host-runtime-lease.d.ts +122 -0
- package/dist/runtime/host-runtime-lease.js +1241 -0
- package/dist/runtime/host-runtime-promotion.d.ts +21 -0
- package/dist/runtime/host-runtime-promotion.js +128 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
- package/dist/runtime/node-host-runtime-adapter.js +205 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +136 -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 +45 -0
- package/dist/sandbox/preflight.js +300 -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 +67 -0
- package/dist/session/phase-completion.artifacts.js +23 -2
- package/dist/session/phase-completion.helpers.js +6 -1
- package/dist/session/phase-completion.runtime.js +3 -1
- 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 +20 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +3 -0
- package/dist/session/provider-cli/provider-cli.process.js +65 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +99 -15
- 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 +4 -1
- package/dist/session/provider-cli/provider-cli.service.js +167 -28
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +4 -0
- package/dist/session/session.service.d.ts +6 -1
- package/dist/session/session.service.js +184 -38
- package/dist/session/session.types.d.ts +58 -1
- package/dist/session/session.types.js +172 -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 +40 -0
- package/dist/shared/process/host-process-supervisor.js +195 -0
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -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/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 +6 -2
- package/dist/worktree/worktree.service.js +60 -43
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +287 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
async inspectWindows(rootPid, port) {
|
|
29
|
+
const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
|
|
30
|
+
if (netstat.exitCode !== 0) {
|
|
31
|
+
throw commandError("netstat", netstat);
|
|
32
|
+
}
|
|
33
|
+
const listenerPids = parseWindowsListenerPids(netstat.stdout, port);
|
|
34
|
+
if (listenerPids.length === 0)
|
|
35
|
+
return "unbound";
|
|
36
|
+
const powershell = windowsPowerShellPath();
|
|
37
|
+
const ownership = await this.commands.run(powershell, [
|
|
38
|
+
"-NoLogo",
|
|
39
|
+
"-NoProfile",
|
|
40
|
+
"-NonInteractive",
|
|
41
|
+
"-ExecutionPolicy",
|
|
42
|
+
"Bypass",
|
|
43
|
+
"-File",
|
|
44
|
+
windowsListenerOwnerPath,
|
|
45
|
+
"-RootProcessId",
|
|
46
|
+
String(rootPid),
|
|
47
|
+
"-ListenerProcessIds",
|
|
48
|
+
listenerPids.join(","),
|
|
49
|
+
]);
|
|
50
|
+
if (ownership.exitCode !== 0)
|
|
51
|
+
throw commandError("Windows process ancestry", ownership);
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(ownership.stdout.trim());
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error("host_listener_inspection_failed: Windows ancestry output was invalid.", { cause: error });
|
|
58
|
+
}
|
|
59
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
60
|
+
return values.length === listenerPids.length && values.every(value => value === true) ? "owned" : "foreign";
|
|
61
|
+
}
|
|
62
|
+
async inspectUnix(rootPid, port) {
|
|
63
|
+
let listenerPids = null;
|
|
64
|
+
const lsof = await this.commands.run("lsof", [
|
|
65
|
+
"-nP",
|
|
66
|
+
"-a",
|
|
67
|
+
`-iTCP@127.0.0.1:${port}`,
|
|
68
|
+
"-sTCP:LISTEN",
|
|
69
|
+
"-Fp",
|
|
70
|
+
]);
|
|
71
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
72
|
+
listenerPids = parseLsofPids(lsof.stdout);
|
|
73
|
+
}
|
|
74
|
+
else if (lsof.errorCode !== "ENOENT") {
|
|
75
|
+
throw commandError("lsof", lsof);
|
|
76
|
+
}
|
|
77
|
+
if (listenerPids === null) {
|
|
78
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp", "sport", "=", `:${port}`]);
|
|
79
|
+
if (ss.exitCode !== 0)
|
|
80
|
+
throw commandError("ss", ss);
|
|
81
|
+
listenerPids = parseSsPids(ss.stdout, port);
|
|
82
|
+
}
|
|
83
|
+
if (listenerPids.length === 0)
|
|
84
|
+
return "unbound";
|
|
85
|
+
const processGroups = await this.commands.run("ps", [
|
|
86
|
+
"-o",
|
|
87
|
+
"pid=,pgid=",
|
|
88
|
+
"-p",
|
|
89
|
+
listenerPids.join(","),
|
|
90
|
+
]);
|
|
91
|
+
if (processGroups.exitCode !== 0)
|
|
92
|
+
throw commandError("ps", processGroups);
|
|
93
|
+
const groups = parseProcessGroups(processGroups.stdout);
|
|
94
|
+
return listenerPids.every(pid => groups.get(pid) === rootPid) ? "owned" : "foreign";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export class ExecFileInspectionCommandRunner {
|
|
98
|
+
run(command, args) {
|
|
99
|
+
return new Promise(resolve => {
|
|
100
|
+
execFile(command, args, {
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
timeout: 5_000,
|
|
103
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
104
|
+
}, (error, stdout, stderr) => {
|
|
105
|
+
const processError = error;
|
|
106
|
+
resolve({
|
|
107
|
+
exitCode: typeof processError?.code === "number" ? processError.code : error ? 1 : 0,
|
|
108
|
+
stdout: String(stdout),
|
|
109
|
+
stderr: String(stderr),
|
|
110
|
+
...(typeof processError?.code === "string" ? { errorCode: processError.code } : {}),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function parseWindowsListenerPids(output, port) {
|
|
117
|
+
const suffix = `:${port}`;
|
|
118
|
+
const pids = output.split(/\r?\n/u).flatMap(line => {
|
|
119
|
+
const fields = line.trim().split(/\s+/u);
|
|
120
|
+
if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
|
|
121
|
+
return [];
|
|
122
|
+
if (fields[1] !== `127.0.0.1${suffix}`)
|
|
123
|
+
return [];
|
|
124
|
+
return validPid(fields[4]);
|
|
125
|
+
});
|
|
126
|
+
return uniqueSorted(pids);
|
|
127
|
+
}
|
|
128
|
+
export function parseLsofPids(output) {
|
|
129
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => line.startsWith("p") ? validPid(line.slice(1)) : []));
|
|
130
|
+
}
|
|
131
|
+
export function parseSsPids(output, port) {
|
|
132
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => {
|
|
133
|
+
if (!line.includes(`127.0.0.1:${port}`))
|
|
134
|
+
return [];
|
|
135
|
+
return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => validPid(match[1]));
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
export function parseProcessGroups(output) {
|
|
139
|
+
const groups = new Map();
|
|
140
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
141
|
+
const fields = line.trim().split(/\s+/u);
|
|
142
|
+
const pid = validPid(fields[0]);
|
|
143
|
+
const group = validPid(fields[1]);
|
|
144
|
+
if (pid.length === 1 && group.length === 1)
|
|
145
|
+
groups.set(pid[0], group[0]);
|
|
146
|
+
}
|
|
147
|
+
return groups;
|
|
148
|
+
}
|
|
149
|
+
async function loadMetadata(metadataPath) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
152
|
+
if (parsed.schemaVersion !== 1 || !Number.isSafeInteger(parsed.rootPid) || parsed.rootPid <= 0) {
|
|
153
|
+
throw new Error("host_listener_inspection_invalid: process supervisor metadata is invalid.");
|
|
154
|
+
}
|
|
155
|
+
return parsed;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === "ENOENT")
|
|
159
|
+
return null;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function matchesOwner(metadata, child, owner) {
|
|
164
|
+
return metadata.supervisorId === child.processSupervisorId
|
|
165
|
+
&& metadata.runtimeLeaseId === owner.executionLeaseId
|
|
166
|
+
&& metadata.dispatchAttemptId === owner.dispatchAttemptId
|
|
167
|
+
&& metadata.fencingToken === owner.fencingToken;
|
|
168
|
+
}
|
|
169
|
+
function validPid(value) {
|
|
170
|
+
if (!value || !/^\d+$/u.test(value))
|
|
171
|
+
return [];
|
|
172
|
+
const pid = Number.parseInt(value, 10);
|
|
173
|
+
return Number.isSafeInteger(pid) && pid > 0 ? [pid] : [];
|
|
174
|
+
}
|
|
175
|
+
function uniqueSorted(values) {
|
|
176
|
+
return [...new Set(values)].sort((left, right) => left - right);
|
|
177
|
+
}
|
|
178
|
+
function commandError(label, result) {
|
|
179
|
+
const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
180
|
+
return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${detail ? `: ${detail}` : ""}.`);
|
|
181
|
+
}
|
|
182
|
+
function windowsPowerShellPath() {
|
|
183
|
+
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
|
|
184
|
+
return path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
185
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
export class HostPortCollisionError extends Error {
|
|
4
|
+
constructor(message = "host_port_collision: the launched process did not own its assigned loopback port.") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "HostPortCollisionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Owns runner-local loopback ports for supervised host attempts.
|
|
11
|
+
*
|
|
12
|
+
* A reservation keeps a real listening socket open until an adapter is ready to
|
|
13
|
+
* launch. The adapter then performs a narrow handoff and must prove that the
|
|
14
|
+
* supervised child owns the assigned endpoint before the reservation is active.
|
|
15
|
+
*/
|
|
16
|
+
export class HostPortBroker {
|
|
17
|
+
hostIp;
|
|
18
|
+
minimumPort;
|
|
19
|
+
maximumPort;
|
|
20
|
+
maximumLaunchAttempts;
|
|
21
|
+
reservations = new Map();
|
|
22
|
+
claimedPorts = new Map();
|
|
23
|
+
nextPort;
|
|
24
|
+
launchGate = Promise.resolve();
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.hostIp = options.hostIp ?? "127.0.0.1";
|
|
27
|
+
const hasMinimum = options.minimumPort !== undefined;
|
|
28
|
+
const hasMaximum = options.maximumPort !== undefined;
|
|
29
|
+
if (hasMinimum !== hasMaximum) {
|
|
30
|
+
throw new Error("host_port_broker_invalid: minimumPort and maximumPort must be configured together.");
|
|
31
|
+
}
|
|
32
|
+
this.minimumPort = hasMinimum ? validPort(options.minimumPort, "minimumPort") : null;
|
|
33
|
+
this.maximumPort = hasMaximum ? validPort(options.maximumPort, "maximumPort") : null;
|
|
34
|
+
if (this.minimumPort !== null && this.maximumPort !== null && this.minimumPort > this.maximumPort) {
|
|
35
|
+
throw new Error("host_port_broker_invalid: minimumPort cannot exceed maximumPort.");
|
|
36
|
+
}
|
|
37
|
+
this.maximumLaunchAttempts = options.maximumLaunchAttempts ?? 3;
|
|
38
|
+
if (!Number.isSafeInteger(this.maximumLaunchAttempts) || this.maximumLaunchAttempts < 1 || this.maximumLaunchAttempts > 10) {
|
|
39
|
+
throw new Error("host_port_broker_invalid: maximumLaunchAttempts must be between 1 and 10.");
|
|
40
|
+
}
|
|
41
|
+
this.nextPort = this.minimumPort;
|
|
42
|
+
}
|
|
43
|
+
async reserve(owner, request, signal) {
|
|
44
|
+
validateOwner(owner);
|
|
45
|
+
validateRequest(request);
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
throw cancellationError(signal);
|
|
48
|
+
const { server, port } = await this.listen(signal, new Set());
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
const reservation = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
reservationId: randomUUID(),
|
|
53
|
+
...owner,
|
|
54
|
+
name: request.name,
|
|
55
|
+
protocol: "tcp",
|
|
56
|
+
hostIp: this.hostIp,
|
|
57
|
+
hostPort: port,
|
|
58
|
+
targetPort: request.targetPort ?? port,
|
|
59
|
+
state: "reserved",
|
|
60
|
+
reservedAt: now,
|
|
61
|
+
handedOffAt: null,
|
|
62
|
+
activatedAt: null,
|
|
63
|
+
};
|
|
64
|
+
this.reservations.set(reservation.reservationId, { reservation, server });
|
|
65
|
+
this.claimedPorts.set(port, reservation.reservationId);
|
|
66
|
+
return reservation;
|
|
67
|
+
}
|
|
68
|
+
async handoff(reservationId, owner) {
|
|
69
|
+
const held = this.owned(reservationId, owner);
|
|
70
|
+
if (!held || held.reservation.state !== "reserved")
|
|
71
|
+
return null;
|
|
72
|
+
await closeServer(held.server);
|
|
73
|
+
held.server = null;
|
|
74
|
+
held.reservation = {
|
|
75
|
+
...held.reservation,
|
|
76
|
+
state: "handed_off",
|
|
77
|
+
handedOffAt: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
return held.reservation;
|
|
80
|
+
}
|
|
81
|
+
activate(reservationId, owner) {
|
|
82
|
+
const held = this.owned(reservationId, owner);
|
|
83
|
+
if (!held || held.reservation.state !== "handed_off")
|
|
84
|
+
return null;
|
|
85
|
+
held.reservation = {
|
|
86
|
+
...held.reservation,
|
|
87
|
+
state: "active",
|
|
88
|
+
activatedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
return held.reservation;
|
|
91
|
+
}
|
|
92
|
+
async release(reservationId, owner) {
|
|
93
|
+
const held = this.owned(reservationId, owner);
|
|
94
|
+
if (!held)
|
|
95
|
+
return false;
|
|
96
|
+
await closeServer(held.server);
|
|
97
|
+
this.reservations.delete(reservationId);
|
|
98
|
+
if (this.claimedPorts.get(held.reservation.hostPort) === reservationId) {
|
|
99
|
+
this.claimedPorts.delete(held.reservation.hostPort);
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
snapshot() {
|
|
104
|
+
return [...this.reservations.values()]
|
|
105
|
+
.map(entry => entry.reservation)
|
|
106
|
+
.sort((left, right) => left.hostPort - right.hostPort);
|
|
107
|
+
}
|
|
108
|
+
diagnostics() {
|
|
109
|
+
return {
|
|
110
|
+
hostIp: this.hostIp,
|
|
111
|
+
pool: this.minimumPort === null || this.maximumPort === null
|
|
112
|
+
? null
|
|
113
|
+
: { minimumPort: this.minimumPort, maximumPort: this.maximumPort },
|
|
114
|
+
reservations: this.snapshot().map(({ fencingToken: _fencingToken, ...reservation }) => reservation),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async launchWithRetry(owner, request, lifecycle, signal) {
|
|
118
|
+
const rejectedPorts = new Set();
|
|
119
|
+
let lastCollision = null;
|
|
120
|
+
for (let attempt = 1; attempt <= this.maximumLaunchAttempts; attempt += 1) {
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
throw cancellationError(signal);
|
|
123
|
+
const reservation = await this.reserveExcluding(owner, request, rejectedPorts, signal);
|
|
124
|
+
try {
|
|
125
|
+
return await this.withLaunchGate(async () => {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
if (signal?.aborted)
|
|
129
|
+
throw cancellationError(signal);
|
|
130
|
+
const handedOff = await this.handoff(reservation.reservationId, owner);
|
|
131
|
+
if (!handedOff)
|
|
132
|
+
throw new Error("host_port_broker_state: reservation handoff failed.");
|
|
133
|
+
value = await lifecycle.launch(handedOff, signal);
|
|
134
|
+
if (!await lifecycle.verifyOwnership(value, handedOff, signal)) {
|
|
135
|
+
throw new HostPortCollisionError();
|
|
136
|
+
}
|
|
137
|
+
const active = this.activate(reservation.reservationId, owner);
|
|
138
|
+
if (!active)
|
|
139
|
+
throw new Error("host_port_broker_state: reservation activation failed.");
|
|
140
|
+
return { reservation: active, value };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (value !== undefined)
|
|
144
|
+
await lifecycle.stop(value).catch(() => undefined);
|
|
145
|
+
rejectedPorts.add(reservation.hostPort);
|
|
146
|
+
await this.release(reservation.reservationId, owner);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}, signal);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (this.reservations.has(reservation.reservationId)) {
|
|
153
|
+
rejectedPorts.add(reservation.hostPort);
|
|
154
|
+
await this.release(reservation.reservationId, owner);
|
|
155
|
+
}
|
|
156
|
+
if (!(error instanceof HostPortCollisionError))
|
|
157
|
+
throw error;
|
|
158
|
+
lastCollision = error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw lastCollision ?? new HostPortCollisionError("host_port_collision: launch retry budget exhausted.");
|
|
162
|
+
}
|
|
163
|
+
async reserveExcluding(owner, request, excludedPorts, signal) {
|
|
164
|
+
validateOwner(owner);
|
|
165
|
+
validateRequest(request);
|
|
166
|
+
const { server, port } = await this.listen(signal, excludedPorts);
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
const reservation = {
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
reservationId: randomUUID(),
|
|
171
|
+
...owner,
|
|
172
|
+
name: request.name,
|
|
173
|
+
protocol: "tcp",
|
|
174
|
+
hostIp: this.hostIp,
|
|
175
|
+
hostPort: port,
|
|
176
|
+
targetPort: request.targetPort ?? port,
|
|
177
|
+
state: "reserved",
|
|
178
|
+
reservedAt: now,
|
|
179
|
+
handedOffAt: null,
|
|
180
|
+
activatedAt: null,
|
|
181
|
+
};
|
|
182
|
+
this.reservations.set(reservation.reservationId, { reservation, server });
|
|
183
|
+
this.claimedPorts.set(port, reservation.reservationId);
|
|
184
|
+
return reservation;
|
|
185
|
+
}
|
|
186
|
+
async listen(signal, excludedPorts) {
|
|
187
|
+
if (this.minimumPort === null || this.maximumPort === null) {
|
|
188
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
189
|
+
const result = await listenOn(this.hostIp, 0, signal);
|
|
190
|
+
if (!this.claimedPorts.has(result.port) && !excludedPorts.has(result.port))
|
|
191
|
+
return result;
|
|
192
|
+
await closeServer(result.server);
|
|
193
|
+
}
|
|
194
|
+
throw new Error("host_port_broker_exhausted: could not reserve a unique ephemeral loopback port.");
|
|
195
|
+
}
|
|
196
|
+
const poolSize = this.maximumPort - this.minimumPort + 1;
|
|
197
|
+
for (let attempt = 0; attempt < poolSize; attempt += 1) {
|
|
198
|
+
const port = this.nextPort ?? this.minimumPort;
|
|
199
|
+
this.nextPort = port >= this.maximumPort ? this.minimumPort : port + 1;
|
|
200
|
+
if (this.claimedPorts.has(port) || excludedPorts.has(port))
|
|
201
|
+
continue;
|
|
202
|
+
try {
|
|
203
|
+
return await listenOn(this.hostIp, port, signal);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (isAddressInUse(error))
|
|
207
|
+
continue;
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw new Error("host_port_broker_exhausted: no loopback ports are available in the configured pool.");
|
|
212
|
+
}
|
|
213
|
+
owned(reservationId, owner) {
|
|
214
|
+
const held = this.reservations.get(reservationId);
|
|
215
|
+
if (!held)
|
|
216
|
+
return null;
|
|
217
|
+
return sameOwner(held.reservation, owner) ? held : null;
|
|
218
|
+
}
|
|
219
|
+
async withLaunchGate(action, signal) {
|
|
220
|
+
const predecessor = this.launchGate;
|
|
221
|
+
let release = () => undefined;
|
|
222
|
+
const turn = new Promise(resolve => { release = resolve; });
|
|
223
|
+
this.launchGate = predecessor.then(() => turn, () => turn);
|
|
224
|
+
try {
|
|
225
|
+
await waitForTurn(predecessor, signal);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
void predecessor.finally(release);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return await action();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
release();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function listenOn(host, port, signal) {
|
|
240
|
+
if (signal?.aborted)
|
|
241
|
+
return Promise.reject(cancellationError(signal));
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
const server = net.createServer(socket => socket.destroy());
|
|
244
|
+
const abort = () => {
|
|
245
|
+
server.close();
|
|
246
|
+
reject(cancellationError(signal));
|
|
247
|
+
};
|
|
248
|
+
const error = (cause) => {
|
|
249
|
+
signal?.removeEventListener("abort", abort);
|
|
250
|
+
reject(cause);
|
|
251
|
+
};
|
|
252
|
+
server.once("error", error);
|
|
253
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
254
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
255
|
+
signal?.removeEventListener("abort", abort);
|
|
256
|
+
server.removeListener("error", error);
|
|
257
|
+
const address = server.address();
|
|
258
|
+
if (!address || typeof address === "string") {
|
|
259
|
+
void closeServer(server).then(() => reject(new Error("host_port_broker_invalid: listener address was unavailable.")));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
resolve({ server, port: address.port });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function closeServer(server) {
|
|
267
|
+
if (!server?.listening)
|
|
268
|
+
return Promise.resolve();
|
|
269
|
+
return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
270
|
+
}
|
|
271
|
+
function validateOwner(owner) {
|
|
272
|
+
if (![owner.dispatchAttemptId, owner.executionLeaseId, owner.fencingToken].every(isUuid)
|
|
273
|
+
|| !boundedText(owner.processSupervisorId, 255)) {
|
|
274
|
+
throw new Error("host_port_broker_invalid: owner identity or fence is invalid.");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function validateRequest(request) {
|
|
278
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/u.test(request.name)
|
|
279
|
+
|| (request.protocol !== undefined && request.protocol !== "tcp")) {
|
|
280
|
+
throw new Error("host_port_broker_invalid: only named TCP loopback reservations are supported.");
|
|
281
|
+
}
|
|
282
|
+
if (request.targetPort !== undefined)
|
|
283
|
+
validPort(request.targetPort, "targetPort");
|
|
284
|
+
}
|
|
285
|
+
function validPort(value, label) {
|
|
286
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65535) {
|
|
287
|
+
throw new Error(`host_port_broker_invalid: ${label} must be a valid TCP port.`);
|
|
288
|
+
}
|
|
289
|
+
return Number(value);
|
|
290
|
+
}
|
|
291
|
+
function sameOwner(left, right) {
|
|
292
|
+
return left.dispatchAttemptId === right.dispatchAttemptId
|
|
293
|
+
&& left.executionLeaseId === right.executionLeaseId
|
|
294
|
+
&& left.fencingToken === right.fencingToken
|
|
295
|
+
&& left.processSupervisorId === right.processSupervisorId;
|
|
296
|
+
}
|
|
297
|
+
function isAddressInUse(error) {
|
|
298
|
+
return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
299
|
+
}
|
|
300
|
+
function isUuid(value) {
|
|
301
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value);
|
|
302
|
+
}
|
|
303
|
+
function boundedText(value, maximumLength) {
|
|
304
|
+
return typeof value === "string"
|
|
305
|
+
&& value.length > 0
|
|
306
|
+
&& value.length <= maximumLength
|
|
307
|
+
&& Array.from(value).every(character => {
|
|
308
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
309
|
+
return codePoint > 31 && codePoint !== 127;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function cancellationError(signal) {
|
|
313
|
+
return new Error(String(signal?.reason ?? "Host port reservation cancelled."));
|
|
314
|
+
}
|
|
315
|
+
function waitForTurn(predecessor, signal) {
|
|
316
|
+
if (!signal)
|
|
317
|
+
return predecessor;
|
|
318
|
+
if (signal.aborted)
|
|
319
|
+
return Promise.reject(cancellationError(signal));
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
const abort = () => {
|
|
322
|
+
reject(cancellationError(signal));
|
|
323
|
+
};
|
|
324
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
325
|
+
void predecessor.then(() => {
|
|
326
|
+
signal.removeEventListener("abort", abort);
|
|
327
|
+
resolve();
|
|
328
|
+
}, error => {
|
|
329
|
+
signal.removeEventListener("abort", abort);
|
|
330
|
+
reject(error);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const hostRuntimeContractPath = ".forge/forge.runtime.yml";
|
|
2
|
+
export type RequestedHostConcurrency = "serial" | "parallel" | "disabled";
|
|
3
|
+
export type HostRuntimePortAdapter = "node" | "vite" | "next" | "aspnetcore";
|
|
4
|
+
export interface HostRuntimeLaunchConfig {
|
|
5
|
+
readonly command: string;
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
readonly workingDirectory: string;
|
|
8
|
+
}
|
|
9
|
+
export interface HostRuntimePortConfig {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly adapter: HostRuntimePortAdapter | null;
|
|
12
|
+
readonly launch: HostRuntimeLaunchConfig | null;
|
|
13
|
+
readonly env: string;
|
|
14
|
+
readonly baseUrlEnv: string | null;
|
|
15
|
+
readonly format: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface HostRuntimePlaywrightConfig {
|
|
18
|
+
readonly config: string;
|
|
19
|
+
readonly baseUrlPort: string;
|
|
20
|
+
readonly workers: number;
|
|
21
|
+
}
|
|
22
|
+
export interface HostRuntimeContract {
|
|
23
|
+
readonly schemaVersion: 1;
|
|
24
|
+
readonly runtime: {
|
|
25
|
+
readonly hostConcurrency: RequestedHostConcurrency;
|
|
26
|
+
readonly ports: readonly HostRuntimePortConfig[];
|
|
27
|
+
readonly playwright: HostRuntimePlaywrightConfig | null;
|
|
28
|
+
readonly browserProfile: "isolated";
|
|
29
|
+
readonly singletonResources: readonly string[];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface LoadedHostRuntimeContract {
|
|
33
|
+
readonly contract: HostRuntimeContract;
|
|
34
|
+
readonly source: "default" | "project";
|
|
35
|
+
}
|
|
36
|
+
export declare function loadHostRuntimeContract(worktreePath: string): Promise<LoadedHostRuntimeContract>;
|
|
37
|
+
export declare function defaultHostRuntimeContract(): HostRuntimeContract;
|
|
38
|
+
export declare function validateHostRuntimeContract(source: string): HostRuntimeContract;
|
|
39
|
+
export declare function hostPortEnvironment(config: HostRuntimePortConfig, hostIp: "127.0.0.1", hostPort: number): Readonly<Record<string, string>>;
|