@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.bfa11ebe98c6
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 +165 -1
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +9 -1
- package/dist/bridge/bridge.service.d.ts +129 -3
- package/dist/bridge/bridge.service.js +2426 -70
- package/dist/bridge/bridge.types.d.ts +218 -2
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -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.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +55 -7
- package/dist/cli/cli.service.js +429 -219
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -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 +97 -0
- package/dist/runtime/attempt-process-supervisor.js +397 -0
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +20 -0
- package/dist/runtime/generated-output-reset.js +157 -0
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +191 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +51 -0
- package/dist/runtime/host-listener-inspector.js +385 -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 +58 -0
- package/dist/runtime/host-runtime-contract.js +754 -0
- package/dist/runtime/host-runtime-lease.d.ts +215 -0
- package/dist/runtime/host-runtime-lease.js +2449 -0
- package/dist/runtime/host-runtime-promotion.d.ts +32 -0
- package/dist/runtime/host-runtime-promotion.js +206 -0
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +285 -0
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +161 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -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 +84 -0
- package/dist/sandbox/preflight.js +521 -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 +6 -0
- package/dist/sandbox/repository-lock.js +128 -0
- package/dist/sandbox/runtime.d.ts +302 -0
- package/dist/sandbox/runtime.js +2367 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -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 +74 -0
- package/dist/session/managed-attempt-tools.d.ts +28 -0
- package/dist/session/managed-attempt-tools.js +1011 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +27 -0
- package/dist/session/phase-completion.artifacts.js +829 -0
- package/dist/session/phase-completion.d.ts +53 -42
- package/dist/session/phase-completion.helpers.d.ts +117 -0
- package/dist/session/phase-completion.helpers.js +707 -0
- package/dist/session/phase-completion.js +125 -3007
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +233 -0
- package/dist/session/phase-completion.runtime.d.ts +49 -0
- package/dist/session/phase-completion.runtime.js +833 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +859 -0
- package/dist/session/phase-completion.validation.d.ts +38 -0
- package/dist/session/phase-completion.validation.js +918 -0
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +657 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
- package/dist/session/provider-cli/provider-cli.process.js +874 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +78 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
- package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
- package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
- package/dist/session/provider-cli/provider-output-state.js +817 -0
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +448 -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 +586 -59
- package/dist/session/session.types.d.ts +324 -3
- package/dist/session/session.types.js +939 -10
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- 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 +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +3 -0
- package/dist/shared/process/process.js +13 -7
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +4 -0
- package/dist/shared/provider-capabilities.js +88 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +47 -4
- 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 +82 -10
- package/dist/worktree/dependency-warmer.d.ts +53 -1
- package/dist/worktree/dependency-warmer.js +619 -11
- package/dist/worktree/index.d.ts +2 -1
- 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/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -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 +16 -3
- package/dist/worktree/worktree.service.js +334 -54
- package/dist/worktree/worktree.types.d.ts +9 -1
- package/package.json +12 -7
- package/runtime/windows-job-supervisor.ps1 +722 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readCanonicalSourceFile } from "./canonical-source-file.js";
|
|
5
|
+
import { assertCanonicalDirectory } from "./attempt-private-directory.js";
|
|
6
|
+
import { runAttemptArchiveOperation, runAttemptPathOperation } from "./attempt-path-operation.js";
|
|
7
|
+
import { readPinnedSourceDirectory } from "./pinned-source-directory.js";
|
|
8
|
+
import { compactHostAttemptId } from "./host-execution-root.js";
|
|
9
|
+
const maximumEvidenceBytes = 200 * 1024 * 1024;
|
|
10
|
+
const diagnosticsRetentionDeadlineMs = 30_000;
|
|
11
|
+
const diagnosticsHelperTerminationConfirmationMs = 2_000;
|
|
12
|
+
export class HostDiagnosticsBudgetError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(`host_diagnostics_budget: ${message}`);
|
|
15
|
+
this.name = "HostDiagnosticsBudgetError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Runner-local evidence, never web-served; browser-profile, cache and temporary directories are excluded. */
|
|
19
|
+
export async function retainHostDiagnostics(stateRoot, attemptRoot, attemptId, expectedAttemptRootIdentity, attemptOwnerRoot = stateRoot, options = {}) {
|
|
20
|
+
const requestedDeadlineMs = options.deadlineMs;
|
|
21
|
+
const configuredDeadlineMs = Math.max(1, Math.min(diagnosticsRetentionDeadlineMs, Number.isFinite(requestedDeadlineMs) && (requestedDeadlineMs ?? 0) > 0
|
|
22
|
+
? Math.floor(requestedDeadlineMs)
|
|
23
|
+
: diagnosticsRetentionDeadlineMs));
|
|
24
|
+
const retentionDeadline = Date.now() + configuredDeadlineMs;
|
|
25
|
+
const identity = attemptId.replaceAll("-", "").toLowerCase();
|
|
26
|
+
const compactAttemptRoot = path.resolve(attemptOwnerRoot, "a", compactHostAttemptId(attemptId));
|
|
27
|
+
const legacyAttemptRoot = path.resolve(stateRoot, "host-attempts", identity);
|
|
28
|
+
if (!/^[0-9a-f]{32}$/u.test(identity)
|
|
29
|
+
|| (path.resolve(attemptRoot) !== compactAttemptRoot
|
|
30
|
+
&& path.resolve(attemptRoot) !== legacyAttemptRoot)) {
|
|
31
|
+
throw new Error("host_diagnostics_invalid_owner: refusing an unowned attempt path.");
|
|
32
|
+
}
|
|
33
|
+
const attemptStat = await withinRetentionDeadline(async () => await fs.lstat(attemptRoot).catch(error => {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}), retentionDeadline, configuredDeadlineMs);
|
|
38
|
+
if (attemptStat) {
|
|
39
|
+
if (!expectedAttemptRootIdentity) {
|
|
40
|
+
throw new Error("host_diagnostics_invalid_owner: original attempt-root identity is unavailable.");
|
|
41
|
+
}
|
|
42
|
+
await withinRetentionDeadline(() => assertHostAttemptRootIdentity(attemptRoot, expectedAttemptRootIdentity), retentionDeadline, configuredDeadlineMs);
|
|
43
|
+
}
|
|
44
|
+
const target = path.join(stateRoot, "diagnostics", "host", identity);
|
|
45
|
+
const manifestPath = path.join(target, "manifest.json");
|
|
46
|
+
await withinRetentionDeadline(() => assertCanonicalDirectory(stateRoot), retentionDeadline, configuredDeadlineMs);
|
|
47
|
+
await createArchiveDirectory(target, retentionDeadline, configuredDeadlineMs);
|
|
48
|
+
const priorManifest = await withinRetentionDeadline(async () => await fs.lstat(manifestPath).catch(error => {
|
|
49
|
+
if (error.code === "ENOENT")
|
|
50
|
+
return null;
|
|
51
|
+
throw error;
|
|
52
|
+
}), retentionDeadline, configuredDeadlineMs);
|
|
53
|
+
if (priorManifest && (!priorManifest.isFile() || priorManifest.isSymbolicLink()))
|
|
54
|
+
throw new Error("host_diagnostics_unsafe_path: manifest is not an ordinary file.");
|
|
55
|
+
if (priorManifest && !attemptStat) {
|
|
56
|
+
const saved = JSON.parse((await withinRetentionDeadline(() => readCanonicalSourceFile(manifestPath, maximumEvidenceBytes), retentionDeadline, configuredDeadlineMs)).toString("utf8"));
|
|
57
|
+
if (saved.attemptId !== attemptId)
|
|
58
|
+
throw new Error("host_diagnostics_invalid_owner: archive identity mismatch.");
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
const files = [];
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
let entries = 0;
|
|
64
|
+
let excludedGeneratedFiles = 0;
|
|
65
|
+
let excludedGeneratedBytes = 0n;
|
|
66
|
+
const readDirectory = async (directory, identity) => {
|
|
67
|
+
const timing = remainingHelperTiming(retentionDeadline, configuredDeadlineMs);
|
|
68
|
+
return await runWithinRetentionHelperDeadline(() => readPinnedSourceDirectory(directory, identity, timing.operationTimeoutMs, timing.terminationConfirmationMs), retentionDeadline, configuredDeadlineMs);
|
|
69
|
+
};
|
|
70
|
+
const walk = async (relative, entry) => {
|
|
71
|
+
if (++entries > 10000)
|
|
72
|
+
throw new HostDiagnosticsBudgetError("evidence inventory exceeds retention budget.");
|
|
73
|
+
const source = path.join(attemptRoot, relative);
|
|
74
|
+
if (entry.kind === "symlink")
|
|
75
|
+
throw new Error("host_diagnostics_unsafe_path: evidence contains a symlink.");
|
|
76
|
+
if (entry.kind === "directory") {
|
|
77
|
+
await createArchiveDirectory(path.join(target, relative), retentionDeadline, configuredDeadlineMs);
|
|
78
|
+
for (const child of await readDirectory(source, entry.identity)) {
|
|
79
|
+
await walk(path.join(relative, child.name), child);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (entry.kind !== "file")
|
|
84
|
+
throw new Error("host_diagnostics_unsafe_path: evidence is not an ordinary file.");
|
|
85
|
+
// .NET test result directories contain copied assemblies and symbols, often
|
|
86
|
+
// many times over. They are reproducible build products, not diagnostics.
|
|
87
|
+
if (relative.startsWith(`test-output${path.sep}`) && /\.(?:dll|pdb)$/iu.test(relative)) {
|
|
88
|
+
excludedGeneratedFiles += 1;
|
|
89
|
+
excludedGeneratedBytes += entry.size;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (files.length >= 5000 || entry.size > BigInt(maximumEvidenceBytes - bytes))
|
|
93
|
+
throw new HostDiagnosticsBudgetError("evidence exceeds retention budget; original attempt retained for recovery.");
|
|
94
|
+
const destination = path.join(target, relative);
|
|
95
|
+
const contents = await withinRetentionDeadline(() => readCanonicalSourceFile(source, maximumEvidenceBytes - bytes, entry.identity), retentionDeadline, configuredDeadlineMs);
|
|
96
|
+
bytes += contents.length;
|
|
97
|
+
const sha256 = createHash("sha256").update(contents).digest("hex");
|
|
98
|
+
remainingRetentionMs(retentionDeadline, configuredDeadlineMs);
|
|
99
|
+
await writeArchiveFile(destination, contents, sha256, retentionDeadline, configuredDeadlineMs);
|
|
100
|
+
files.push({ path: relative, bytes: contents.length, sha256 });
|
|
101
|
+
};
|
|
102
|
+
if (attemptStat) {
|
|
103
|
+
await withinRetentionDeadline(() => assertHostAttemptRootIdentity(attemptRoot, expectedAttemptRootIdentity), retentionDeadline, configuredDeadlineMs);
|
|
104
|
+
const rootEntries = await readDirectory(attemptRoot, expectedAttemptRootIdentity);
|
|
105
|
+
for (const directory of ["logs", "test-output"]) {
|
|
106
|
+
const entry = rootEntries.find(candidate => candidate.name === directory);
|
|
107
|
+
if (entry)
|
|
108
|
+
await walk(directory, entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Only owned relative evidence paths and hashes go into the public-facing manifest.
|
|
112
|
+
const manifest = Buffer.from(JSON.stringify({
|
|
113
|
+
schemaVersion: 1,
|
|
114
|
+
attemptId,
|
|
115
|
+
retainedAt: new Date().toISOString(),
|
|
116
|
+
files,
|
|
117
|
+
excludedGeneratedFiles,
|
|
118
|
+
excludedGeneratedBytes: excludedGeneratedBytes.toString(),
|
|
119
|
+
}, null, 2));
|
|
120
|
+
await writeArchiveFile(manifestPath, manifest, createHash("sha256").update(manifest).digest("hex"), retentionDeadline, configuredDeadlineMs);
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
123
|
+
export async function assertHostAttemptRootIdentity(attemptRoot, expected) {
|
|
124
|
+
const current = await assertCanonicalDirectory(attemptRoot);
|
|
125
|
+
if (!current || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
126
|
+
throw new Error("host_diagnostics_invalid_owner: attempt-root identity changed.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function writeArchiveFile(target, bytes, sha256, retentionDeadline, configuredDeadlineMs) {
|
|
130
|
+
const parent = path.dirname(path.resolve(target));
|
|
131
|
+
const identity = (await withinRetentionDeadline(() => assertCanonicalDirectory(parent), retentionDeadline, configuredDeadlineMs));
|
|
132
|
+
const timing = remainingHelperTiming(retentionDeadline, configuredDeadlineMs);
|
|
133
|
+
await runWithinRetentionHelperDeadline(() => runAttemptArchiveOperation(parent, identity, { name: path.basename(target), bytes, sha256 }, timing.operationTimeoutMs, timing.terminationConfirmationMs), retentionDeadline, configuredDeadlineMs);
|
|
134
|
+
}
|
|
135
|
+
async function createArchiveDirectory(target, retentionDeadline, configuredDeadlineMs) {
|
|
136
|
+
const resolved = path.resolve(target);
|
|
137
|
+
if (await withinRetentionDeadline(() => assertCanonicalDirectory(resolved, true), retentionDeadline, configuredDeadlineMs))
|
|
138
|
+
return;
|
|
139
|
+
let parent = path.dirname(resolved);
|
|
140
|
+
let identity = await withinRetentionDeadline(() => assertCanonicalDirectory(parent, true), retentionDeadline, configuredDeadlineMs);
|
|
141
|
+
while (!identity) {
|
|
142
|
+
parent = path.dirname(parent);
|
|
143
|
+
identity = await withinRetentionDeadline(() => assertCanonicalDirectory(parent, true), retentionDeadline, configuredDeadlineMs);
|
|
144
|
+
}
|
|
145
|
+
const timing = remainingHelperTiming(retentionDeadline, configuredDeadlineMs);
|
|
146
|
+
await runWithinRetentionHelperDeadline(() => runAttemptPathOperation(parent, identity, { kind: "mkdir", segments: path.relative(parent, resolved).split(path.sep) }, timing.operationTimeoutMs, timing.terminationConfirmationMs), retentionDeadline, configuredDeadlineMs);
|
|
147
|
+
await withinRetentionDeadline(() => assertCanonicalDirectory(resolved), retentionDeadline, configuredDeadlineMs);
|
|
148
|
+
}
|
|
149
|
+
function remainingHelperTiming(retentionDeadline, configuredDeadlineMs) {
|
|
150
|
+
const remainingMs = remainingRetentionMs(retentionDeadline, configuredDeadlineMs);
|
|
151
|
+
const terminationConfirmationMs = Math.min(diagnosticsHelperTerminationConfirmationMs, Math.max(1, Math.floor(remainingMs / 4)));
|
|
152
|
+
const operationTimeoutMs = remainingMs - terminationConfirmationMs;
|
|
153
|
+
if (operationTimeoutMs <= 0)
|
|
154
|
+
throw diagnosticsDeadlineError(configuredDeadlineMs);
|
|
155
|
+
return { operationTimeoutMs, terminationConfirmationMs };
|
|
156
|
+
}
|
|
157
|
+
function remainingRetentionMs(retentionDeadline, configuredDeadlineMs) {
|
|
158
|
+
const remainingMs = retentionDeadline - Date.now();
|
|
159
|
+
if (remainingMs <= 0)
|
|
160
|
+
throw diagnosticsDeadlineError(configuredDeadlineMs);
|
|
161
|
+
return remainingMs;
|
|
162
|
+
}
|
|
163
|
+
async function withinRetentionDeadline(operation, retentionDeadline, configuredDeadlineMs) {
|
|
164
|
+
const remainingMs = remainingRetentionMs(retentionDeadline, configuredDeadlineMs);
|
|
165
|
+
let timer;
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([
|
|
168
|
+
operation(),
|
|
169
|
+
new Promise((_resolve, reject) => {
|
|
170
|
+
timer = setTimeout(() => reject(diagnosticsDeadlineError(configuredDeadlineMs)), remainingMs);
|
|
171
|
+
}),
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (timer)
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function runWithinRetentionHelperDeadline(operation, retentionDeadline, configuredDeadlineMs) {
|
|
180
|
+
try {
|
|
181
|
+
return await operation();
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (Date.now() >= retentionDeadline)
|
|
185
|
+
throw diagnosticsDeadlineError(configuredDeadlineMs);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function diagnosticsDeadlineError(configuredDeadlineMs) {
|
|
190
|
+
return new Error(`host_diagnostics_temporary_unavailable: diagnostics retention exceeded ${configuredDeadlineMs} ms.`);
|
|
191
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Keep ephemeral host-attempt trees short and per-user. */
|
|
2
|
+
export declare function defaultHostExecutionRoot(configuredRoot?: string | undefined, platform?: NodeJS.Platform, homeDirectory?: string): string;
|
|
3
|
+
export declare function assertHostExecutionRoot(executionRoot: string, platform?: NodeJS.Platform): void;
|
|
4
|
+
export declare function compactHostAttemptId(dispatchAttemptId: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const windowsPathBudget = 248;
|
|
4
|
+
/** Keep ephemeral host-attempt trees short and per-user. */
|
|
5
|
+
export function defaultHostExecutionRoot(configuredRoot = process.env.FORGE_SANDBOX_EXECUTION_ROOT, platform = process.platform, homeDirectory = os.homedir()) {
|
|
6
|
+
const platformPath = platform === "win32" ? path.win32 : path.posix;
|
|
7
|
+
const configured = configuredRoot?.trim();
|
|
8
|
+
const selected = configured
|
|
9
|
+
? platformPath.resolve(configured)
|
|
10
|
+
: platformPath.resolve(homeDirectory, ".fgx");
|
|
11
|
+
assertHostExecutionRoot(selected, platform);
|
|
12
|
+
return selected;
|
|
13
|
+
}
|
|
14
|
+
export function assertHostExecutionRoot(executionRoot, platform = process.platform) {
|
|
15
|
+
const platformPath = platform === "win32" ? path.win32 : path.posix;
|
|
16
|
+
if (!platformPath.isAbsolute(executionRoot)) {
|
|
17
|
+
throw new Error("host_execution_root_invalid: FORGE_SANDBOX_EXECUTION_ROOT must be absolute.");
|
|
18
|
+
}
|
|
19
|
+
const resolved = platformPath.resolve(executionRoot);
|
|
20
|
+
if (resolved === platformPath.parse(resolved).root) {
|
|
21
|
+
throw new Error("host_execution_root_invalid: the execution root cannot be a filesystem root.");
|
|
22
|
+
}
|
|
23
|
+
if (platform !== "win32")
|
|
24
|
+
return;
|
|
25
|
+
// Model the deepest Forge-owned test-world plus Git's own bookkeeping. This
|
|
26
|
+
// rejects unsafe layouts before an agent starts instead of failing midway.
|
|
27
|
+
const projected = path.win32.join(resolved, "a", "f".repeat(20), "t", "p-i-0", "f".repeat(32), "forge-test-worlds", "f".repeat(32), `activation-commit-${"f".repeat(40)}`, ".git", "worktrees", "f".repeat(20), "locked");
|
|
28
|
+
if (projected.length > windowsPathBudget) {
|
|
29
|
+
throw new Error(`host_execution_path_budget_exceeded: '${resolved}' exceeds the safe Windows path budget by ${projected.length - windowsPathBudget} characters. `
|
|
30
|
+
+ "Set FORGE_SANDBOX_EXECUTION_ROOT to a shorter per-user directory before starting the Bridge.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function compactHostAttemptId(dispatchAttemptId) {
|
|
34
|
+
const normalized = dispatchAttemptId.replaceAll("-", "").toLowerCase();
|
|
35
|
+
if (!/^[0-9a-f]{32}$/u.test(normalized)) {
|
|
36
|
+
throw new Error("host_execution_attempt_invalid: dispatch attempt must be a UUID.");
|
|
37
|
+
}
|
|
38
|
+
return normalized.slice(0, 20);
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
readonly timedOut?: boolean;
|
|
10
|
+
readonly signal?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface HostInspectionCommandRunner {
|
|
13
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
14
|
+
}
|
|
15
|
+
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
16
|
+
export declare class HostListenerInspector {
|
|
17
|
+
private readonly commands;
|
|
18
|
+
private readonly platform;
|
|
19
|
+
private readonly retryDelayMs;
|
|
20
|
+
constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform, retryDelayMs?: number);
|
|
21
|
+
inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
|
|
22
|
+
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
23
|
+
inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
|
|
24
|
+
/** Snapshots loopback TCP ports that predate a supervised child launch. */
|
|
25
|
+
inspectLoopbackListeners(): Promise<readonly number[]>;
|
|
26
|
+
private inspectWindows;
|
|
27
|
+
private inspectAllWindows;
|
|
28
|
+
private runWindowsAncestry;
|
|
29
|
+
private runWindowsNetstat;
|
|
30
|
+
private inspectUnix;
|
|
31
|
+
private inspectAllUnix;
|
|
32
|
+
}
|
|
33
|
+
export declare function parseWindowsLoopbackListeners(output: string): readonly {
|
|
34
|
+
pid: number;
|
|
35
|
+
port: number;
|
|
36
|
+
}[];
|
|
37
|
+
export declare function parseLsofLoopbackListeners(output: string): readonly {
|
|
38
|
+
pid: number;
|
|
39
|
+
port: number;
|
|
40
|
+
}[];
|
|
41
|
+
export declare function parseSsLoopbackListeners(output: string): readonly {
|
|
42
|
+
pid: number;
|
|
43
|
+
port: number;
|
|
44
|
+
}[];
|
|
45
|
+
export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
|
|
46
|
+
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
47
|
+
}
|
|
48
|
+
export declare function parseWindowsListenerPids(output: string, port: number): readonly number[];
|
|
49
|
+
export declare function parseLsofPids(output: string): readonly number[];
|
|
50
|
+
export declare function parseSsPids(output: string, port: number): readonly number[];
|
|
51
|
+
export declare function parseProcessGroups(output: string): ReadonlyMap<number, number>;
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
const windowsNetstatArgs = ["-ano", "-p", "TCP"];
|
|
7
|
+
const windowsNetstatAttempts = 3;
|
|
8
|
+
const windowsAncestryAttempts = 2;
|
|
9
|
+
const windowsNetstatRetryDelayMs = 250;
|
|
10
|
+
const inspectionCommandTimeoutMs = 15_000;
|
|
11
|
+
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
12
|
+
export class HostListenerInspector {
|
|
13
|
+
commands;
|
|
14
|
+
platform;
|
|
15
|
+
retryDelayMs;
|
|
16
|
+
constructor(commands = new ExecFileInspectionCommandRunner(), platform = process.platform, retryDelayMs = windowsNetstatRetryDelayMs) {
|
|
17
|
+
this.commands = commands;
|
|
18
|
+
this.platform = platform;
|
|
19
|
+
this.retryDelayMs = retryDelayMs;
|
|
20
|
+
}
|
|
21
|
+
async inspect(child, reservation, owner) {
|
|
22
|
+
if (reservation.hostIp !== "127.0.0.1" || reservation.protocol !== "tcp") {
|
|
23
|
+
throw new Error("host_listener_inspection_invalid: only TCP IPv4 loopback listeners are supported.");
|
|
24
|
+
}
|
|
25
|
+
const metadata = await loadMetadata(child.metadataPath);
|
|
26
|
+
if (!metadata)
|
|
27
|
+
return "unbound";
|
|
28
|
+
if (!matchesOwner(metadata, child, owner))
|
|
29
|
+
return "foreign";
|
|
30
|
+
if (this.platform === "win32") {
|
|
31
|
+
return await this.inspectWindows(metadata.rootPid, reservation.hostPort);
|
|
32
|
+
}
|
|
33
|
+
return await this.inspectUnix(metadata.rootPid, reservation.hostPort);
|
|
34
|
+
}
|
|
35
|
+
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
36
|
+
async inspectOwnedLoopbackListeners(child, owner) {
|
|
37
|
+
const metadata = await loadMetadata(child.metadataPath);
|
|
38
|
+
if (!metadata || !matchesOwner(metadata, child, owner)) {
|
|
39
|
+
throw new Error("host_listener_inspection_invalid: supervised provider ownership metadata is unavailable.");
|
|
40
|
+
}
|
|
41
|
+
return this.platform === "win32"
|
|
42
|
+
? await this.inspectAllWindows(metadata.rootPid)
|
|
43
|
+
: await this.inspectAllUnix(metadata.rootPid);
|
|
44
|
+
}
|
|
45
|
+
/** Snapshots loopback TCP ports that predate a supervised child launch. */
|
|
46
|
+
async inspectLoopbackListeners() {
|
|
47
|
+
if (this.platform === "win32") {
|
|
48
|
+
const netstat = await this.runWindowsNetstat();
|
|
49
|
+
return uniqueSorted(parseWindowsLoopbackListeners(netstat.stdout).map(listener => listener.port));
|
|
50
|
+
}
|
|
51
|
+
const lsof = await this.commands.run("lsof", [
|
|
52
|
+
"-nP",
|
|
53
|
+
"-a",
|
|
54
|
+
"-iTCP@127.0.0.1",
|
|
55
|
+
"-sTCP:LISTEN",
|
|
56
|
+
"-Fpn",
|
|
57
|
+
]);
|
|
58
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
59
|
+
return uniqueSorted(parseLsofLoopbackListeners(lsof.stdout).map(listener => listener.port));
|
|
60
|
+
}
|
|
61
|
+
if (lsof.errorCode !== "ENOENT")
|
|
62
|
+
throw commandError("lsof", lsof);
|
|
63
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp"]);
|
|
64
|
+
if (ss.exitCode !== 0)
|
|
65
|
+
throw commandError("ss", ss);
|
|
66
|
+
return uniqueSorted(parseSsLoopbackListeners(ss.stdout).map(listener => listener.port));
|
|
67
|
+
}
|
|
68
|
+
async inspectWindows(rootPid, port) {
|
|
69
|
+
const netstat = await this.runWindowsNetstat();
|
|
70
|
+
const listenerPids = parseWindowsListenerPids(netstat.stdout, port);
|
|
71
|
+
if (listenerPids.length === 0)
|
|
72
|
+
return "unbound";
|
|
73
|
+
const values = await this.runWindowsAncestry(rootPid, listenerPids);
|
|
74
|
+
return values.every(value => value) ? "owned" : "foreign";
|
|
75
|
+
}
|
|
76
|
+
async inspectAllWindows(rootPid) {
|
|
77
|
+
const netstat = await this.runWindowsNetstat();
|
|
78
|
+
const listeners = parseWindowsLoopbackListeners(netstat.stdout);
|
|
79
|
+
if (listeners.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
const pids = [...new Set(listeners.map(listener => listener.pid))];
|
|
82
|
+
const values = await this.runWindowsAncestry(rootPid, pids);
|
|
83
|
+
const ownedPids = new Set(pids.filter((_pid, index) => values[index] === true));
|
|
84
|
+
return uniqueSorted(listeners.filter(listener => ownedPids.has(listener.pid)).map(listener => listener.port));
|
|
85
|
+
}
|
|
86
|
+
async runWindowsAncestry(rootPid, listenerPids) {
|
|
87
|
+
let lastResult = null;
|
|
88
|
+
let lastParseError;
|
|
89
|
+
for (let attempt = 1; attempt <= windowsAncestryAttempts; attempt += 1) {
|
|
90
|
+
const result = await this.commands.run(windowsPowerShellPath(), [
|
|
91
|
+
"-NoLogo",
|
|
92
|
+
"-NoProfile",
|
|
93
|
+
"-NonInteractive",
|
|
94
|
+
"-ExecutionPolicy",
|
|
95
|
+
"Bypass",
|
|
96
|
+
"-File",
|
|
97
|
+
windowsListenerOwnerPath,
|
|
98
|
+
"-RootProcessId",
|
|
99
|
+
String(rootPid),
|
|
100
|
+
"-ListenerProcessIds",
|
|
101
|
+
listenerPids.join(","),
|
|
102
|
+
]);
|
|
103
|
+
lastResult = result;
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
if (!isTransientWindowsInspectionFailure(result) || attempt === windowsAncestryAttempts) {
|
|
106
|
+
throw commandError("Windows process ancestry", result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
112
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
113
|
+
if (values.length !== listenerPids.length || values.some(value => typeof value !== "boolean")) {
|
|
114
|
+
throw new Error("Windows ancestry output was incomplete.");
|
|
115
|
+
}
|
|
116
|
+
return values;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
lastParseError = error;
|
|
120
|
+
if (attempt === windowsAncestryAttempts) {
|
|
121
|
+
throw new Error(`host_diagnostics_temporary_unavailable: Windows process ancestry returned invalid output after ${attempt} attempts.`, { cause: error });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await wait(this.retryDelayMs);
|
|
126
|
+
}
|
|
127
|
+
if (lastResult)
|
|
128
|
+
throw commandError("Windows process ancestry", lastResult);
|
|
129
|
+
throw new Error("host_diagnostics_temporary_unavailable: Windows process ancestry produced no result.", {
|
|
130
|
+
cause: lastParseError,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async runWindowsNetstat() {
|
|
134
|
+
let lastResult = null;
|
|
135
|
+
for (let attempt = 1; attempt <= windowsNetstatAttempts; attempt += 1) {
|
|
136
|
+
const result = await this.commands.run("netstat.exe", windowsNetstatArgs);
|
|
137
|
+
if (result.exitCode === 0)
|
|
138
|
+
return result;
|
|
139
|
+
lastResult = result;
|
|
140
|
+
if (!isTransientWindowsNetstatFailure(result) || attempt === windowsNetstatAttempts)
|
|
141
|
+
break;
|
|
142
|
+
await wait(this.retryDelayMs);
|
|
143
|
+
}
|
|
144
|
+
throw commandError("netstat", lastResult);
|
|
145
|
+
}
|
|
146
|
+
async inspectUnix(rootPid, port) {
|
|
147
|
+
let listenerPids = null;
|
|
148
|
+
const lsof = await this.commands.run("lsof", [
|
|
149
|
+
"-nP",
|
|
150
|
+
"-a",
|
|
151
|
+
`-iTCP@127.0.0.1:${port}`,
|
|
152
|
+
"-sTCP:LISTEN",
|
|
153
|
+
"-Fp",
|
|
154
|
+
]);
|
|
155
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
156
|
+
listenerPids = parseLsofPids(lsof.stdout);
|
|
157
|
+
}
|
|
158
|
+
else if (lsof.errorCode !== "ENOENT") {
|
|
159
|
+
throw commandError("lsof", lsof);
|
|
160
|
+
}
|
|
161
|
+
if (listenerPids === null) {
|
|
162
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp", "sport", "=", `:${port}`]);
|
|
163
|
+
if (ss.exitCode !== 0)
|
|
164
|
+
throw commandError("ss", ss);
|
|
165
|
+
listenerPids = parseSsPids(ss.stdout, port);
|
|
166
|
+
}
|
|
167
|
+
if (listenerPids.length === 0)
|
|
168
|
+
return "unbound";
|
|
169
|
+
const processGroups = await this.commands.run("ps", [
|
|
170
|
+
"-o",
|
|
171
|
+
"pid=,pgid=",
|
|
172
|
+
"-p",
|
|
173
|
+
listenerPids.join(","),
|
|
174
|
+
]);
|
|
175
|
+
if (processGroups.exitCode !== 0)
|
|
176
|
+
throw commandError("ps", processGroups);
|
|
177
|
+
const groups = parseProcessGroups(processGroups.stdout);
|
|
178
|
+
return listenerPids.every(pid => groups.get(pid) === rootPid) ? "owned" : "foreign";
|
|
179
|
+
}
|
|
180
|
+
async inspectAllUnix(rootPid) {
|
|
181
|
+
const lsof = await this.commands.run("lsof", [
|
|
182
|
+
"-nP",
|
|
183
|
+
"-a",
|
|
184
|
+
"-iTCP@127.0.0.1",
|
|
185
|
+
"-sTCP:LISTEN",
|
|
186
|
+
"-Fpn",
|
|
187
|
+
]);
|
|
188
|
+
let listeners;
|
|
189
|
+
if (lsof.exitCode === 0 || lsof.exitCode === 1) {
|
|
190
|
+
listeners = parseLsofLoopbackListeners(lsof.stdout);
|
|
191
|
+
}
|
|
192
|
+
else if (lsof.errorCode === "ENOENT") {
|
|
193
|
+
const ss = await this.commands.run("ss", ["-H", "-ltnp"]);
|
|
194
|
+
if (ss.exitCode !== 0)
|
|
195
|
+
throw commandError("ss", ss);
|
|
196
|
+
listeners = parseSsLoopbackListeners(ss.stdout);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
throw commandError("lsof", lsof);
|
|
200
|
+
}
|
|
201
|
+
if (listeners.length === 0)
|
|
202
|
+
return [];
|
|
203
|
+
const pids = [...new Set(listeners.map(listener => listener.pid))];
|
|
204
|
+
const processGroups = await this.commands.run("ps", ["-o", "pid=,pgid=", "-p", pids.join(",")]);
|
|
205
|
+
if (processGroups.exitCode !== 0)
|
|
206
|
+
throw commandError("ps", processGroups);
|
|
207
|
+
const groups = parseProcessGroups(processGroups.stdout);
|
|
208
|
+
return uniqueSorted(listeners
|
|
209
|
+
.filter(listener => groups.get(listener.pid) === rootPid)
|
|
210
|
+
.map(listener => listener.port));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function parseWindowsLoopbackListeners(output) {
|
|
214
|
+
return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
|
|
215
|
+
const fields = line.trim().split(/\s+/u);
|
|
216
|
+
if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
|
|
217
|
+
return [];
|
|
218
|
+
const endpoint = parseIpv4LoopbackEndpoint(fields[1]);
|
|
219
|
+
const pids = validPid(fields[4]);
|
|
220
|
+
return endpoint && pids.length === 1 ? [{ pid: pids[0], port: endpoint }] : [];
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
export function parseLsofLoopbackListeners(output) {
|
|
224
|
+
const listeners = [];
|
|
225
|
+
let pid = null;
|
|
226
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
227
|
+
if (line.startsWith("p"))
|
|
228
|
+
pid = validPid(line.slice(1))[0] ?? null;
|
|
229
|
+
if (!line.startsWith("n") || pid === null)
|
|
230
|
+
continue;
|
|
231
|
+
const port = parseIpv4LoopbackEndpoint(line.slice(1));
|
|
232
|
+
if (port !== null)
|
|
233
|
+
listeners.push({ pid, port });
|
|
234
|
+
}
|
|
235
|
+
return uniqueListeners(listeners);
|
|
236
|
+
}
|
|
237
|
+
export function parseSsLoopbackListeners(output) {
|
|
238
|
+
return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
|
|
239
|
+
const endpoint = line.match(/127\.0\.0\.1:(\d+)/u)?.[1];
|
|
240
|
+
const port = endpoint ? Number.parseInt(endpoint, 10) : 0;
|
|
241
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
242
|
+
return [];
|
|
243
|
+
return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => {
|
|
244
|
+
const pid = validPid(match[1])[0];
|
|
245
|
+
return pid ? [{ pid, port }] : [];
|
|
246
|
+
});
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
export class ExecFileInspectionCommandRunner {
|
|
250
|
+
run(command, args) {
|
|
251
|
+
return new Promise(resolve => {
|
|
252
|
+
const executable = process.platform === "win32" && command.toLowerCase() === "netstat.exe"
|
|
253
|
+
? windowsSystemExecutable("netstat.exe")
|
|
254
|
+
: command;
|
|
255
|
+
execFile(executable, args, {
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
timeout: inspectionCommandTimeoutMs,
|
|
258
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
259
|
+
}, (error, stdout, stderr) => {
|
|
260
|
+
const processError = error;
|
|
261
|
+
const timedOut = Boolean(processError?.killed && processError.signal === "SIGTERM" && processError.code == null);
|
|
262
|
+
resolve({
|
|
263
|
+
exitCode: typeof processError?.code === "number" ? processError.code : error ? 1 : 0,
|
|
264
|
+
stdout: String(stdout),
|
|
265
|
+
stderr: String(stderr),
|
|
266
|
+
...(typeof processError?.code === "string" ? { errorCode: processError.code } : {}),
|
|
267
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
268
|
+
...(processError?.signal ? { signal: processError.signal } : {}),
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export function parseWindowsListenerPids(output, port) {
|
|
275
|
+
const suffix = `:${port}`;
|
|
276
|
+
const pids = output.split(/\r?\n/u).flatMap(line => {
|
|
277
|
+
const fields = line.trim().split(/\s+/u);
|
|
278
|
+
if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
|
|
279
|
+
return [];
|
|
280
|
+
if (fields[1] !== `127.0.0.1${suffix}`)
|
|
281
|
+
return [];
|
|
282
|
+
return validPid(fields[4]);
|
|
283
|
+
});
|
|
284
|
+
return uniqueSorted(pids);
|
|
285
|
+
}
|
|
286
|
+
export function parseLsofPids(output) {
|
|
287
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => line.startsWith("p") ? validPid(line.slice(1)) : []));
|
|
288
|
+
}
|
|
289
|
+
export function parseSsPids(output, port) {
|
|
290
|
+
return uniqueSorted(output.split(/\r?\n/u).flatMap(line => {
|
|
291
|
+
if (!line.includes(`127.0.0.1:${port}`))
|
|
292
|
+
return [];
|
|
293
|
+
return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => validPid(match[1]));
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
export function parseProcessGroups(output) {
|
|
297
|
+
const groups = new Map();
|
|
298
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
299
|
+
const fields = line.trim().split(/\s+/u);
|
|
300
|
+
const pid = validPid(fields[0]);
|
|
301
|
+
const group = validPid(fields[1]);
|
|
302
|
+
if (pid.length === 1 && group.length === 1)
|
|
303
|
+
groups.set(pid[0], group[0]);
|
|
304
|
+
}
|
|
305
|
+
return groups;
|
|
306
|
+
}
|
|
307
|
+
async function loadMetadata(metadataPath) {
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
310
|
+
if (parsed.schemaVersion !== 1 || !Number.isSafeInteger(parsed.rootPid) || parsed.rootPid <= 0) {
|
|
311
|
+
throw new Error("host_listener_inspection_invalid: process supervisor metadata is invalid.");
|
|
312
|
+
}
|
|
313
|
+
return parsed;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error.code === "ENOENT")
|
|
317
|
+
return null;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function matchesOwner(metadata, child, owner) {
|
|
322
|
+
return metadata.supervisorId === child.processSupervisorId
|
|
323
|
+
&& metadata.runtimeLeaseId === owner.executionLeaseId
|
|
324
|
+
&& metadata.dispatchAttemptId === owner.dispatchAttemptId
|
|
325
|
+
&& metadata.fencingToken === owner.fencingToken;
|
|
326
|
+
}
|
|
327
|
+
function validPid(value) {
|
|
328
|
+
if (!value || !/^\d+$/u.test(value))
|
|
329
|
+
return [];
|
|
330
|
+
const pid = Number.parseInt(value, 10);
|
|
331
|
+
return Number.isSafeInteger(pid) && pid > 0 ? [pid] : [];
|
|
332
|
+
}
|
|
333
|
+
function uniqueSorted(values) {
|
|
334
|
+
return [...new Set(values)].sort((left, right) => left - right);
|
|
335
|
+
}
|
|
336
|
+
function parseIpv4LoopbackEndpoint(value) {
|
|
337
|
+
const match = value?.match(/^127\.0\.0\.1:(\d+)$/u);
|
|
338
|
+
if (!match)
|
|
339
|
+
return null;
|
|
340
|
+
const port = Number.parseInt(match[1], 10);
|
|
341
|
+
return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
|
342
|
+
}
|
|
343
|
+
function uniqueListeners(values) {
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
return values.filter(value => {
|
|
346
|
+
const key = `${value.pid}:${value.port}`;
|
|
347
|
+
if (seen.has(key))
|
|
348
|
+
return false;
|
|
349
|
+
seen.add(key);
|
|
350
|
+
return true;
|
|
351
|
+
}).sort((left, right) => left.port - right.port || left.pid - right.pid);
|
|
352
|
+
}
|
|
353
|
+
function commandError(label, result) {
|
|
354
|
+
const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
355
|
+
const code = result.errorCode ? ` (${result.errorCode})` : "";
|
|
356
|
+
if (result.timedOut) {
|
|
357
|
+
return new Error(`host_diagnostics_temporary_unavailable: ${label} timed out after ${inspectionCommandTimeoutMs} ms${result.signal ? ` (${result.signal})` : ""}.`);
|
|
358
|
+
}
|
|
359
|
+
if (isTransientWindowsNetstatFailure(result)) {
|
|
360
|
+
return new Error(`host_diagnostics_temporary_unavailable: ${label} repeatedly exited ${result.exitCode} without diagnostics.`);
|
|
361
|
+
}
|
|
362
|
+
return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${code}${detail ? `: ${detail}` : ""}.`);
|
|
363
|
+
}
|
|
364
|
+
function isTransientWindowsNetstatFailure(result) {
|
|
365
|
+
return isTransientWindowsInspectionFailure(result);
|
|
366
|
+
}
|
|
367
|
+
function isTransientWindowsInspectionFailure(result) {
|
|
368
|
+
return result.timedOut === true
|
|
369
|
+
|| (result.exitCode === 1
|
|
370
|
+
&& !result.errorCode
|
|
371
|
+
&& !result.stdout.trim()
|
|
372
|
+
&& !result.stderr.trim());
|
|
373
|
+
}
|
|
374
|
+
function wait(milliseconds) {
|
|
375
|
+
return milliseconds > 0
|
|
376
|
+
? new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
377
|
+
: Promise.resolve();
|
|
378
|
+
}
|
|
379
|
+
function windowsPowerShellPath() {
|
|
380
|
+
return path.win32.join(path.dirname(windowsSystemExecutable("netstat.exe")), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
381
|
+
}
|
|
382
|
+
function windowsSystemExecutable(name) {
|
|
383
|
+
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
|
|
384
|
+
return path.win32.join(systemRoot, "System32", name);
|
|
385
|
+
}
|