@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.1ff37038366e
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 +2427 -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-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -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 +813 -0
- package/dist/runtime/host-runtime-lease.d.ts +217 -0
- package/dist/runtime/host-runtime-lease.js +2487 -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 +1015 -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 +840 -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 +661 -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 +81 -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,390 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* The managed host attempt's database capability. The Bridge keeps the test cluster's
|
|
7
|
+
* administrator credential; an attempt (its agent, its application runtime and its tests)
|
|
8
|
+
* receives only a login for one database created for it. Forge.Api checks that login before it
|
|
9
|
+
* migrates (AttemptScopedDatabasePolicy.AssertBrokeredCapabilityAsync), but that check runs in
|
|
10
|
+
* attempt code: the boundary is what this broker issues and the cluster's pg_hba rules, which
|
|
11
|
+
* let an attempt role log in only to its own database.
|
|
12
|
+
*/
|
|
13
|
+
export const attemptDatabaseCapabilityEnvironment = "FORGE_ATTEMPT_DATABASE_CAPABILITY";
|
|
14
|
+
export class AttemptDatabaseBrokerError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code, detail) {
|
|
17
|
+
super(`${code}: ${detail}`);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = "AttemptDatabaseBrokerError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const markerVersion = "broker-v1";
|
|
23
|
+
const attemptNamePattern = /^forge_attempt_[0-9a-f]{32}$/u;
|
|
24
|
+
const template1BusyPattern = /source database "template1" is being accessed by other users/u;
|
|
25
|
+
const hbaRejectPattern = /pg_hba\.conf rejects connection|no pg_hba\.conf entry/u;
|
|
26
|
+
// Three attempts at this limit stay well under the cluster's 200 connections, leaving room for
|
|
27
|
+
// the administrator; the issued pool stays below it.
|
|
28
|
+
const attemptConnectionLimit = 40;
|
|
29
|
+
const attemptPoolSize = 20;
|
|
30
|
+
/**
|
|
31
|
+
* The ownership marker on the attempt's role. Only a role with CREATEROLE can change a role's
|
|
32
|
+
* comment, so the attempt cannot forge or erase it (it can rewrite its database's comment, which
|
|
33
|
+
* is therefore informational). Must match AttemptScopedDatabasePolicy.BrokeredRoleMarkerPrefix.
|
|
34
|
+
*/
|
|
35
|
+
export function brokeredDatabaseMarker(dispatchAttemptId, fencingToken, owner) {
|
|
36
|
+
return `${brokeredMarkerPrefix(dispatchAttemptId, fencingToken)}owner=${owner}`;
|
|
37
|
+
}
|
|
38
|
+
function brokeredMarkerPrefix(dispatchAttemptId, fencingToken) {
|
|
39
|
+
const name = attemptDatabaseName(dispatchAttemptId);
|
|
40
|
+
return `forge_attempt_database:${markerVersion};id=${hex(dispatchAttemptId)};fence=${hex(fencingToken)};database=${name};`;
|
|
41
|
+
}
|
|
42
|
+
export function attemptDatabaseName(dispatchAttemptId) {
|
|
43
|
+
return `forge_attempt_${hex(dispatchAttemptId)}`;
|
|
44
|
+
}
|
|
45
|
+
/** Identifies one Bridge's broker on a shared cluster, so it reaps only databases it issued. */
|
|
46
|
+
export function attemptDatabaseBrokerOwner(stateRoot) {
|
|
47
|
+
return createHash("sha256").update(path.resolve(stateRoot).toLowerCase()).digest("hex").slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Brokers attempt databases on the dedicated native test cluster that
|
|
51
|
+
* scripts/setup-native-test-postgres.ps1 provisions. That cluster holds only disposable test
|
|
52
|
+
* databases, so even a leaked attempt capability cannot reach developer data. Each attempt
|
|
53
|
+
* gets a login role and a database it owns, cloned from template1 (which carries pgvector,
|
|
54
|
+
* an extension an unprivileged owner cannot create).
|
|
55
|
+
*/
|
|
56
|
+
export class NativeTestClusterDatabaseBroker {
|
|
57
|
+
admin;
|
|
58
|
+
runSql;
|
|
59
|
+
owner;
|
|
60
|
+
delay;
|
|
61
|
+
/** Names being provisioned right now; the reaper leaves them alone. */
|
|
62
|
+
provisioning = new Set();
|
|
63
|
+
constructor(admin, runSql, owner, delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))) {
|
|
64
|
+
this.admin = admin;
|
|
65
|
+
this.runSql = runSql;
|
|
66
|
+
this.owner = owner;
|
|
67
|
+
this.delay = delay;
|
|
68
|
+
if (!/^[0-9a-f]{16}$/u.test(owner)) {
|
|
69
|
+
throw new AttemptDatabaseBrokerError("attempt_database_broker_invalid", "the broker owner must be 16 hex characters.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async provision(dispatchAttemptId, fencingToken, signal) {
|
|
73
|
+
const name = attemptDatabaseName(dispatchAttemptId);
|
|
74
|
+
const marker = brokeredDatabaseMarker(dispatchAttemptId, fencingToken, this.owner);
|
|
75
|
+
this.provisioning.add(name);
|
|
76
|
+
const password = randomBytes(24).toString("hex");
|
|
77
|
+
try {
|
|
78
|
+
const state = await this.state(name, signal);
|
|
79
|
+
if (state.roleMarker !== null && state.roleMarker !== marker) {
|
|
80
|
+
throw new AttemptDatabaseBrokerError("attempt_database_not_owned", `role ${name} exists without this attempt's broker marker and was left untouched.`);
|
|
81
|
+
}
|
|
82
|
+
if (state.databaseOwner !== null && (state.roleMarker === null || state.databaseOwner !== name)) {
|
|
83
|
+
throw new AttemptDatabaseBrokerError("attempt_database_not_owned", `database ${name} is not owned by this attempt's broker role and was left untouched.`);
|
|
84
|
+
}
|
|
85
|
+
// One transaction: an interrupted run leaves either no role or a marked one.
|
|
86
|
+
await this.runSql([
|
|
87
|
+
"BEGIN;",
|
|
88
|
+
...(state.roleMarker === null
|
|
89
|
+
? [
|
|
90
|
+
`CREATE ROLE ${ident(name)} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT CONNECTION LIMIT ${attemptConnectionLimit} PASSWORD ${literal(password)};`,
|
|
91
|
+
`COMMENT ON ROLE ${ident(name)} IS ${literal(marker)};`,
|
|
92
|
+
// Lets the administrator create, and later drop, a database the attempt role owns.
|
|
93
|
+
`GRANT ${ident(name)} TO CURRENT_USER;`,
|
|
94
|
+
]
|
|
95
|
+
: [`ALTER ROLE ${ident(name)} LOGIN PASSWORD ${literal(password)};`]),
|
|
96
|
+
"COMMIT;",
|
|
97
|
+
].join("\n"), signal);
|
|
98
|
+
if (state.databaseOwner === null)
|
|
99
|
+
await this.createDatabase(name, signal);
|
|
100
|
+
// Idempotent, so a retry after an interrupted provision converges.
|
|
101
|
+
await this.runSql([
|
|
102
|
+
`COMMENT ON DATABASE ${ident(name)} IS ${literal(marker)};`,
|
|
103
|
+
`REVOKE ALL ON DATABASE ${ident(name)} FROM PUBLIC;`,
|
|
104
|
+
`GRANT CONNECT, TEMPORARY ON DATABASE ${ident(name)} TO ${ident(name)};`,
|
|
105
|
+
].join("\n"), signal);
|
|
106
|
+
await this.assertConfinedToOwnDatabase(name, password, signal);
|
|
107
|
+
return {
|
|
108
|
+
databaseName: name,
|
|
109
|
+
connectionString: [
|
|
110
|
+
`Host=${this.admin.host}`,
|
|
111
|
+
`Port=${this.admin.port}`,
|
|
112
|
+
`Database=${name}`,
|
|
113
|
+
`Username=${name}`,
|
|
114
|
+
`Password=${password}`,
|
|
115
|
+
"Timeout=15",
|
|
116
|
+
"Command Timeout=60",
|
|
117
|
+
`Maximum Pool Size=${attemptPoolSize}`,
|
|
118
|
+
].join(";"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof Error) {
|
|
123
|
+
error.message = error.message.replaceAll(password, "[redacted]");
|
|
124
|
+
if (error.stack)
|
|
125
|
+
error.stack = error.stack.replaceAll(password, "[redacted]");
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
this.provisioning.delete(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async release(dispatchAttemptId, fencingToken) {
|
|
134
|
+
const name = attemptDatabaseName(dispatchAttemptId);
|
|
135
|
+
const state = await this.state(name);
|
|
136
|
+
if (state.roleMarker === null && state.databaseOwner === null)
|
|
137
|
+
return "missing";
|
|
138
|
+
if (state.roleMarker !== brokeredDatabaseMarker(dispatchAttemptId, fencingToken, this.owner)
|
|
139
|
+
|| (state.databaseOwner !== null && state.databaseOwner !== name)) {
|
|
140
|
+
throw new AttemptDatabaseBrokerError("attempt_database_not_owned", `${name} is not this attempt's broker database and was left untouched.`);
|
|
141
|
+
}
|
|
142
|
+
return await this.drop(name);
|
|
143
|
+
}
|
|
144
|
+
async reapOrphans(isLive) {
|
|
145
|
+
const rows = await this.runSql([
|
|
146
|
+
"SELECT r.rolname, COALESCE(shobj_description(r.oid, 'pg_authid'), '') FROM pg_roles r",
|
|
147
|
+
"WHERE r.rolname ~ '^forge_attempt_[0-9a-f]{32}$';",
|
|
148
|
+
].join("\n"));
|
|
149
|
+
const dropped = [];
|
|
150
|
+
for (const line of rows.split(/\r?\n/u)) {
|
|
151
|
+
const separator = line.indexOf("|");
|
|
152
|
+
if (separator < 0)
|
|
153
|
+
continue;
|
|
154
|
+
const name = line.slice(0, separator);
|
|
155
|
+
const marker = line.slice(separator + 1);
|
|
156
|
+
if (!attemptNamePattern.test(name))
|
|
157
|
+
continue;
|
|
158
|
+
// Only this broker's exact-name role marker is ownership evidence; prefix matches are not.
|
|
159
|
+
const id = name.slice("forge_attempt_".length);
|
|
160
|
+
if (!new RegExp(`^forge_attempt_database:${markerVersion};id=${id};fence=[0-9a-f]{32};database=${name};owner=${this.owner}$`, "u")
|
|
161
|
+
.test(marker))
|
|
162
|
+
continue;
|
|
163
|
+
try {
|
|
164
|
+
const state = await this.state(name);
|
|
165
|
+
if (state.databaseOwner !== null && state.databaseOwner !== name)
|
|
166
|
+
continue;
|
|
167
|
+
// Asked last, so an attempt admitted while the role list was read is still spared.
|
|
168
|
+
if (this.provisioning.has(name) || isLive(uuidFromHex(id)))
|
|
169
|
+
continue;
|
|
170
|
+
if (await this.drop(name) === "dropped")
|
|
171
|
+
dropped.push(name);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// One stuck name must not stop the rest of the reap; it is retried next time.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return dropped;
|
|
178
|
+
}
|
|
179
|
+
async state(name, signal) {
|
|
180
|
+
const output = await this.runSql([
|
|
181
|
+
`SELECT 'role', COALESCE(shobj_description(oid, 'pg_authid'), '') FROM pg_roles WHERE rolname = ${literal(name)}`,
|
|
182
|
+
"UNION ALL",
|
|
183
|
+
`SELECT 'database', pg_get_userbyid(datdba) FROM pg_database WHERE datname = ${literal(name)};`,
|
|
184
|
+
].join("\n"), signal);
|
|
185
|
+
let roleMarker = null;
|
|
186
|
+
let databaseOwner = null;
|
|
187
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
188
|
+
const separator = line.indexOf("|");
|
|
189
|
+
if (separator < 0)
|
|
190
|
+
continue;
|
|
191
|
+
const kind = line.slice(0, separator);
|
|
192
|
+
const value = line.slice(separator + 1);
|
|
193
|
+
if (kind === "role")
|
|
194
|
+
roleMarker = value;
|
|
195
|
+
if (kind === "database")
|
|
196
|
+
databaseOwner = value;
|
|
197
|
+
}
|
|
198
|
+
return { roleMarker, databaseOwner };
|
|
199
|
+
}
|
|
200
|
+
async createDatabase(name, signal) {
|
|
201
|
+
// CREATE DATABASE needs template1 to itself for a moment; .NET test fixtures on the same
|
|
202
|
+
// cluster can hold it briefly.
|
|
203
|
+
for (let attempt = 1;; attempt += 1) {
|
|
204
|
+
try {
|
|
205
|
+
await this.runSql(`CREATE DATABASE ${ident(name)} OWNER ${ident(name)} TEMPLATE template1;`, signal);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (attempt >= 10 || !template1BusyPattern.test(String(error.message)))
|
|
210
|
+
throw error;
|
|
211
|
+
signal?.throwIfAborted();
|
|
212
|
+
await this.delay(500 * attempt);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Fails closed unless the cluster lets this login reach its own database and nothing else. */
|
|
217
|
+
async assertConfinedToOwnDatabase(name, password, signal) {
|
|
218
|
+
const own = await this.runSql("SELECT current_user;", signal, { database: name, username: name, password });
|
|
219
|
+
if (own.trim() !== name) {
|
|
220
|
+
throw new AttemptDatabaseBrokerError("attempt_database_broker_failed", `${name} could not log in to its own database.`);
|
|
221
|
+
}
|
|
222
|
+
let confined = true;
|
|
223
|
+
// A database that cannot exist stands for every other name: pg_hba is checked before the
|
|
224
|
+
// database is looked up, so only a rule that rejects this role everywhere else rejects it.
|
|
225
|
+
const unmatched = `forge_confinement_probe_${randomBytes(8).toString("hex")}`;
|
|
226
|
+
for (const other of ["postgres", "template1", unmatched]) {
|
|
227
|
+
try {
|
|
228
|
+
await this.runSql("SELECT 1;", signal, { database: other, username: name, password });
|
|
229
|
+
confined = false;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (!hbaRejectPattern.test(String(error.message)))
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (confined)
|
|
237
|
+
return;
|
|
238
|
+
await this.drop(name).catch(() => "retained");
|
|
239
|
+
throw new AttemptDatabaseBrokerError("attempt_database_cluster_unrestricted", "the native test cluster lets attempt roles log in to other databases; run scripts/setup-native-test-postgres.ps1 -Action RestrictAttemptRoles.");
|
|
240
|
+
}
|
|
241
|
+
async drop(name) {
|
|
242
|
+
try {
|
|
243
|
+
await this.runSql([
|
|
244
|
+
// Refuse new logins first, so nothing reconnects between the terminate and the drop.
|
|
245
|
+
`ALTER ROLE ${ident(name)} NOLOGIN;`,
|
|
246
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${literal(name)} OR usename = ${literal(name)};`,
|
|
247
|
+
`DROP DATABASE IF EXISTS ${ident(name)} WITH (FORCE);`,
|
|
248
|
+
`DROP OWNED BY ${ident(name)};`,
|
|
249
|
+
`DROP ROLE IF EXISTS ${ident(name)};`,
|
|
250
|
+
].join("\n"));
|
|
251
|
+
return "dropped";
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return "retained";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function nativeTestClusterAdmin(fallback) {
|
|
259
|
+
const fields = new Map();
|
|
260
|
+
for (const segment of fallback.connectionString.split(";")) {
|
|
261
|
+
const separator = segment.indexOf("=");
|
|
262
|
+
if (separator <= 0)
|
|
263
|
+
continue;
|
|
264
|
+
fields.set(segment.slice(0, separator).trim().toLowerCase().replaceAll(" ", ""), segment.slice(separator + 1).trim());
|
|
265
|
+
}
|
|
266
|
+
const separator = fallback.endpoint.lastIndexOf(":");
|
|
267
|
+
const host = fallback.endpoint.slice(0, separator).replace(/^\[(.*)\]$/u, "$1");
|
|
268
|
+
const port = Number.parseInt(fallback.endpoint.slice(separator + 1), 10);
|
|
269
|
+
const username = fields.get("username") ?? fields.get("userid") ?? fields.get("user") ?? "";
|
|
270
|
+
return { host, port, username, password: fields.get("password") ?? "" };
|
|
271
|
+
}
|
|
272
|
+
/** Runs SQL through the psql that ships with the PostgreSQL install hosting the native test cluster. */
|
|
273
|
+
export function psqlSqlRunner(admin, psqlPath, timeoutMs = 120_000) {
|
|
274
|
+
return (sql, signal, login) => new Promise((resolve, reject) => {
|
|
275
|
+
const username = login?.username ?? admin.username;
|
|
276
|
+
const password = login?.password ?? admin.password;
|
|
277
|
+
const child = spawn(psqlPath, [
|
|
278
|
+
"-X", "-q", "-A", "-t", "-w",
|
|
279
|
+
"-v", "ON_ERROR_STOP=1",
|
|
280
|
+
"-h", admin.host,
|
|
281
|
+
"-p", String(admin.port),
|
|
282
|
+
"-U", username,
|
|
283
|
+
"-d", login?.database ?? "postgres",
|
|
284
|
+
], {
|
|
285
|
+
// Only what psql needs: never the Bridge's own environment.
|
|
286
|
+
env: {
|
|
287
|
+
PGPASSWORD: password,
|
|
288
|
+
PGCONNECT_TIMEOUT: "10",
|
|
289
|
+
PGAPPNAME: "Forge.Bridge.AttemptDatabaseBroker",
|
|
290
|
+
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
|
291
|
+
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
|
292
|
+
},
|
|
293
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
294
|
+
windowsHide: true,
|
|
295
|
+
signal,
|
|
296
|
+
});
|
|
297
|
+
let stdout = "";
|
|
298
|
+
let stderr = "";
|
|
299
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
300
|
+
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; });
|
|
301
|
+
child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk; });
|
|
302
|
+
child.once("error", error => {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
reject(error);
|
|
305
|
+
});
|
|
306
|
+
child.once("close", code => {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
if (code === 0)
|
|
309
|
+
resolve(stdout);
|
|
310
|
+
else
|
|
311
|
+
reject(new AttemptDatabaseBrokerError("attempt_database_broker_failed", stderr.trim().slice(-2_000).replaceAll(password, "[redacted]").replaceAll(admin.password, "[redacted]")
|
|
312
|
+
|| `psql exited with ${code}`));
|
|
313
|
+
});
|
|
314
|
+
child.stdin.end(sql);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** psql beside the cluster's pg_ctl: FORGE_NATIVE_TEST_POSTGRES_PSQL, a Windows install, or PATH. */
|
|
318
|
+
export function resolvePsqlPath() {
|
|
319
|
+
const configured = process.env.FORGE_NATIVE_TEST_POSTGRES_PSQL?.trim();
|
|
320
|
+
if (configured)
|
|
321
|
+
return fs.existsSync(configured) ? configured : null;
|
|
322
|
+
if (process.platform === "win32") {
|
|
323
|
+
const root = path.join(process.env.ProgramFiles ?? "C:\\Program Files", "PostgreSQL");
|
|
324
|
+
let versions;
|
|
325
|
+
try {
|
|
326
|
+
versions = fs.readdirSync(root).filter(entry => /^\d+$/u.test(entry));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
for (const version of versions.sort((left, right) => Number(right) - Number(left))) {
|
|
332
|
+
const candidate = path.join(root, version, "bin", "psql.exe");
|
|
333
|
+
if (fs.existsSync(candidate))
|
|
334
|
+
return candidate;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
339
|
+
const candidate = path.join(directory, "psql");
|
|
340
|
+
if (directory && fs.existsSync(candidate))
|
|
341
|
+
return candidate;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The broker for this host, or null when no native test cluster is provisioned. Without a
|
|
347
|
+
* broker, Forge.Api still refuses to start in a managed host attempt; other applications run.
|
|
348
|
+
*/
|
|
349
|
+
export function createNativeTestClusterDatabaseBroker(readFallback, stateRoot) {
|
|
350
|
+
let fallback;
|
|
351
|
+
try {
|
|
352
|
+
fallback = readFallback();
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// An unsafe cluster file is reported by pre-publication validation; it is never brokered.
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const psqlPath = fallback ? resolvePsqlPath() : null;
|
|
359
|
+
if (!fallback || !psqlPath)
|
|
360
|
+
return null;
|
|
361
|
+
const admin = nativeTestClusterAdmin(fallback);
|
|
362
|
+
return new NativeTestClusterDatabaseBroker(admin, psqlSqlRunner(admin, psqlPath), attemptDatabaseBrokerOwner(stateRoot));
|
|
363
|
+
}
|
|
364
|
+
/** The exact strings to redact wherever a capability may be printed: the whole value and its password. */
|
|
365
|
+
export function attemptDatabaseCapabilitySecrets(capability) {
|
|
366
|
+
const value = capability?.trim();
|
|
367
|
+
if (!value)
|
|
368
|
+
return [];
|
|
369
|
+
const password = /(?:^|;)\s*Password\s*=\s*([^;]+)/iu.exec(value)?.[1]?.trim();
|
|
370
|
+
return password ? [value, password] : [value];
|
|
371
|
+
}
|
|
372
|
+
function uuidFromHex(value) {
|
|
373
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
374
|
+
}
|
|
375
|
+
function hex(uuid) {
|
|
376
|
+
const value = uuid.trim().toLowerCase().replaceAll("-", "");
|
|
377
|
+
if (!/^[0-9a-f]{32}$/u.test(value)) {
|
|
378
|
+
throw new AttemptDatabaseBrokerError("attempt_database_broker_invalid", "attempt identifiers must be UUIDs.");
|
|
379
|
+
}
|
|
380
|
+
return value;
|
|
381
|
+
}
|
|
382
|
+
function ident(name) {
|
|
383
|
+
if (!attemptNamePattern.test(name)) {
|
|
384
|
+
throw new AttemptDatabaseBrokerError("attempt_database_broker_invalid", `${name} is not an attempt database name.`);
|
|
385
|
+
}
|
|
386
|
+
return `"${name}"`;
|
|
387
|
+
}
|
|
388
|
+
function literal(value) {
|
|
389
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
390
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AttemptProcessChildHandle, AttemptProcessSupervisor } from "./attempt-process-supervisor.js";
|
|
2
|
+
import type { HostPortLeaseOwner } from "./host-port-broker.js";
|
|
3
|
+
import { HostListenerInspector } from "./host-listener-inspector.js";
|
|
4
|
+
import type { HostListenerOwnership, NodeRuntimeCommandLauncher, NodeRuntimeLaunchRequest, NodeRuntimeReadinessDiagnostics } from "./node-host-runtime-adapter.js";
|
|
5
|
+
/** Launches one explicitly configured HTTP project beneath the attempt coordinator. */
|
|
6
|
+
export declare class AttemptNodeRuntimeLauncher implements NodeRuntimeCommandLauncher<AttemptProcessChildHandle> {
|
|
7
|
+
private readonly supervisor;
|
|
8
|
+
private readonly owner;
|
|
9
|
+
private readonly logDirectory;
|
|
10
|
+
private readonly listenerInspector;
|
|
11
|
+
private readonly exactSecrets;
|
|
12
|
+
private readonly diagnosticsByChild;
|
|
13
|
+
constructor(supervisor: AttemptProcessSupervisor, owner: HostPortLeaseOwner, logDirectory: string, listenerInspector?: HostListenerInspector, exactSecrets?: readonly string[]);
|
|
14
|
+
launch(request: NodeRuntimeLaunchRequest, signal?: AbortSignal): Promise<AttemptProcessChildHandle>;
|
|
15
|
+
inspectListener(child: AttemptProcessChildHandle, request: NodeRuntimeLaunchRequest): Promise<HostListenerOwnership>;
|
|
16
|
+
stop(child: AttemptProcessChildHandle): Promise<void>;
|
|
17
|
+
readinessDiagnostics(child: AttemptProcessChildHandle): Promise<NodeRuntimeReadinessDiagnostics>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { redactText } from "../shared/redaction/redaction.js";
|
|
5
|
+
import { ForgeFailureError, sanitizeFailureError } from "../session/session.types.js";
|
|
6
|
+
import { HostListenerInspector } from "./host-listener-inspector.js";
|
|
7
|
+
const diagnosticTailLength = 4_000;
|
|
8
|
+
/** Launches one explicitly configured HTTP project beneath the attempt coordinator. */
|
|
9
|
+
export class AttemptNodeRuntimeLauncher {
|
|
10
|
+
supervisor;
|
|
11
|
+
owner;
|
|
12
|
+
logDirectory;
|
|
13
|
+
listenerInspector;
|
|
14
|
+
exactSecrets;
|
|
15
|
+
diagnosticsByChild = new WeakMap();
|
|
16
|
+
constructor(supervisor, owner, logDirectory, listenerInspector = new HostListenerInspector(), exactSecrets = []) {
|
|
17
|
+
this.supervisor = supervisor;
|
|
18
|
+
this.owner = owner;
|
|
19
|
+
this.logDirectory = logDirectory;
|
|
20
|
+
this.listenerInspector = listenerInspector;
|
|
21
|
+
this.exactSecrets = exactSecrets;
|
|
22
|
+
}
|
|
23
|
+
async launch(request, signal) {
|
|
24
|
+
const childName = projectChildName(request.port.name);
|
|
25
|
+
const stdoutPath = path.join(this.logDirectory, `${childName}.stdout.log`);
|
|
26
|
+
const stderrPath = path.join(this.logDirectory, `${childName}.stderr.log`);
|
|
27
|
+
const diagnostics = { exited: false, stdoutTail: "", stderrTail: "", outputRevision: 0 };
|
|
28
|
+
let child;
|
|
29
|
+
try {
|
|
30
|
+
child = await this.supervisor.startChildReady(childName, {
|
|
31
|
+
command: request.command,
|
|
32
|
+
args: request.args,
|
|
33
|
+
cwd: request.cwd,
|
|
34
|
+
env: request.environment,
|
|
35
|
+
captureOutput: false,
|
|
36
|
+
failureRedactionValues: this.exactSecrets,
|
|
37
|
+
}, {
|
|
38
|
+
onStdoutLine: async (line) => {
|
|
39
|
+
const redacted = redactText(line, this.exactSecrets);
|
|
40
|
+
diagnostics.stdoutTail = appendTail(diagnostics.stdoutTail, redacted);
|
|
41
|
+
diagnostics.outputRevision += 1;
|
|
42
|
+
await appendLine(stdoutPath, redacted);
|
|
43
|
+
},
|
|
44
|
+
onStderrLine: async (line) => {
|
|
45
|
+
const redacted = redactText(line, this.exactSecrets);
|
|
46
|
+
diagnostics.stderrTail = appendTail(diagnostics.stderrTail, redacted);
|
|
47
|
+
diagnostics.outputRevision += 1;
|
|
48
|
+
await appendLine(stderrPath, redacted);
|
|
49
|
+
},
|
|
50
|
+
}, signal, { maxStartAttempts: 3, retryDelayMs: 250 });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw enrichLaunchFailure(error, diagnostics, this.exactSecrets);
|
|
54
|
+
}
|
|
55
|
+
const exposedChild = sanitizedChildHandle(child, this.exactSecrets);
|
|
56
|
+
this.diagnosticsByChild.set(exposedChild, diagnostics);
|
|
57
|
+
void exposedChild.result.then(result => {
|
|
58
|
+
diagnostics.exited = true;
|
|
59
|
+
diagnostics.exitCode = result.exitCode;
|
|
60
|
+
diagnostics.stdoutTail = appendTail(diagnostics.stdoutTail, result.stdout);
|
|
61
|
+
diagnostics.stderrTail = appendTail(diagnostics.stderrTail, result.stderr);
|
|
62
|
+
diagnostics.outputRevision += 1;
|
|
63
|
+
}, error => {
|
|
64
|
+
diagnostics.exited = true;
|
|
65
|
+
diagnostics.stderrTail = appendTail(diagnostics.stderrTail, redactText(error instanceof Error ? error.message : String(error), this.exactSecrets));
|
|
66
|
+
diagnostics.outputRevision += 1;
|
|
67
|
+
});
|
|
68
|
+
return exposedChild;
|
|
69
|
+
}
|
|
70
|
+
async inspectListener(child, request) {
|
|
71
|
+
return await this.listenerInspector.inspect(child, request.port, this.owner);
|
|
72
|
+
}
|
|
73
|
+
async stop(child) {
|
|
74
|
+
await child.stop(this.owner.fencingToken);
|
|
75
|
+
}
|
|
76
|
+
async readinessDiagnostics(child) {
|
|
77
|
+
// Yield once so an already-settled result can update the diagnostic record.
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
const diagnostics = this.diagnosticsByChild.get(child);
|
|
80
|
+
return diagnostics ?? { exited: false };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function projectChildName(portName) {
|
|
84
|
+
const candidate = `project-${portName}`;
|
|
85
|
+
if (/^[a-z][a-z0-9_-]{0,31}$/u.test(candidate))
|
|
86
|
+
return candidate;
|
|
87
|
+
const digest = createHash("sha256").update(portName).digest("hex").slice(0, 16);
|
|
88
|
+
return `project-${digest}`;
|
|
89
|
+
}
|
|
90
|
+
async function appendLine(target, line) {
|
|
91
|
+
await fs.appendFile(target, `${line}\n`, { encoding: "utf8", mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
function appendTail(current, value) {
|
|
94
|
+
if (!value)
|
|
95
|
+
return current;
|
|
96
|
+
const combined = current ? `${current}\n${value}` : value;
|
|
97
|
+
return combined.slice(-diagnosticTailLength);
|
|
98
|
+
}
|
|
99
|
+
function enrichLaunchFailure(error, diagnostics, exactSecrets) {
|
|
100
|
+
const sanitized = sanitizeFailureError(error, exactSecrets);
|
|
101
|
+
const evidence = [
|
|
102
|
+
diagnostics.stderrTail ? `stderr tail: ${boundedDiagnostic(diagnostics.stderrTail)}` : "",
|
|
103
|
+
diagnostics.stdoutTail ? `stdout tail: ${boundedDiagnostic(diagnostics.stdoutTail)}` : "",
|
|
104
|
+
].filter(Boolean);
|
|
105
|
+
if (evidence.length === 0)
|
|
106
|
+
return sanitized;
|
|
107
|
+
return replaceSanitizedErrorMessage(sanitized, `${sanitized.message} ${evidence.join("; ")}`);
|
|
108
|
+
}
|
|
109
|
+
function sanitizedChildHandle(child, exactSecrets) {
|
|
110
|
+
const sanitizeResult = (result) => ({
|
|
111
|
+
...result,
|
|
112
|
+
stdout: redactText(result.stdout, exactSecrets),
|
|
113
|
+
stderr: redactText(result.stderr, exactSecrets),
|
|
114
|
+
});
|
|
115
|
+
const ready = child.ready.catch(error => { throw sanitizeFailureError(error, exactSecrets); });
|
|
116
|
+
const result = child.result.then(sanitizeResult, error => { throw sanitizeFailureError(error, exactSecrets); });
|
|
117
|
+
const stop = child.stop.bind(child);
|
|
118
|
+
Object.defineProperties(child, {
|
|
119
|
+
ready: { configurable: true, enumerable: true, value: ready },
|
|
120
|
+
result: { configurable: true, enumerable: true, value: result },
|
|
121
|
+
stop: { configurable: true, enumerable: true, value: async (fencingToken) => {
|
|
122
|
+
try {
|
|
123
|
+
return sanitizeResult(await stop(fencingToken));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw sanitizeFailureError(error, exactSecrets);
|
|
127
|
+
}
|
|
128
|
+
} },
|
|
129
|
+
});
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
function replaceSanitizedErrorMessage(error, message) {
|
|
133
|
+
const options = error.cause === undefined ? undefined : { cause: error.cause };
|
|
134
|
+
return error instanceof ForgeFailureError
|
|
135
|
+
? new ForgeFailureError(error.failureCode, message, error.failureSource, options)
|
|
136
|
+
: new Error(message, options);
|
|
137
|
+
}
|
|
138
|
+
function boundedDiagnostic(value) {
|
|
139
|
+
return value.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(-1_000);
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BigIntStats } from "node:fs";
|
|
2
|
+
/** A separate process pins cwd without changing the multithreaded Bridge's cwd.
|
|
3
|
+
* All writes use single relative components, never a re-resolved ancestor path.
|
|
4
|
+
*/
|
|
5
|
+
export declare function runAttemptPathOperation(parent: string, identity: BigIntStats, operation: {
|
|
6
|
+
kind: "mkdir";
|
|
7
|
+
segments: string[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "private-directory-mode";
|
|
10
|
+
segments: string[];
|
|
11
|
+
} | {
|
|
12
|
+
kind: "overlay";
|
|
13
|
+
name: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}, timeoutMs?: number, terminationConfirmationMs?: number): Promise<void>;
|
|
16
|
+
/** Archive copies may be large or numerous: never block Bridge heartbeats while a pinned worker writes. */
|
|
17
|
+
export declare function runAttemptArchiveOperation(parent: string, identity: BigIntStats, operation: {
|
|
18
|
+
name: string;
|
|
19
|
+
bytes: Buffer;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}, timeoutMs?: number, terminationConfirmationMs?: number): Promise<void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { runPinnedDirectoryProcess } from "./pinned-directory-process.js";
|
|
2
|
+
/** A separate process pins cwd without changing the multithreaded Bridge's cwd.
|
|
3
|
+
* All writes use single relative components, never a re-resolved ancestor path.
|
|
4
|
+
*/
|
|
5
|
+
export async function runAttemptPathOperation(parent, identity, operation, timeoutMs = 30_000, terminationConfirmationMs = 2_000) {
|
|
6
|
+
try {
|
|
7
|
+
await runPinnedDirectoryProcess(parent, identity, worker, operation, undefined, 16 * 1024, timeoutMs, terminationConfirmationMs);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
throw new Error(`playwright_runtime_adapter_invalid: pinned directory operation failed: ${String(error)}`, { cause: error });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Archive copies may be large or numerous: never block Bridge heartbeats while a pinned worker writes. */
|
|
14
|
+
export async function runAttemptArchiveOperation(parent, identity, operation, timeoutMs = 30_000, terminationConfirmationMs = 2_000) {
|
|
15
|
+
try {
|
|
16
|
+
await runPinnedDirectoryProcess(parent, identity, worker, { kind: "archive", name: operation.name, sha256: operation.sha256 }, operation.bytes, 16 * 1024, timeoutMs, terminationConfirmationMs);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw new Error(`host_diagnostics_copy_failed: pinned directory operation failed: ${String(error)}`, { cause: error });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const worker = String.raw `
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const input = fs.readFileSync(0);
|
|
26
|
+
const boundary = input.indexOf(10);
|
|
27
|
+
const job = JSON.parse(input.subarray(0, boundary).toString('utf8'));
|
|
28
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
29
|
+
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
30
|
+
const component = value => {
|
|
31
|
+
if (typeof value !== 'string' || !value || value === '.' || value === '..'
|
|
32
|
+
|| ['/', ':', String.fromCharCode(92), String.fromCharCode(0)].some(char => value.includes(char)))
|
|
33
|
+
throw new Error('invalid relative component');
|
|
34
|
+
};
|
|
35
|
+
const statCwd = () => fs.statSync('.', { bigint: true });
|
|
36
|
+
try {
|
|
37
|
+
if (!same(statCwd(), { dev: BigInt(job.dev), ino: BigInt(job.ino) })
|
|
38
|
+
|| !samePath(fs.realpathSync('.'), job.parent)) throw new Error('parent identity changed');
|
|
39
|
+
if (job.kind === 'mkdir' || job.kind === 'private-directory-mode') {
|
|
40
|
+
let expected = job.parent;
|
|
41
|
+
for (const name of job.segments) {
|
|
42
|
+
component(name);
|
|
43
|
+
let before;
|
|
44
|
+
try { before = fs.lstatSync(name, { bigint: true }); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== 'ENOENT' || job.kind !== 'mkdir') throw error;
|
|
47
|
+
try { fs.mkdirSync(name, { mode: 0o700 }); }
|
|
48
|
+
catch (mkdirError) { if (mkdirError.code !== 'EEXIST') throw mkdirError; }
|
|
49
|
+
before = fs.lstatSync(name, { bigint: true });
|
|
50
|
+
}
|
|
51
|
+
if (!before.isDirectory() || before.isSymbolicLink()) throw new Error('redirected directory');
|
|
52
|
+
process.chdir(name);
|
|
53
|
+
expected = path.join(expected, name);
|
|
54
|
+
if (!same(before, statCwd()) || !samePath(fs.realpathSync('.'), expected)) throw new Error('child identity changed');
|
|
55
|
+
}
|
|
56
|
+
if (job.kind === 'private-directory-mode' && process.platform !== 'win32') fs.chmodSync('.', 0o700);
|
|
57
|
+
} else if (job.kind === 'overlay' || job.kind === 'archive') {
|
|
58
|
+
component(job.name);
|
|
59
|
+
let before;
|
|
60
|
+
try { before = fs.lstatSync(job.name, { bigint: true }); }
|
|
61
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
62
|
+
if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n))
|
|
63
|
+
throw new Error('overlay must be a regular file');
|
|
64
|
+
const fd = fs.openSync(job.name, (job.kind === 'archive' ? fs.constants.O_RDWR : fs.constants.O_WRONLY) | (fs.constants.O_NOFOLLOW ?? 0)
|
|
65
|
+
| (fs.constants.O_NONBLOCK ?? 0) | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
|
|
66
|
+
try {
|
|
67
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
68
|
+
if (!opened.isFile() || opened.nlink !== 1n || (before && !same(before, opened)))
|
|
69
|
+
throw new Error('overlay identity changed');
|
|
70
|
+
fs.ftruncateSync(fd, 0);
|
|
71
|
+
if (job.kind === 'overlay') fs.writeFileSync(fd, job.content, 'utf8');
|
|
72
|
+
else {
|
|
73
|
+
const bytes = input.subarray(boundary + 1);
|
|
74
|
+
fs.writeFileSync(fd, bytes);
|
|
75
|
+
fs.fsyncSync(fd);
|
|
76
|
+
const hash = require('node:crypto').createHash('sha256');
|
|
77
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
while (offset < bytes.length) {
|
|
80
|
+
const count = fs.readSync(fd, chunk, 0, Math.min(chunk.length, bytes.length - offset), offset);
|
|
81
|
+
if (!count) throw new Error('incomplete archive read');
|
|
82
|
+
hash.update(chunk.subarray(0, count));
|
|
83
|
+
offset += count;
|
|
84
|
+
}
|
|
85
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
86
|
+
const current = fs.lstatSync(job.name, { bigint: true });
|
|
87
|
+
if (after.size !== BigInt(bytes.length) || after.nlink !== 1n || current.isSymbolicLink()
|
|
88
|
+
|| !same(opened, after) || !same(after, current) || hash.digest('hex') !== job.sha256)
|
|
89
|
+
throw new Error('archive verification failed');
|
|
90
|
+
}
|
|
91
|
+
} finally { fs.closeSync(fd); }
|
|
92
|
+
} else throw new Error('invalid operation');
|
|
93
|
+
} catch (error) { process.stderr.write(error.message); process.exitCode = 1; }
|
|
94
|
+
`;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BigIntStats } from "node:fs";
|
|
2
|
+
/** Validate every existing ancestor before creating anything through a pathname. */
|
|
3
|
+
export declare function assertCanonicalDirectory(target: string, allowMissing?: boolean): Promise<BigIntStats | undefined>;
|
|
4
|
+
export declare function createCanonicalDirectory(target: string): Promise<void>;
|
|
5
|
+
export declare function createPrivateCanonicalDirectory(target: string): Promise<void>;
|
|
6
|
+
export declare function restorePrivateDirectoryMode(target: string): Promise<void>;
|