@loucompanion/forge-bridge 0.1.3-dev.ba7d36726eda → 0.1.3-dev.bca7d0ccee53
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/dist/bridge/bridge.base.d.ts +2 -1
- package/dist/bridge/bridge.service.d.ts +8 -0
- package/dist/bridge/bridge.service.js +184 -12
- package/dist/bridge/bridge.types.d.ts +37 -0
- package/dist/cli/cli.help.js +1 -1
- package/dist/cli/cli.service.js +1 -1
- package/dist/cli/local-config.js +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +3 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +62 -11
- package/dist/runtime/attempt-process-supervisor-registry.js +1 -1
- package/dist/runtime/attempt-process-supervisor.d.ts +12 -0
- package/dist/runtime/attempt-process-supervisor.js +75 -15
- 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 +16 -0
- package/dist/runtime/host-listener-inspector.js +143 -4
- package/dist/runtime/host-runtime-contract.d.ts +9 -1
- package/dist/runtime/host-runtime-contract.js +411 -6
- package/dist/runtime/host-runtime-lease.d.ts +12 -1
- package/dist/runtime/host-runtime-lease.js +271 -50
- package/dist/runtime/host-runtime-promotion.d.ts +2 -0
- package/dist/runtime/host-runtime-promotion.js +35 -4
- package/dist/runtime/node-host-runtime-adapter.d.ts +7 -0
- package/dist/runtime/node-host-runtime-adapter.js +71 -8
- package/dist/runtime/playwright-host-runtime-adapter.js +84 -0
- package/dist/sandbox/preflight.d.ts +9 -0
- package/dist/sandbox/preflight.js +39 -4
- package/dist/sandbox/workspace.service.js +7 -2
- package/dist/session/managed-attempt-tools.d.ts +17 -0
- package/dist/session/managed-attempt-tools.js +712 -0
- package/dist/session/phase-completion.artifacts.js +9 -2
- package/dist/session/phase-completion.d.ts +11 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +45 -9
- 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 +21 -6
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +183 -8
- 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 +144 -18
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.process.js +176 -18
- package/dist/session/provider-cli/provider-cli.prompts.js +30 -8
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +19 -2
- package/dist/session/provider-cli/provider-cli.service.js +390 -83
- package/dist/session/provider-cli/provider-cli.types.d.ts +3 -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.base.d.ts +2 -1
- package/dist/session/session.service.d.ts +2 -1
- package/dist/session/session.service.js +303 -30
- package/dist/session/session.types.d.ts +38 -0
- package/dist/session/session.types.js +17 -0
- package/dist/shared/process/host-process-supervisor.d.ts +18 -1
- package/dist/shared/process/host-process-supervisor.js +94 -7
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- 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/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +12 -1
- package/package.json +1 -1
- package/runtime/windows-job-supervisor.ps1 +89 -20
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const terminalChildStatuses = new Set(["exited", "failed"]);
|
|
5
|
+
const defaultMetadataReadyTimeoutMs = process.platform === "win32" ? 60_000 : 10_000;
|
|
5
6
|
/**
|
|
6
7
|
* Owns the independently supervised process trees that belong to one host
|
|
7
8
|
* dispatch attempt. Each named child gets a distinct Windows Job Object or
|
|
@@ -23,7 +24,7 @@ export class AttemptProcessSupervisor {
|
|
|
23
24
|
constructor(identity, runner, options = {}) {
|
|
24
25
|
this.runner = runner;
|
|
25
26
|
this.identity = validateIdentity(identity);
|
|
26
|
-
this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ??
|
|
27
|
+
this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ?? defaultMetadataReadyTimeoutMs, 100, 120_000, "metadataReadyTimeoutMs");
|
|
27
28
|
this.ledgerPath = path.join(this.identity.logDirectory, "attempt-process-supervisor.json");
|
|
28
29
|
if (options.signal?.aborted) {
|
|
29
30
|
this.status = "stopping";
|
|
@@ -111,6 +112,36 @@ export class AttemptProcessSupervisor {
|
|
|
111
112
|
stop: async (fencingToken) => await this.stopChild(name, generation, fencingToken),
|
|
112
113
|
};
|
|
113
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Starts a child and proves that the OS process tree is supervised before
|
|
117
|
+
* returning it to the caller. A launch that settles before producing its
|
|
118
|
+
* provider-ready supervision record is safe to retry: the raw runner only
|
|
119
|
+
* persists that record after the actual provider child has started, before
|
|
120
|
+
* Forge announces the provider as active.
|
|
121
|
+
*/
|
|
122
|
+
async startChildReady(name, request, handlers = {}, signal, options = {}) {
|
|
123
|
+
const maxStartAttempts = boundedInteger(options.maxStartAttempts ?? 1, 1, 5, "maxStartAttempts");
|
|
124
|
+
const retryDelayMs = boundedInteger(options.retryDelayMs ?? 100, 0, 5_000, "retryDelayMs");
|
|
125
|
+
let lastError;
|
|
126
|
+
for (let attempt = 1; attempt <= maxStartAttempts; attempt += 1) {
|
|
127
|
+
const child = await this.startChild(name, request, handlers, signal);
|
|
128
|
+
try {
|
|
129
|
+
await child.ready;
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
lastError = error;
|
|
134
|
+
await child.result.catch(() => undefined);
|
|
135
|
+
if (!isRetryableStartFailure(error) || attempt === maxStartAttempts)
|
|
136
|
+
throw error;
|
|
137
|
+
if (signal?.aborted)
|
|
138
|
+
throw cancellationError(signal);
|
|
139
|
+
if (retryDelayMs > 0)
|
|
140
|
+
await wait(retryDelayMs * attempt);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw lastError;
|
|
144
|
+
}
|
|
114
145
|
async stopChild(name, generation, fencingToken, reason = "Forge stopped the supervised child.") {
|
|
115
146
|
this.assertFence(fencingToken);
|
|
116
147
|
const record = this.currentByName.get(name);
|
|
@@ -126,16 +157,19 @@ export class AttemptProcessSupervisor {
|
|
|
126
157
|
}
|
|
127
158
|
async stopAll(fencingToken, reason = "Forge stopped the supervised attempt.") {
|
|
128
159
|
this.assertFence(fencingToken);
|
|
160
|
+
// The durable terminal ledger is part of the stop operation's contract.
|
|
161
|
+
// Join an in-flight stop before inspecting terminal state so lease cleanup
|
|
162
|
+
// cannot remove the log directory underneath its final persistence.
|
|
163
|
+
if (this.stopOperation)
|
|
164
|
+
return await this.stopOperation;
|
|
129
165
|
if (this.status === "stopped")
|
|
130
166
|
return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
138
|
-
}
|
|
167
|
+
const operation = this.performStopAll(reason);
|
|
168
|
+
this.stopOperation = operation;
|
|
169
|
+
void operation.catch(() => undefined).finally(() => {
|
|
170
|
+
if (this.stopOperation === operation && this.status !== "stopped")
|
|
171
|
+
this.stopOperation = null;
|
|
172
|
+
});
|
|
139
173
|
return await this.stopOperation;
|
|
140
174
|
}
|
|
141
175
|
async performStopAll(reason) {
|
|
@@ -147,8 +181,13 @@ export class AttemptProcessSupervisor {
|
|
|
147
181
|
await this.persist();
|
|
148
182
|
const results = await Promise.allSettled(active.map(record => record.result));
|
|
149
183
|
const rejected = results.filter((result) => result.status === "rejected");
|
|
150
|
-
|
|
151
|
-
await this.persist(
|
|
184
|
+
const terminalStatus = rejected.length > 0 || this.persistenceFailure !== undefined ? "degraded" : "stopped";
|
|
185
|
+
await this.persist({
|
|
186
|
+
...this.snapshot(),
|
|
187
|
+
status: terminalStatus,
|
|
188
|
+
updatedAt: new Date().toISOString(),
|
|
189
|
+
});
|
|
190
|
+
this.status = terminalStatus;
|
|
152
191
|
if (rejected.length > 0) {
|
|
153
192
|
throw new AggregateError(rejected.map(result => result.reason), "attempt_process_supervisor_cleanup_degraded");
|
|
154
193
|
}
|
|
@@ -188,12 +227,14 @@ export class AttemptProcessSupervisor {
|
|
|
188
227
|
return;
|
|
189
228
|
}
|
|
190
229
|
if (terminalChildStatuses.has(record.status)) {
|
|
191
|
-
|
|
230
|
+
const detail = await childStartFailureDetail(record);
|
|
231
|
+
throw new Error(`attempt_process_supervisor_start_failed: child '${record.childId}' exited before supervision metadata was produced${detail ? `: ${detail}` : "."}`);
|
|
192
232
|
}
|
|
193
233
|
await wait(25);
|
|
194
234
|
}
|
|
195
235
|
record.controller.abort("Supervised child metadata was not produced before the readiness deadline.");
|
|
196
|
-
|
|
236
|
+
const detail = await childStartFailureDetail(record);
|
|
237
|
+
throw new Error(`attempt_process_supervisor_start_timeout: child '${record.childId}' did not become supervised${detail ? `: ${detail}` : "."}`);
|
|
197
238
|
}
|
|
198
239
|
async settleChild(record, status, exitCode, failure) {
|
|
199
240
|
record.status = status;
|
|
@@ -210,8 +251,7 @@ export class AttemptProcessSupervisor {
|
|
|
210
251
|
throw new Error("attempt_process_supervisor_fenced: fencing token does not own this attempt.");
|
|
211
252
|
}
|
|
212
253
|
}
|
|
213
|
-
persist() {
|
|
214
|
-
const snapshot = this.snapshot();
|
|
254
|
+
persist(snapshot = this.snapshot()) {
|
|
215
255
|
const operation = this.persistTail.then(async () => {
|
|
216
256
|
await ensurePrivateDirectory(this.identity.logDirectory);
|
|
217
257
|
const temporary = `${this.ledgerPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
@@ -274,6 +314,26 @@ function boundedFailure(error) {
|
|
|
274
314
|
const message = error instanceof Error ? error.message : String(error);
|
|
275
315
|
return message.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 512);
|
|
276
316
|
}
|
|
317
|
+
async function childStartFailureDetail(record) {
|
|
318
|
+
try {
|
|
319
|
+
const result = await record.result;
|
|
320
|
+
const stderr = boundedDiagnostic(result.stderr);
|
|
321
|
+
return stderr
|
|
322
|
+
? `exit ${result.exitCode}; stderr: ${stderr}`
|
|
323
|
+
: `exit ${result.exitCode}`;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
return boundedFailure(error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function boundedDiagnostic(value) {
|
|
330
|
+
return value.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
331
|
+
}
|
|
332
|
+
function isRetryableStartFailure(error) {
|
|
333
|
+
return error instanceof Error
|
|
334
|
+
&& (error.message.startsWith("attempt_process_supervisor_start_failed:")
|
|
335
|
+
|| error.message.startsWith("attempt_process_supervisor_start_timeout:"));
|
|
336
|
+
}
|
|
277
337
|
function cancellationError(signal) {
|
|
278
338
|
return new Error(String(signal.reason ?? "Supervised child launch was cancelled."));
|
|
279
339
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface GeneratedOutputResetResult {
|
|
2
|
+
readonly quarantineDirectory: string;
|
|
3
|
+
readonly quarantinedPaths: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Moves only known, untracked test-output directories out of a worktree before
|
|
7
|
+
* a managed attempt starts. The quarantine lives in the attempt-owned output
|
|
8
|
+
* directory, so cleanup is recoverable and cannot cross the attempt boundary.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
|
|
@@ -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
|
+
}
|
|
@@ -16,9 +16,25 @@ export declare class HostListenerInspector {
|
|
|
16
16
|
private readonly platform;
|
|
17
17
|
constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
|
|
18
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[]>;
|
|
19
21
|
private inspectWindows;
|
|
22
|
+
private inspectAllWindows;
|
|
20
23
|
private inspectUnix;
|
|
24
|
+
private inspectAllUnix;
|
|
21
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
|
+
}[];
|
|
22
38
|
export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
|
|
23
39
|
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
24
40
|
}
|
|
@@ -25,6 +25,16 @@ export class HostListenerInspector {
|
|
|
25
25
|
}
|
|
26
26
|
return await this.inspectUnix(metadata.rootPid, reservation.hostPort);
|
|
27
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
|
+
}
|
|
28
38
|
async inspectWindows(rootPid, port) {
|
|
29
39
|
const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
|
|
30
40
|
if (netstat.exitCode !== 0) {
|
|
@@ -59,6 +69,43 @@ export class HostListenerInspector {
|
|
|
59
69
|
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
60
70
|
return values.length === listenerPids.length && values.every(value => value === true) ? "owned" : "foreign";
|
|
61
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
|
+
}
|
|
62
109
|
async inspectUnix(rootPid, port) {
|
|
63
110
|
let listenerPids = null;
|
|
64
111
|
const lsof = await this.commands.run("lsof", [
|
|
@@ -93,13 +140,84 @@ export class HostListenerInspector {
|
|
|
93
140
|
const groups = parseProcessGroups(processGroups.stdout);
|
|
94
141
|
return listenerPids.every(pid => groups.get(pid) === rootPid) ? "owned" : "foreign";
|
|
95
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
|
+
}));
|
|
96
211
|
}
|
|
97
212
|
export class ExecFileInspectionCommandRunner {
|
|
98
213
|
run(command, args) {
|
|
99
214
|
return new Promise(resolve => {
|
|
100
|
-
|
|
215
|
+
const executable = process.platform === "win32" && command.toLowerCase() === "netstat.exe"
|
|
216
|
+
? windowsSystemExecutable("netstat.exe")
|
|
217
|
+
: command;
|
|
218
|
+
execFile(executable, args, {
|
|
101
219
|
windowsHide: true,
|
|
102
|
-
timeout:
|
|
220
|
+
timeout: 15_000,
|
|
103
221
|
maxBuffer: 4 * 1024 * 1024,
|
|
104
222
|
}, (error, stdout, stderr) => {
|
|
105
223
|
const processError = error;
|
|
@@ -175,11 +293,32 @@ function validPid(value) {
|
|
|
175
293
|
function uniqueSorted(values) {
|
|
176
294
|
return [...new Set(values)].sort((left, right) => left - right);
|
|
177
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
|
+
}
|
|
178
313
|
function commandError(label, result) {
|
|
179
314
|
const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
180
|
-
|
|
315
|
+
const code = result.errorCode ? ` (${result.errorCode})` : "";
|
|
316
|
+
return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${code}${detail ? `: ${detail}` : ""}.`);
|
|
181
317
|
}
|
|
182
318
|
function windowsPowerShellPath() {
|
|
319
|
+
return path.win32.join(path.dirname(windowsSystemExecutable("netstat.exe")), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
320
|
+
}
|
|
321
|
+
function windowsSystemExecutable(name) {
|
|
183
322
|
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
|
|
184
|
-
return path.win32.join(systemRoot, "System32",
|
|
323
|
+
return path.win32.join(systemRoot, "System32", name);
|
|
185
324
|
}
|
|
@@ -5,6 +5,7 @@ export interface HostRuntimeLaunchConfig {
|
|
|
5
5
|
readonly command: string;
|
|
6
6
|
readonly args: readonly string[];
|
|
7
7
|
readonly workingDirectory: string;
|
|
8
|
+
readonly environment?: Readonly<Record<string, string>>;
|
|
8
9
|
}
|
|
9
10
|
export interface HostRuntimePortConfig {
|
|
10
11
|
readonly name: string;
|
|
@@ -31,7 +32,14 @@ export interface HostRuntimeContract {
|
|
|
31
32
|
}
|
|
32
33
|
export interface LoadedHostRuntimeContract {
|
|
33
34
|
readonly contract: HostRuntimeContract;
|
|
34
|
-
readonly source: "default" | "project";
|
|
35
|
+
readonly source: "default" | "project" | "derived";
|
|
36
|
+
readonly discovery?: HostRuntimeDiscovery | null;
|
|
37
|
+
}
|
|
38
|
+
export interface HostRuntimeDiscovery {
|
|
39
|
+
readonly status: "derived" | "ambiguous" | "unsupported";
|
|
40
|
+
readonly reasonCode: string;
|
|
41
|
+
readonly summary: string;
|
|
42
|
+
readonly candidateFingerprint?: string | null;
|
|
35
43
|
}
|
|
36
44
|
export declare function loadHostRuntimeContract(worktreePath: string): Promise<LoadedHostRuntimeContract>;
|
|
37
45
|
export declare function defaultHostRuntimeContract(): HostRuntimeContract;
|