@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.04a1e5b9e484

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.
Files changed (165) hide show
  1. package/README.md +88 -2
  2. package/dist/bridge/bridge-chat.types.d.ts +111 -0
  3. package/dist/bridge/bridge-chat.types.js +401 -0
  4. package/dist/bridge/bridge.base.d.ts +4 -1
  5. package/dist/bridge/bridge.service.d.ts +72 -3
  6. package/dist/bridge/bridge.service.js +1563 -152
  7. package/dist/bridge/bridge.types.d.ts +46 -1
  8. package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
  9. package/dist/bridge/dispatch-result-outbox.js +62 -0
  10. package/dist/bridge/dispatch-result-worker.d.ts +2 -0
  11. package/dist/bridge/dispatch-result-worker.js +122 -0
  12. package/dist/bridge/internals.d.ts +2 -0
  13. package/dist/bridge/internals.js +1 -0
  14. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  15. package/dist/bridge/readiness-result-outbox.js +54 -0
  16. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  17. package/dist/bridge/readiness-result-worker.js +121 -0
  18. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  19. package/dist/bridge/runner-host-capabilities.js +93 -0
  20. package/dist/cleanup/cleanup.service.d.ts +1 -0
  21. package/dist/cleanup/cleanup.service.js +70 -9
  22. package/dist/cli/cli.help.js +2 -2
  23. package/dist/cli/cli.service.d.ts +14 -6
  24. package/dist/cli/cli.service.js +75 -16
  25. package/dist/cli/local-config.d.ts +1 -0
  26. package/dist/cli/local-config.js +4 -0
  27. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  28. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  29. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  30. package/dist/runtime/attempt-database-broker.js +390 -0
  31. package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
  32. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  33. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  34. package/dist/runtime/attempt-path-operation.js +94 -0
  35. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  36. package/dist/runtime/attempt-private-directory.js +54 -0
  37. package/dist/runtime/attempt-process-supervisor.d.ts +16 -0
  38. package/dist/runtime/attempt-process-supervisor.js +94 -23
  39. package/dist/runtime/canonical-source-file.d.ts +3 -0
  40. package/dist/runtime/canonical-source-file.js +54 -0
  41. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  42. package/dist/runtime/docker-engine-probe.js +333 -0
  43. package/dist/runtime/generated-output-reset.d.ts +10 -0
  44. package/dist/runtime/generated-output-reset.js +62 -8
  45. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  46. package/dist/runtime/host-database-capability-boundary.js +62 -0
  47. package/dist/runtime/host-diagnostics.d.ts +11 -0
  48. package/dist/runtime/host-diagnostics.js +192 -0
  49. package/dist/runtime/host-execution-root.d.ts +4 -0
  50. package/dist/runtime/host-execution-root.js +39 -0
  51. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  52. package/dist/runtime/host-listener-inspector.js +121 -60
  53. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  54. package/dist/runtime/host-runtime-contract.js +168 -55
  55. package/dist/runtime/host-runtime-lease.d.ts +92 -8
  56. package/dist/runtime/host-runtime-lease.js +1141 -114
  57. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  58. package/dist/runtime/host-runtime-promotion.js +59 -12
  59. package/dist/runtime/memory-capacity.d.ts +70 -0
  60. package/dist/runtime/memory-capacity.js +123 -0
  61. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  62. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  63. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  64. package/dist/runtime/node-host-runtime-adapter.js +29 -11
  65. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  66. package/dist/runtime/pinned-directory-process.js +61 -0
  67. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  68. package/dist/runtime/pinned-source-directory.js +94 -0
  69. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  70. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  71. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  72. package/dist/runtime/prepared-runtime-identity.js +218 -0
  73. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  74. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  75. package/dist/runtime/runtime-isolation-lease.js +4 -1
  76. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  77. package/dist/runtime/workspace-resource-estimator.js +174 -0
  78. package/dist/sandbox/preflight.d.ts +31 -1
  79. package/dist/sandbox/preflight.js +237 -51
  80. package/dist/sandbox/repository-lock.d.ts +2 -1
  81. package/dist/sandbox/repository-lock.js +19 -9
  82. package/dist/sandbox/runtime.d.ts +6 -1
  83. package/dist/sandbox/runtime.js +137 -107
  84. package/dist/sandbox/sandbox.types.d.ts +7 -0
  85. package/dist/sandbox/workspace.service.js +2 -0
  86. package/dist/session/managed-attempt-tools.d.ts +17 -1
  87. package/dist/session/managed-attempt-tools.js +489 -34
  88. package/dist/session/managed-build-coordinator.d.ts +5 -0
  89. package/dist/session/managed-build-coordinator.js +106 -0
  90. package/dist/session/managed-build-receipt.d.ts +21 -0
  91. package/dist/session/managed-build-receipt.js +337 -0
  92. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  93. package/dist/session/managed-verification-timeouts.js +17 -0
  94. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  95. package/dist/session/phase-completion.artifacts.js +224 -22
  96. package/dist/session/phase-completion.d.ts +11 -1
  97. package/dist/session/phase-completion.helpers.d.ts +5 -0
  98. package/dist/session/phase-completion.helpers.js +30 -7
  99. package/dist/session/phase-completion.js +14 -3
  100. package/dist/session/phase-completion.prompts.js +17 -18
  101. package/dist/session/phase-completion.runtime.d.ts +3 -1
  102. package/dist/session/phase-completion.runtime.js +126 -17
  103. package/dist/session/phase-completion.schemas.js +49 -2
  104. package/dist/session/phase-completion.validation.d.ts +1 -0
  105. package/dist/session/phase-completion.validation.js +111 -21
  106. package/dist/session/pre-publication-validation.d.ts +75 -0
  107. package/dist/session/pre-publication-validation.js +661 -0
  108. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  109. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  110. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  111. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  112. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  113. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  114. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  115. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  116. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  117. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  118. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  119. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  120. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  121. package/dist/session/provider-cli/provider-cli.runtime.d.ts +14 -1
  122. package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
  123. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  124. package/dist/session/provider-cli/provider-cli.service.js +773 -69
  125. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  126. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  127. package/dist/session/provider-cli/provider-output-state.js +361 -17
  128. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  129. package/dist/session/provider-cli/provider-usage.js +28 -5
  130. package/dist/session/session-redaction.js +434 -9
  131. package/dist/session/session.service.js +145 -23
  132. package/dist/session/session.types.d.ts +147 -2
  133. package/dist/session/session.types.js +587 -7
  134. package/dist/session/unreferenced-exports.d.ts +12 -0
  135. package/dist/session/unreferenced-exports.js +59 -0
  136. package/dist/shared/format/format-bytes.d.ts +1 -0
  137. package/dist/shared/format/format-bytes.js +9 -0
  138. package/dist/shared/git/git.js +2 -1
  139. package/dist/shared/path/path.d.ts +1 -0
  140. package/dist/shared/path/path.js +18 -0
  141. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  142. package/dist/shared/process/dotnet-toolchain.js +307 -0
  143. package/dist/shared/process/host-process-recovery.js +3 -2
  144. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  145. package/dist/shared/process/host-process-supervisor.js +60 -11
  146. package/dist/shared/process/process.d.ts +2 -0
  147. package/dist/shared/process/process.js +12 -7
  148. package/dist/shared/provider-capabilities.d.ts +3 -0
  149. package/dist/shared/provider-capabilities.js +85 -0
  150. package/dist/shared/redaction/redaction.d.ts +1 -0
  151. package/dist/shared/redaction/redaction.js +39 -3
  152. package/dist/shared/time/monotonic.d.ts +1 -0
  153. package/dist/shared/time/monotonic.js +8 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/dist/worktree/dependency-warmer.d.ts +41 -9
  157. package/dist/worktree/dependency-warmer.js +497 -22
  158. package/dist/worktree/index.d.ts +1 -1
  159. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  160. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  161. package/dist/worktree/worktree.service.d.ts +7 -0
  162. package/dist/worktree/worktree.service.js +253 -21
  163. package/dist/worktree/worktree.types.d.ts +7 -1
  164. package/package.json +7 -5
  165. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -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
