@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 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 +88 -2
- 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 +4 -1
- package/dist/bridge/bridge.service.d.ts +72 -3
- package/dist/bridge/bridge.service.js +1562 -152
- package/dist/bridge/bridge.types.d.ts +46 -1
- 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 +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.help.js +2 -2
- package/dist/cli/cli.service.d.ts +14 -6
- package/dist/cli/cli.service.js +75 -16
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +4 -0
- 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 +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
- 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.d.ts +11 -0
- package/dist/runtime/attempt-process-supervisor.js +67 -20
- 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 +10 -0
- package/dist/runtime/generated-output-reset.js +62 -8
- 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 +8 -1
- package/dist/runtime/host-listener-inspector.js +121 -60
- package/dist/runtime/host-runtime-contract.d.ts +12 -1
- package/dist/runtime/host-runtime-contract.js +107 -53
- package/dist/runtime/host-runtime-lease.d.ts +90 -8
- package/dist/runtime/host-runtime-lease.js +1097 -108
- package/dist/runtime/host-runtime-promotion.d.ts +12 -3
- package/dist/runtime/host-runtime-promotion.js +59 -12
- 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 +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +28 -11
- 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 +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
- 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.js +4 -1
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.d.ts +2 -1
- package/dist/sandbox/repository-lock.js +17 -8
- package/dist/sandbox/runtime.d.ts +6 -1
- package/dist/sandbox/runtime.js +132 -103
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/sandbox/workspace.service.js +2 -0
- package/dist/session/managed-attempt-tools.d.ts +17 -1
- package/dist/session/managed-attempt-tools.js +481 -31
- 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 +17 -0
- package/dist/session/phase-completion.artifacts.js +224 -22
- package/dist/session/phase-completion.d.ts +11 -1
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +30 -7
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.js +17 -18
- package/dist/session/phase-completion.runtime.d.ts +3 -1
- package/dist/session/phase-completion.runtime.js +118 -16
- package/dist/session/phase-completion.schemas.js +49 -2
- package/dist/session/phase-completion.validation.d.ts +1 -0
- package/dist/session/phase-completion.validation.js +111 -21
- 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 +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
- package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
- package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
- package/dist/session/provider-cli/provider-cli.process.js +422 -22
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
- 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 +8 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +40 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
- package/dist/session/provider-cli/provider-cli.service.js +771 -68
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
- package/dist/session/provider-cli/provider-output-state.js +361 -17
- 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.js +434 -9
- package/dist/session/session.service.js +145 -23
- package/dist/session/session.types.d.ts +147 -2
- package/dist/session/session.types.js +587 -7
- 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 +18 -0
- 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-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +57 -9
- package/dist/shared/process/process.d.ts +2 -0
- package/dist/shared/process/process.js +12 -7
- package/dist/shared/provider-capabilities.d.ts +3 -0
- package/dist/shared/provider-capabilities.js +85 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +39 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +41 -9
- package/dist/worktree/dependency-warmer.js +497 -22
- package/dist/worktree/index.d.ts +1 -1
- 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.service.d.ts +7 -0
- package/dist/worktree/worktree.service.js +253 -21
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +7 -5
- package/runtime/windows-job-supervisor.ps1 +380 -14
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,8 @@ export interface HostInspectionCommandResult {
|
|
|
6
6
|
readonly stdout: string;
|
|
7
7
|
readonly stderr: string;
|
|
8
8
|
readonly errorCode?: string;
|
|
9
|
+
readonly timedOut?: boolean;
|
|
10
|
+
readonly signal?: string;
|
|
9
11
|
}
|
|
10
12
|
export interface HostInspectionCommandRunner {
|
|
11
13
|
run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
|
|
@@ -14,12 +16,17 @@ export interface HostInspectionCommandRunner {
|
|
|
14
16
|
export declare class HostListenerInspector {
|
|
15
17
|
private readonly commands;
|
|
16
18
|
private readonly platform;
|
|
17
|
-
|
|
19
|
+
private readonly retryDelayMs;
|
|
20
|
+
constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform, retryDelayMs?: number);
|
|
18
21
|
inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
|
|
19
22
|
/** Returns loopback TCP ports opened by one exact supervised child process tree. */
|
|
20
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[]>;
|
|
21
26
|
private inspectWindows;
|
|
22
27
|
private inspectAllWindows;
|
|
28
|
+
private runWindowsAncestry;
|
|
29
|
+
private runWindowsNetstat;
|
|
23
30
|
private inspectUnix;
|
|
24
31
|
private inspectAllUnix;
|
|
25
32
|
}
|
|
@@ -3,13 +3,20 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
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;
|
|
6
11
|
/** Proves that a loopback listener belongs to one exact supervised child. */
|
|
7
12
|
export class HostListenerInspector {
|
|
8
13
|
commands;
|
|
9
14
|
platform;
|
|
10
|
-
|
|
15
|
+
retryDelayMs;
|
|
16
|
+
constructor(commands = new ExecFileInspectionCommandRunner(), platform = process.platform, retryDelayMs = windowsNetstatRetryDelayMs) {
|
|
11
17
|
this.commands = commands;
|
|
12
18
|
this.platform = platform;
|
|
19
|
+
this.retryDelayMs = retryDelayMs;
|
|
13
20
|
}
|
|
14
21
|
async inspect(child, reservation, owner) {
|
|
15
22
|
if (reservation.hostIp !== "127.0.0.1" || reservation.protocol !== "tcp") {
|
|
@@ -35,77 +42,107 @@ export class HostListenerInspector {
|
|
|
35
42
|
? await this.inspectAllWindows(metadata.rootPid)
|
|
36
43
|
: await this.inspectAllUnix(metadata.rootPid);
|
|
37
44
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
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));
|
|
42
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();
|
|
43
70
|
const listenerPids = parseWindowsListenerPids(netstat.stdout, port);
|
|
44
71
|
if (listenerPids.length === 0)
|
|
45
72
|
return "unbound";
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
"-NoLogo",
|
|
49
|
-
"-NoProfile",
|
|
50
|
-
"-NonInteractive",
|
|
51
|
-
"-ExecutionPolicy",
|
|
52
|
-
"Bypass",
|
|
53
|
-
"-File",
|
|
54
|
-
windowsListenerOwnerPath,
|
|
55
|
-
"-RootProcessId",
|
|
56
|
-
String(rootPid),
|
|
57
|
-
"-ListenerProcessIds",
|
|
58
|
-
listenerPids.join(","),
|
|
59
|
-
]);
|
|
60
|
-
if (ownership.exitCode !== 0)
|
|
61
|
-
throw commandError("Windows process ancestry", ownership);
|
|
62
|
-
let parsed;
|
|
63
|
-
try {
|
|
64
|
-
parsed = JSON.parse(ownership.stdout.trim());
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
throw new Error("host_listener_inspection_failed: Windows ancestry output was invalid.", { cause: error });
|
|
68
|
-
}
|
|
69
|
-
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
70
|
-
return values.length === listenerPids.length && values.every(value => value === true) ? "owned" : "foreign";
|
|
73
|
+
const values = await this.runWindowsAncestry(rootPid, listenerPids);
|
|
74
|
+
return values.every(value => value) ? "owned" : "foreign";
|
|
71
75
|
}
|
|
72
76
|
async inspectAllWindows(rootPid) {
|
|
73
|
-
const netstat = await this.
|
|
74
|
-
if (netstat.exitCode !== 0)
|
|
75
|
-
throw commandError("netstat", netstat);
|
|
77
|
+
const netstat = await this.runWindowsNetstat();
|
|
76
78
|
const listeners = parseWindowsLoopbackListeners(netstat.stdout);
|
|
77
79
|
if (listeners.length === 0)
|
|
78
80
|
return [];
|
|
79
81
|
const pids = [...new Set(listeners.map(listener => listener.pid))];
|
|
80
|
-
const
|
|
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
|
-
}
|
|
82
|
+
const values = await this.runWindowsAncestry(rootPid, pids);
|
|
106
83
|
const ownedPids = new Set(pids.filter((_pid, index) => values[index] === true));
|
|
107
84
|
return uniqueSorted(listeners.filter(listener => ownedPids.has(listener.pid)).map(listener => listener.port));
|
|
108
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
|
+
}
|
|
109
146
|
async inspectUnix(rootPid, port) {
|
|
110
147
|
let listenerPids = null;
|
|
111
148
|
const lsof = await this.commands.run("lsof", [
|
|
@@ -217,15 +254,18 @@ export class ExecFileInspectionCommandRunner {
|
|
|
217
254
|
: command;
|
|
218
255
|
execFile(executable, args, {
|
|
219
256
|
windowsHide: true,
|
|
220
|
-
timeout:
|
|
257
|
+
timeout: inspectionCommandTimeoutMs,
|
|
221
258
|
maxBuffer: 4 * 1024 * 1024,
|
|
222
259
|
}, (error, stdout, stderr) => {
|
|
223
260
|
const processError = error;
|
|
261
|
+
const timedOut = Boolean(processError?.killed && processError.signal === "SIGTERM" && processError.code == null);
|
|
224
262
|
resolve({
|
|
225
263
|
exitCode: typeof processError?.code === "number" ? processError.code : error ? 1 : 0,
|
|
226
264
|
stdout: String(stdout),
|
|
227
265
|
stderr: String(stderr),
|
|
228
266
|
...(typeof processError?.code === "string" ? { errorCode: processError.code } : {}),
|
|
267
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
268
|
+
...(processError?.signal ? { signal: processError.signal } : {}),
|
|
229
269
|
});
|
|
230
270
|
});
|
|
231
271
|
});
|
|
@@ -313,8 +353,29 @@ function uniqueListeners(values) {
|
|
|
313
353
|
function commandError(label, result) {
|
|
314
354
|
const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
|
|
315
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
|
+
}
|
|
316
362
|
return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${code}${detail ? `: ${detail}` : ""}.`);
|
|
317
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
|
+
}
|
|
318
379
|
function windowsPowerShellPath() {
|
|
319
380
|
return path.win32.join(path.dirname(windowsSystemExecutable("netstat.exe")), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
320
381
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const hostRuntimeContractPath = ".forge/forge.runtime.yml";
|
|
2
|
+
export declare const hostRuntimeTestPostgresAdminEnvironment = "FORGE_TEST_POSTGRES_ADMIN_CONNECTION_STRING";
|
|
3
|
+
export declare const hostRuntimeLegacyTestPostgresEnvironment = "FORGE_TEST_POSTGRES_CONNECTION_STRING";
|
|
2
4
|
export type RequestedHostConcurrency = "serial" | "parallel" | "disabled";
|
|
3
5
|
export type HostRuntimePortAdapter = "node" | "vite" | "next" | "aspnetcore";
|
|
4
6
|
export interface HostRuntimeLaunchConfig {
|
|
@@ -41,7 +43,16 @@ export interface HostRuntimeDiscovery {
|
|
|
41
43
|
readonly summary: string;
|
|
42
44
|
readonly candidateFingerprint?: string | null;
|
|
43
45
|
}
|
|
44
|
-
|
|
46
|
+
/** Returns retained bytes (or retained absence) for one canonical discovery input. */
|
|
47
|
+
export type HostRuntimeSourceSnapshot = (target: string) => Promise<Buffer | null>;
|
|
48
|
+
export interface HostRuntimeDirectoryEntry {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly isDirectory: boolean;
|
|
51
|
+
readonly isFile: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** Returns one identity-bound directory inventory for runtime discovery. */
|
|
54
|
+
export type HostRuntimeDirectorySnapshot = (target: string) => Promise<readonly HostRuntimeDirectoryEntry[]>;
|
|
55
|
+
export declare function loadHostRuntimeContract(worktreePath: string, snapshot?: HostRuntimeSourceSnapshot, directorySnapshot?: HostRuntimeDirectorySnapshot): Promise<LoadedHostRuntimeContract>;
|
|
45
56
|
export declare function defaultHostRuntimeContract(): HostRuntimeContract;
|
|
46
57
|
export declare function validateHostRuntimeContract(source: string): HostRuntimeContract;
|
|
47
58
|
export declare function hostPortEnvironment(config: HostRuntimePortConfig, hostIp: "127.0.0.1", hostPort: number): Readonly<Record<string, string>>;
|