+ }
@@ -8,8 +8,9 @@ export declare class AttemptNodeRuntimeLauncher implements NodeRuntimeCommandLau
8
8
  private readonly owner;
9
9
  private readonly logDirectory;
10
10
  private readonly listenerInspector;
11
+ private readonly exactSecrets;
11
12
  private readonly diagnosticsByChild;
12
- constructor(supervisor: AttemptProcessSupervisor, owner: HostPortLeaseOwner, logDirectory: string, listenerInspector?: HostListenerInspector);
13
+ constructor(supervisor: AttemptProcessSupervisor, owner: HostPortLeaseOwner, logDirectory: string, listenerInspector?: HostListenerInspector, exactSecrets?: readonly string[]);
13
14
  launch(request: NodeRuntimeLaunchRequest, signal?: AbortSignal): Promise<AttemptProcessChildHandle>;
14
15
  inspectListener(child: AttemptProcessChildHandle, request: NodeRuntimeLaunchRequest): Promise<HostListenerOwnership>;
15
16
  stop(child: AttemptProcessChildHandle): Promise<void>;
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { redactText } from "../shared/redaction/redaction.js";
5
+ import { ForgeFailureError, sanitizeFailureError } from "../session/session.types.js";
4
6
  import { HostListenerInspector } from "./host-listener-inspector.js";
5
7
  const diagnosticTailLength = 4_000;
6
8
  /** Launches one explicitly configured HTTP project beneath the attempt coordinator. */
@@ -9,18 +11,20 @@ export class AttemptNodeRuntimeLauncher {
9
11
  owner;
10
12
  logDirectory;
11
13
  listenerInspector;
14
+ exactSecrets;
12
15
  diagnosticsByChild = new WeakMap();
13
- constructor(supervisor, owner, logDirectory, listenerInspector = new HostListenerInspector()) {
16
+ constructor(supervisor, owner, logDirectory, listenerInspector = new HostListenerInspector(), exactSecrets = []) {
14
17
  this.supervisor = supervisor;
15
18
  this.owner = owner;
16
19
  this.logDirectory = logDirectory;
17
20
  this.listenerInspector = listenerInspector;
21
+ this.exactSecrets = exactSecrets;
18
22
  }
19
23
  async launch(request, signal) {
20
24
  const childName = projectChildName(request.port.name);
21
25
  const stdoutPath = path.join(this.logDirectory, `${childName}.stdout.log`);
22
26
  const stderrPath = path.join(this.logDirectory, `${childName}.stderr.log`);
23
- const diagnostics = { exited: false, stdoutTail: "", stderrTail: "" };
27
+ const diagnostics = { exited: false, stdoutTail: "", stderrTail: "", outputRevision: 0 };
24
28
  let child;
25
29
  try {
26
30
  child = await this.supervisor.startChildReady(childName, {
@@ -29,31 +33,39 @@ export class AttemptNodeRuntimeLauncher {
29
33
  cwd: request.cwd,
30
34
  env: request.environment,
31
35
  captureOutput: false,
36
+ failureRedactionValues: this.exactSecrets,
32
37
  }, {
33
38
  onStdoutLine: async (line) => {
34
- diagnostics.stdoutTail = appendTail(diagnostics.stdoutTail, line);
35
- await appendLine(stdoutPath, line);
39
+ const redacted = redactText(line, this.exactSecrets);
40
+ diagnostics.stdoutTail = appendTail(diagnostics.stdoutTail, redacted);
41
+ diagnostics.outputRevision += 1;
42
+ await appendLine(stdoutPath, redacted);
36
43
  },
37
44
  onStderrLine: async (line) => {
38
- diagnostics.stderrTail = appendTail(diagnostics.stderrTail, line);
39
- await appendLine(stderrPath, line);
45
+ const redacted = redactText(line, this.exactSecrets);
46
+ diagnostics.stderrTail = appendTail(diagnostics.stderrTail, redacted);
47
+ diagnostics.outputRevision += 1;
48
+ await appendLine(stderrPath, redacted);
40
49
  },
41
50
  }, signal, { maxStartAttempts: 3, retryDelayMs: 250 });
42
51
  }
43
52
  catch (error) {
44
- throw enrichLaunchFailure(error, diagnostics);
53
+ throw enrichLaunchFailure(error, diagnostics, this.exactSecrets);
45
54
  }
46
- this.diagnosticsByChild.set(child, diagnostics);
47
- void child.result.then(result => {
55
+ const exposedChild = sanitizedChildHandle(child, this.exactSecrets);
56
+ this.diagnosticsByChild.set(exposedChild, diagnostics);
57
+ void exposedChild.result.then(result => {
48
58
  diagnostics.exited = true;
49
59
  diagnostics.exitCode = result.exitCode;
50
60
  diagnostics.stdoutTail = appendTail(diagnostics.stdoutTail, result.stdout);
51
61
  diagnostics.stderrTail = appendTail(diagnostics.stderrTail, result.stderr);
62
+ diagnostics.outputRevision += 1;
52
63
  }, error => {
53
64
  diagnostics.exited = true;
54
- diagnostics.stderrTail = appendTail(diagnostics.stderrTail, error instanceof Error ? error.message : String(error));
65
+ diagnostics.stderrTail = appendTail(diagnostics.stderrTail, redactText(error instanceof Error ? error.message : String(error), this.exactSecrets));
66
+ diagnostics.outputRevision += 1;
55
67
  });
56
- return child;
68
+ return exposedChild;
57
69
  }
58
70
  async inspectListener(child, request) {
59
71
  return await this.listenerInspector.inspect(child, request.port, this.owner);
@@ -84,17 +96,44 @@ function appendTail(current, value) {
84
96
  const combined = current ? `${current}\n${value}` : value;
85
97
  return combined.slice(-diagnosticTailLength);
86
98
  }
87
- function enrichLaunchFailure(error, diagnostics) {
88
- const message = error instanceof Error ? error.message : String(error);
99
+ function enrichLaunchFailure(error, diagnostics, exactSecrets) {
100
+ const sanitized = sanitizeFailureError(error, exactSecrets);
89
101
  const evidence = [
90
102
  diagnostics.stderrTail ? `stderr tail: ${boundedDiagnostic(diagnostics.stderrTail)}` : "",
91
103
  diagnostics.stdoutTail ? `stdout tail: ${boundedDiagnostic(diagnostics.stdoutTail)}` : "",
92
104
  ].filter(Boolean);
93
- return evidence.length > 0
94
- ? new Error(`${message} ${evidence.join("; ")}`, { cause: error })
95
- : error instanceof Error
96
- ? error
97
- : new Error(message);
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);
98
137
  }
99
138
  function boundedDiagnostic(value) {
100
139
  return value.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(-1_000);
@@ -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>;