@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.
Files changed (160) 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 +1562 -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-node-runtime-launcher.d.ts +2 -1
  30. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  31. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  32. package/dist/runtime/attempt-path-operation.js +94 -0
  33. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  34. package/dist/runtime/attempt-private-directory.js +54 -0
  35. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  36. package/dist/runtime/attempt-process-supervisor.js +67 -20
  37. package/dist/runtime/canonical-source-file.d.ts +3 -0
  38. package/dist/runtime/canonical-source-file.js +54 -0
  39. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  40. package/dist/runtime/docker-engine-probe.js +333 -0
  41. package/dist/runtime/generated-output-reset.d.ts +10 -0
  42. package/dist/runtime/generated-output-reset.js +62 -8
  43. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  44. package/dist/runtime/host-database-capability-boundary.js +62 -0
  45. package/dist/runtime/host-diagnostics.d.ts +11 -0
  46. package/dist/runtime/host-diagnostics.js +191 -0
  47. package/dist/runtime/host-execution-root.d.ts +4 -0
  48. package/dist/runtime/host-execution-root.js +39 -0
  49. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  50. package/dist/runtime/host-listener-inspector.js +121 -60
  51. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  52. package/dist/runtime/host-runtime-contract.js +107 -53
  53. package/dist/runtime/host-runtime-lease.d.ts +90 -8
  54. package/dist/runtime/host-runtime-lease.js +1097 -108
  55. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  56. package/dist/runtime/host-runtime-promotion.js +59 -12
  57. package/dist/runtime/memory-capacity.d.ts +70 -0
  58. package/dist/runtime/memory-capacity.js +123 -0
  59. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  60. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  61. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  62. package/dist/runtime/node-host-runtime-adapter.js +28 -11
  63. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  64. package/dist/runtime/pinned-directory-process.js +61 -0
  65. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  66. package/dist/runtime/pinned-source-directory.js +94 -0
  67. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  68. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  69. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  70. package/dist/runtime/prepared-runtime-identity.js +218 -0
  71. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  72. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  73. package/dist/runtime/runtime-isolation-lease.js +4 -1
  74. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  75. package/dist/runtime/workspace-resource-estimator.js +174 -0
  76. package/dist/sandbox/preflight.d.ts +31 -1
  77. package/dist/sandbox/preflight.js +237 -51
  78. package/dist/sandbox/repository-lock.d.ts +2 -1
  79. package/dist/sandbox/repository-lock.js +17 -8
  80. package/dist/sandbox/runtime.d.ts +6 -1
  81. package/dist/sandbox/runtime.js +132 -103
  82. package/dist/sandbox/sandbox.types.d.ts +7 -0
  83. package/dist/sandbox/workspace.service.js +2 -0
  84. package/dist/session/managed-attempt-tools.d.ts +17 -1
  85. package/dist/session/managed-attempt-tools.js +481 -31
  86. package/dist/session/managed-build-coordinator.d.ts +5 -0
  87. package/dist/session/managed-build-coordinator.js +105 -0
  88. package/dist/session/managed-build-receipt.d.ts +21 -0
  89. package/dist/session/managed-build-receipt.js +337 -0
  90. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  91. package/dist/session/managed-verification-timeouts.js +17 -0
  92. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  93. package/dist/session/phase-completion.artifacts.js +224 -22
  94. package/dist/session/phase-completion.d.ts +11 -1
  95. package/dist/session/phase-completion.helpers.d.ts +5 -0
  96. package/dist/session/phase-completion.helpers.js +30 -7
  97. package/dist/session/phase-completion.js +14 -3
  98. package/dist/session/phase-completion.prompts.js +17 -18
  99. package/dist/session/phase-completion.runtime.d.ts +3 -1
  100. package/dist/session/phase-completion.runtime.js +118 -16
  101. package/dist/session/phase-completion.schemas.js +49 -2
  102. package/dist/session/phase-completion.validation.d.ts +1 -0
  103. package/dist/session/phase-completion.validation.js +111 -21
  104. package/dist/session/pre-publication-validation.d.ts +75 -0
  105. package/dist/session/pre-publication-validation.js +657 -0
  106. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  107. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  108. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  109. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  110. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  111. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  112. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  113. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  114. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  115. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  116. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  117. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  118. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  119. package/dist/session/provider-cli/provider-cli.runtime.d.ts +8 -1
  120. package/dist/session/provider-cli/provider-cli.runtime.js +40 -3
  121. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  122. package/dist/session/provider-cli/provider-cli.service.js +771 -68
  123. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  124. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  125. package/dist/session/provider-cli/provider-output-state.js +361 -17
  126. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  127. package/dist/session/provider-cli/provider-usage.js +28 -5
  128. package/dist/session/session-redaction.js +434 -9
  129. package/dist/session/session.service.js +145 -23
  130. package/dist/session/session.types.d.ts +147 -2
  131. package/dist/session/session.types.js +587 -7
  132. package/dist/session/unreferenced-exports.d.ts +12 -0
  133. package/dist/session/unreferenced-exports.js +59 -0
  134. package/dist/shared/format/format-bytes.d.ts +1 -0
  135. package/dist/shared/format/format-bytes.js +9 -0
  136. package/dist/shared/git/git.js +2 -1
  137. package/dist/shared/path/path.d.ts +1 -0
  138. package/dist/shared/path/path.js +18 -0
  139. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  140. package/dist/shared/process/dotnet-toolchain.js +307 -0
  141. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  142. package/dist/shared/process/host-process-supervisor.js +57 -9
  143. package/dist/shared/process/process.d.ts +2 -0
  144. package/dist/shared/process/process.js +12 -7
  145. package/dist/shared/provider-capabilities.d.ts +3 -0
  146. package/dist/shared/provider-capabilities.js +85 -0
  147. package/dist/shared/redaction/redaction.d.ts +1 -0
  148. package/dist/shared/redaction/redaction.js +39 -3
  149. package/dist/version.d.ts +1 -1
  150. package/dist/version.js +1 -1
  151. package/dist/worktree/dependency-warmer.d.ts +41 -9
  152. package/dist/worktree/dependency-warmer.js +497 -22
  153. package/dist/worktree/index.d.ts +1 -1
  154. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  155. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  156. package/dist/worktree/worktree.service.d.ts +7 -0
  157. package/dist/worktree/worktree.service.js +253 -21
  158. package/dist/worktree/worktree.types.d.ts +7 -1
  159. package/package.json +7 -5
  160. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -0,0 +1,17 @@
1
+ import { runPinnedDirectoryProcess, type PinnedDirectoryIdentity } from "./pinned-directory-process.js";
2
+ export interface PinnedSourceEntry {
3
+ name: string;
4
+ kind: "directory" | "file" | "symlink" | "other";
5
+ identity: PinnedDirectoryIdentity;
6
+ size: bigint;
7
+ }
8
+ type PinnedDirectoryProcessRunner = typeof runPinnedDirectoryProcess;
9
+ export interface PinnedSourceDirectoryReadOptions {
10
+ /** Production retries are Windows-only; tests may override this explicitly. */
11
+ readonly maxAttempts?: number;
12
+ readonly processRunner?: PinnedDirectoryProcessRunner;
13
+ }
14
+ /** Enumerate from a cwd whose inode is checked by the worker, not by reopening a validated pathname. */
15
+ export declare function readPinnedSourceDirectory(directory: string, identity: PinnedDirectoryIdentity, timeoutMs?: number, terminationConfirmationMs?: number, options?: PinnedSourceDirectoryReadOptions): Promise<PinnedSourceEntry[]>;
16
+ export declare function decodePinnedSourceDirectoryInventory(output: string): PinnedSourceEntry[];
17
+ export {};
@@ -0,0 +1,94 @@
1
+ import { PinnedDirectoryProcessError, runPinnedDirectoryProcess, } from "./pinned-directory-process.js";
2
+ /** Enumerate from a cwd whose inode is checked by the worker, not by reopening a validated pathname. */
3
+ export async function readPinnedSourceDirectory(directory, identity, timeoutMs = 30_000, terminationConfirmationMs = 2_000, options = {}) {
4
+ const configuredAttempts = options.maxAttempts ?? (process.platform === "win32" ? 2 : 1);
5
+ const maxAttempts = Math.max(1, Math.min(3, Math.floor(configuredAttempts), Math.floor(timeoutMs)));
6
+ const attemptBudgetMs = Math.max(1, Math.floor(timeoutMs / maxAttempts));
7
+ const attemptTerminationConfirmationMs = Math.min(Math.max(0, terminationConfirmationMs), Math.max(0, attemptBudgetMs - 1));
8
+ const attemptTimeoutMs = Math.max(1, attemptBudgetMs - attemptTerminationConfirmationMs);
9
+ const processRunner = options.processRunner ?? runPinnedDirectoryProcess;
10
+ let lastError;
11
+ let attemptsStarted = 0;
12
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
13
+ attemptsStarted = attempt;
14
+ try {
15
+ const output = await processRunner(directory, identity, worker, { kind: "source_inventory" }, undefined, 16 * 1024 * 1024, attemptTimeoutMs, attemptTerminationConfirmationMs);
16
+ return decodePinnedSourceDirectoryInventory(output);
17
+ }
18
+ catch (error) {
19
+ lastError = error;
20
+ if (!(error instanceof PinnedDirectoryProcessError)
21
+ || error.failureKind !== "timeout"
22
+ || !error.terminationConfirmed
23
+ || attempt === maxAttempts) {
24
+ break;
25
+ }
26
+ }
27
+ }
28
+ {
29
+ const error = lastError;
30
+ const detail = error instanceof Error ? error.message : String(error);
31
+ if (detail.includes("evidence inventory exceeds retention budget")) {
32
+ throw new Error("host_diagnostics_budget: evidence inventory exceeds retention budget.", { cause: error });
33
+ }
34
+ if (detail.includes("source directory identity changed") || detail.includes("invalid operation")) {
35
+ throw new Error(`host_diagnostics_source_invalid: pinned directory enumeration failed: ${detail}`, { cause: error });
36
+ }
37
+ const reason = error instanceof PinnedDirectoryProcessError && error.failureKind === "timeout"
38
+ ? `pinned directory enumeration timed out after ${attemptsStarted} attempt${attemptsStarted === 1 ? "" : "s"} within ${timeoutMs} ms recovery budget`
39
+ : "pinned directory enumeration was temporarily unavailable";
40
+ throw new Error(`host_diagnostics_temporary_unavailable: ${reason}: ${detail}`, { cause: error });
41
+ }
42
+ }
43
+ export function decodePinnedSourceDirectoryInventory(output) {
44
+ const decoded = JSON.parse(output);
45
+ if (!Array.isArray(decoded) || decoded.length > 10000) {
46
+ throw new Error("host_diagnostics_budget: evidence inventory exceeds retention budget.");
47
+ }
48
+ return decoded.map(entry => {
49
+ if (!isSingleComponent(entry.name) || !["directory", "file", "symlink", "other"].includes(entry.kind)
50
+ || !isSignedFileIdentity(entry.dev) || !isSignedFileIdentity(entry.ino) || !/^\d+$/u.test(entry.size)) {
51
+ throw new Error("host_diagnostics_source_invalid: invalid pinned directory inventory.");
52
+ }
53
+ return {
54
+ name: entry.name, kind: entry.kind, identity: { dev: BigInt(entry.dev), ino: BigInt(entry.ino) },
55
+ size: BigInt(entry.size),
56
+ };
57
+ }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
58
+ }
59
+ function isSignedFileIdentity(value) {
60
+ // Node exposes Windows FILE_ID_128 low words through signed bigint stat fields.
61
+ // A valid NTFS identity can therefore be negative when its high bit is set.
62
+ return /^-?\d{1,64}$/u.test(value);
63
+ }
64
+ function isSingleComponent(value) {
65
+ return Boolean(value) && value !== "." && value !== ".."
66
+ && !["/", ":", "\\", "\0"].some(character => value.includes(character));
67
+ }
68
+ const worker = String.raw `
69
+ const fs = require('node:fs');
70
+ const input = fs.readFileSync(0);
71
+ const boundary = input.indexOf(10);
72
+ const job = JSON.parse(input.subarray(0, boundary).toString('utf8'));
73
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
74
+ const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
75
+ const expected = { dev: BigInt(job.dev), ino: BigInt(job.ino) };
76
+ const verify = () => {
77
+ const current = fs.statSync('.', { bigint: true });
78
+ if (!current.isDirectory() || !same(current, expected) || !samePath(fs.realpathSync('.'), job.parent))
79
+ throw new Error('source directory identity changed');
80
+ };
81
+ try {
82
+ if (job.kind !== 'source_inventory') throw new Error('invalid operation');
83
+ verify();
84
+ const entries = fs.readdirSync('.');
85
+ if (entries.length > 10000) throw new Error('evidence inventory exceeds retention budget');
86
+ const inventory = entries.map(name => {
87
+ const stat = fs.lstatSync(name, { bigint: true });
88
+ const kind = stat.isSymbolicLink() ? 'symlink' : stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other';
89
+ return { name, kind, dev: stat.dev.toString(), ino: stat.ino.toString(), size: stat.size.toString() };
90
+ });
91
+ verify();
92
+ process.stdout.write(JSON.stringify(inventory));
93
+ } catch (error) { process.stderr.write(error.message); process.exitCode = 1; }
94
+ `;
@@ -5,6 +5,7 @@ export interface PlaywrightHostRuntimeContext {
5
5
  readonly testOutputDirectory: string;
6
6
  readonly browserProfileDirectory: string;
7
7
  readonly baseUrl: string;
8
+ readonly ownershipToken: string;
8
9
  }
9
10
  export interface PreparedPlaywrightHostRuntime {
10
11
  readonly configPath: string;
@@ -2,6 +2,9 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { assertCanonicalDirectory, createPrivateCanonicalDirectory } from "./attempt-private-directory.js";
6
+ import { readCanonicalSourceFile } from "./canonical-source-file.js";
7
+ import { runAttemptPathOperation } from "./attempt-path-operation.js";
5
8
  const maximumConfigBytes = 1024 * 1024;
6
9
  /**
7
10
  * Prepares a finite Playwright test boundary for a managed-host attempt.
@@ -17,10 +20,19 @@ export class PlaywrightHostRuntimeAdapter {
17
20
  fs.realpath(sourceConfig),
18
21
  ]);
19
22
  assertContained(realWorktree, realSource, "project Playwright config");
20
- const sourceStat = await fs.stat(realSource);
21
- if (!sourceStat.isFile() || sourceStat.size < 1 || sourceStat.size > maximumConfigBytes) {
23
+ const sourceBytes = await readCanonicalSourceFile(sourceConfig, maximumConfigBytes);
24
+ if (sourceBytes.length < 1) {
22
25
  throw new Error(`playwright_runtime_adapter_invalid: project config must be a regular 1-${maximumConfigBytes} byte file.`);
23
26
  }
27
+ const sourceConfigText = sourceBytes.toString("utf8");
28
+ const projectNames = [...sourceConfigText.matchAll(/\bname\s*:\s*["']([^"']+)["']/gu)]
29
+ .map(match => match[1])
30
+ .filter((name, index, names) => names.indexOf(name) === index);
31
+ await ensureAttemptWritableDirectories([
32
+ context.temporaryDirectory,
33
+ context.testOutputDirectory,
34
+ context.browserProfileDirectory,
35
+ ], realWorktree);
24
36
  const temporaryDirectory = await realContainedDirectory(context.temporaryDirectory, realWorktree, "temporary directory");
25
37
  const testOutputDirectory = await realContainedDirectory(context.testOutputDirectory, realWorktree, "test output directory");
26
38
  const browserProfileDirectory = await realContainedDirectory(context.browserProfileDirectory, realWorktree, "browser profile directory");
@@ -29,22 +41,28 @@ export class PlaywrightHostRuntimeAdapter {
29
41
  throw new Error("playwright_runtime_adapter_invalid: writable directories must belong to the same attempt root.");
30
42
  }
31
43
  const baseUrl = loopbackHttpUrl(context.baseUrl);
44
+ if (!/^[0-9a-f]{32}$/u.test(context.ownershipToken)) {
45
+ throw new Error("playwright_runtime_adapter_invalid: ownership token must be a normalized dispatch attempt UUID.");
46
+ }
32
47
  const overlayPath = path.join(temporaryDirectory, "forge.playwright.config.mjs");
33
48
  const browserCache = await resolvePlaywrightBrowserCache(attemptRoot);
34
49
  const browserInstallCommand = await resolvePlaywrightBrowserInstallCommand(context.worktreePath, path.dirname(realSource));
35
- await fs.writeFile(overlayPath, renderOverlay({
50
+ await writeAttemptOverlay(overlayPath, renderOverlay({
36
51
  projectConfigUrl: pathToFileURL(realSource).href,
37
52
  projectConfigDirectory: path.dirname(realSource),
38
53
  testOutputDirectory,
39
54
  baseUrl,
40
55
  workers: config.workers,
41
- }), { encoding: "utf8", mode: 0o600 });
56
+ projectNames,
57
+ }));
42
58
  return {
43
59
  configPath: overlayPath,
44
60
  environment: {
45
61
  FORGE_PLAYWRIGHT_CONFIG: overlayPath,
46
62
  FORGE_PLAYWRIGHT_BASE_URL: baseUrl,
47
63
  FORGE_E2E_WEB_URL: baseUrl,
64
+ FORGE_E2E_OWNERSHIP_MODE: "attempt",
65
+ FORGE_E2E_OWNERSHIP_TOKEN: context.ownershipToken,
48
66
  FORGE_PLAYWRIGHT_BROWSER_READY: browserCache.ready ? "true" : "false",
49
67
  FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND: browserInstallCommand,
50
68
  PLAYWRIGHT_BROWSERS_PATH: browserCache.path,
@@ -58,6 +76,34 @@ export class PlaywrightHostRuntimeAdapter {
58
76
  };
59
77
  }
60
78
  }
79
+ async function ensureAttemptWritableDirectories(targets, realWorktree) {
80
+ const resolvedTargets = targets.map(target => path.resolve(target));
81
+ const attemptRoots = new Set(resolvedTargets.map(target => path.dirname(target)));
82
+ if (attemptRoots.size !== 1) {
83
+ throw new Error("playwright_runtime_adapter_invalid: writable directories must belong to the same attempt root.");
84
+ }
85
+ const attemptRoot = [...attemptRoots][0];
86
+ if (!attemptRoot || attemptRoot === realWorktree || isContained(realWorktree, attemptRoot)) {
87
+ throw new Error("playwright_runtime_adapter_invalid: writable directories must be attempt-private and outside the worktree.");
88
+ }
89
+ await assertCanonicalDirectory(attemptRoot, true);
90
+ for (const target of resolvedTargets) {
91
+ if (target === realWorktree || isContained(realWorktree, target)) {
92
+ throw new Error("playwright_runtime_adapter_invalid: writable directories must be attempt-private and outside the worktree.");
93
+ }
94
+ await assertCanonicalDirectory(target, true);
95
+ }
96
+ await createPrivateCanonicalDirectory(attemptRoot);
97
+ for (const target of resolvedTargets)
98
+ await createPrivateCanonicalDirectory(target);
99
+ }
100
+ async function writeAttemptOverlay(target, content) {
101
+ const parent = path.dirname(target);
102
+ const identity = await assertCanonicalDirectory(parent);
103
+ if (!identity)
104
+ throw new Error("playwright_runtime_adapter_invalid: overlay parent is missing.");
105
+ await runAttemptPathOperation(parent, identity, { kind: "overlay", name: path.basename(target), content });
106
+ }
61
107
  async function resolvePlaywrightBrowserInstallCommand(worktreePath, configDirectory) {
62
108
  const relativeDirectory = path.relative(worktreePath, configDirectory).replaceAll(path.sep, "/") || ".";
63
109
  if (await isFile(path.join(configDirectory, "pnpm-lock.yaml"))) {
@@ -91,7 +137,7 @@ async function resolvePlaywrightBrowserCache(attemptRoot) {
91
137
  }
92
138
  }
93
139
  const attemptCache = path.join(attemptRoot, "cache", "ms-playwright");
94
- await fs.mkdir(attemptCache, { recursive: true });
140
+ await createPrivateCanonicalDirectory(attemptCache);
95
141
  return { path: attemptCache, ready: false };
96
142
  }
97
143
  async function hasChromiumExecutable(cachePath) {
@@ -126,6 +172,7 @@ async function hasChromiumExecutable(cachePath) {
126
172
  function renderOverlay(values) {
127
173
  return [
128
174
  "// Generated by forge-bridge for one fenced host attempt.",
175
+ `// forge-playwright-projects: ${JSON.stringify(values.projectNames)}`,
129
176
  "import path from \"node:path\";",
130
177
  `import projectConfig from ${JSON.stringify(values.projectConfigUrl)};`,
131
178
  `const projectConfigDirectory = ${JSON.stringify(values.projectConfigDirectory)};`,
@@ -183,6 +230,7 @@ function renderOverlay(values) {
183
230
  ].join("\n");
184
231
  }
185
232
  async function realContainedDirectory(target, realWorktree, label) {
233
+ await assertCanonicalDirectory(target);
186
234
  const realTarget = await fs.realpath(path.resolve(target));
187
235
  if (realTarget === realWorktree || isContained(realWorktree, realTarget)) {
188
236
  throw new Error(`playwright_runtime_adapter_invalid: ${label} must be attempt-private and outside the worktree.`);
@@ -0,0 +1,6 @@
1
+ import { type HostParallelProofIdentity } from "./host-runtime-promotion.js";
2
+ export interface PreparedRuntimeIdentityDiagnostics {
3
+ readonly entries: Map<string, string>;
4
+ }
5
+ /** Bind evidence to the prepared source, including unstaged replacements, not just HEAD. */
6
+ export declare function preparedRuntimeIdentity(worktreePath: string, signal?: AbortSignal, diagnostics?: PreparedRuntimeIdentityDiagnostics): Promise<HostParallelProofIdentity>;
@@ -0,0 +1,218 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { GitClient } from "../shared/git/git.js";
5
+ import { hostRuntimeContractPath, loadHostRuntimeContract } from "./host-runtime-contract.js";
6
+ import { hostRuntimeContractFingerprint } from "./host-runtime-promotion.js";
7
+ import { readCanonicalSourceFile } from "./canonical-source-file.js";
8
+ import { assertCanonicalDirectory } from "./attempt-private-directory.js";
9
+ import { generatedOutputRootForPath } from "./generated-output-reset.js";
10
+ import { readPinnedSourceDirectory } from "./pinned-source-directory.js";
11
+ /** Bind evidence to the prepared source, including unstaged replacements, not just HEAD. */
12
+ export async function preparedRuntimeIdentity(worktreePath, signal, diagnostics) {
13
+ diagnostics?.entries.clear();
14
+ const root = await fs.realpath(worktreePath);
15
+ const rootIdentity = (await assertCanonicalDirectory(root));
16
+ const sourceKey = (target) => process.platform === "win32" ? target.toLowerCase() : target;
17
+ const expectedEntries = new Map();
18
+ const directories = new Map();
19
+ const containedRelative = (target) => {
20
+ const relative = path.relative(root, path.resolve(target));
21
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
22
+ throw new Error("host_runtime_source_invalid: source path escapes the worktree.");
23
+ }
24
+ return relative.replaceAll("\\", "/");
25
+ };
26
+ const directorySnapshot = async (target) => {
27
+ const resolved = path.resolve(target);
28
+ containedRelative(resolved);
29
+ const key = sourceKey(resolved);
30
+ let captured = directories.get(key);
31
+ if (!captured) {
32
+ captured = (async () => {
33
+ let identity;
34
+ if (key === sourceKey(root))
35
+ identity = rootIdentity;
36
+ else {
37
+ const parent = await expectedEntry(resolved);
38
+ if (!parent || parent.kind !== "directory") {
39
+ throw new Error("host_runtime_source_changed: discovery directory identity changed.");
40
+ }
41
+ identity = parent.identity;
42
+ }
43
+ const entries = await readPinnedSourceDirectory(resolved, identity);
44
+ for (const entry of entries)
45
+ expectedEntries.set(sourceKey(path.join(resolved, entry.name)), entry);
46
+ return { path: resolved, identity, entries };
47
+ })();
48
+ directories.set(key, captured);
49
+ }
50
+ return (await captured).entries.map(entry => ({
51
+ name: entry.name,
52
+ isDirectory: entry.kind === "directory",
53
+ isFile: entry.kind === "file",
54
+ }));
55
+ };
56
+ const expectedEntry = async (target) => {
57
+ const resolved = path.resolve(target);
58
+ containedRelative(resolved);
59
+ if (sourceKey(resolved) === sourceKey(root)) {
60
+ return { name: path.basename(root), kind: "directory", identity: rootIdentity, size: 0n };
61
+ }
62
+ const parentPath = path.dirname(resolved);
63
+ if (sourceKey(parentPath) !== sourceKey(root)) {
64
+ const parent = await expectedEntry(parentPath);
65
+ if (!parent)
66
+ return undefined;
67
+ if (parent.kind !== "directory") {
68
+ throw new Error("host_runtime_source_changed: discovery ancestor is not a directory.");
69
+ }
70
+ }
71
+ await directorySnapshot(parentPath);
72
+ return expectedEntries.get(sourceKey(resolved));
73
+ };
74
+ // Pin the worktree before Git or discovery uses it. Every child directory is
75
+ // subsequently reached through an identity captured from its pinned parent.
76
+ await directorySnapshot(root);
77
+ const git = new GitClient();
78
+ const head = (await git.run(root, ["rev-parse", "HEAD"])).trim();
79
+ const inventory = async () => {
80
+ const included = (name) => name && (!name.startsWith(".forge/") || name === ".forge/forge.runtime.yml");
81
+ // ls-files --deleted loses a deletion once staged. HEAD-relative tombstones
82
+ // keep the same prepared source identity before and after git add -u.
83
+ const deleted = [...new Set((await git.run(root, ["ls-files", "-z", "--deleted"]))
84
+ .split("\0").concat((await git.run(root, ["diff", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"]))
85
+ .split("\0")).filter(included))].sort();
86
+ const tracked = (await git.run(root, ["ls-files", "-z", "--cached"]))
87
+ .split("\0").filter(included);
88
+ const untracked = (await git.run(root, ["ls-files", "-z", "--others", "--exclude-standard"]))
89
+ .split("\0").filter(included);
90
+ const generatedRoots = [...new Set(untracked
91
+ .map(generatedOutputRootForPath)
92
+ .filter((candidate) => candidate !== null))];
93
+ const comparisonKey = (value) => process.platform === "win32" ? value.toLowerCase() : value;
94
+ const containedBy = (name, candidate) => {
95
+ const key = comparisonKey(name);
96
+ const rootKey = comparisonKey(candidate);
97
+ return key === rootKey || key.startsWith(`${rootKey}/`);
98
+ };
99
+ // resetGeneratedOutputs only quarantines a generated directory when Git
100
+ // confirms it contains no tracked files. Mirror that boundary here so the
101
+ // cleanup performed by runtime.start cannot invalidate its own foreground
102
+ // proof, while tracked or mixed-content directories remain source-bound.
103
+ const resettableRoots = generatedRoots.filter(candidate => !tracked.some(name => containedBy(name, candidate)));
104
+ const names = [...new Set(tracked.concat(untracked.filter(name => !resettableRoots.some(candidate => containedBy(name, candidate))), deleted, hostRuntimeContractPath).filter(included))].sort();
105
+ return { names, deleted };
106
+ };
107
+ const source = await inventory();
108
+ const { names } = source;
109
+ if (names.length > 100_000)
110
+ throw new Error("host_runtime_source_invalid: source inventory exceeds the file limit.");
111
+ const deleted = new Set(source.deleted);
112
+ const hash = createHash("sha256");
113
+ const snapshot = new Map();
114
+ const additional = new Map();
115
+ let discoveryCapture = Promise.resolve();
116
+ let bytes = 0;
117
+ for (const name of names) {
118
+ signal?.throwIfAborted();
119
+ const target = path.resolve(root, name);
120
+ const relative = path.relative(root, target);
121
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
122
+ throw new Error("host_runtime_source_invalid: source path escapes the worktree.");
123
+ }
124
+ hash.update(JSON.stringify(name));
125
+ try {
126
+ const expected = await expectedEntry(target);
127
+ if (!expected || expected.kind !== "file") {
128
+ throw Object.assign(new Error("host_runtime_source_changed: source inventory no longer names an ordinary file."), { code: "ENOENT" });
129
+ }
130
+ const sourceBytes = await readCanonicalSourceFile(target, 256 * 1024 * 1024 - bytes, expected.identity);
131
+ bytes += sourceBytes.length;
132
+ const digest = createHash("sha256").update(sourceBytes).digest();
133
+ hash.update(digest);
134
+ diagnostics?.entries.set(name, digest.toString("hex"));
135
+ snapshot.set(sourceKey(target), Promise.resolve(sourceBytes));
136
+ }
137
+ catch (error) {
138
+ if (error.code !== "ENOENT" || (!deleted.has(name) && name !== hostRuntimeContractPath))
139
+ throw error;
140
+ const disposition = deleted.has(name) ? "deleted" : "absent";
141
+ hash.update(disposition);
142
+ diagnostics?.entries.set(name, disposition);
143
+ snapshot.set(sourceKey(target), Promise.resolve(null));
144
+ }
145
+ }
146
+ const contract = await loadHostRuntimeContract(root, target => {
147
+ const key = sourceKey(path.resolve(target));
148
+ const captured = snapshot.get(key);
149
+ if (captured)
150
+ return captured;
151
+ // Discovery also consumes ignored lockfiles and launch metadata. Capture
152
+ // those once, too; omission from Git's inventory must not change discovery.
153
+ const capture = discoveryCapture.then(async () => {
154
+ signal?.throwIfAborted();
155
+ const relative = path.relative(root, target).replaceAll("\\", "/");
156
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
157
+ throw new Error("host_runtime_source_invalid: discovery input escapes the worktree.");
158
+ }
159
+ let sourceBytes;
160
+ try {
161
+ const expected = await expectedEntry(target);
162
+ if (!expected)
163
+ sourceBytes = null;
164
+ else if (expected.kind !== "file") {
165
+ throw new Error("host_runtime_source_changed: discovery input is not an ordinary file.");
166
+ }
167
+ else {
168
+ sourceBytes = await readCanonicalSourceFile(target, 256 * 1024 * 1024 - bytes, expected.identity);
169
+ }
170
+ if (sourceBytes !== null) {
171
+ bytes += sourceBytes.length;
172
+ if (bytes > 256 * 1024 * 1024)
173
+ throw new Error("host_runtime_source_invalid: discovery exceeds the byte limit.");
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (error.code !== "ENOENT")
178
+ throw error;
179
+ sourceBytes = null;
180
+ }
181
+ additional.set(relative, sourceBytes);
182
+ return sourceBytes;
183
+ });
184
+ // Discovery walks in parallel, but reads share one total byte budget.
185
+ discoveryCapture = capture.then(() => undefined, () => undefined);
186
+ snapshot.set(key, capture);
187
+ return capture;
188
+ }, directorySnapshot);
189
+ for (const [name, sourceBytes] of [...additional].sort(([left], [right]) => left.localeCompare(right))) {
190
+ hash.update(JSON.stringify(["runtime-discovery", name]));
191
+ const digest = sourceBytes === null
192
+ ? null
193
+ : createHash("sha256").update(sourceBytes).digest();
194
+ hash.update(digest ?? "absent");
195
+ diagnostics?.entries.set(`runtime-discovery:${name}`, digest?.toString("hex") ?? "absent");
196
+ }
197
+ if (head !== (await git.run(root, ["rev-parse", "HEAD"])).trim()
198
+ || JSON.stringify(source) !== JSON.stringify(await inventory())) {
199
+ throw new Error("host_runtime_source_changed: prepared source changed during verification.");
200
+ }
201
+ for (const captured of await Promise.all([...directories.values()])) {
202
+ const current = await readPinnedSourceDirectory(captured.path, captured.identity);
203
+ if (directoryInventory(current) !== directoryInventory(captured.entries)) {
204
+ throw new Error("host_runtime_source_changed: runtime discovery inventory changed during verification.");
205
+ }
206
+ }
207
+ return {
208
+ inputCommitSha: head,
209
+ runtimeContractFingerprint: hostRuntimeContractFingerprint(contract),
210
+ sourceFingerprint: `sha256:${hash.digest("hex")}`,
211
+ };
212
+ }
213
+ function directoryInventory(entries) {
214
+ return JSON.stringify(entries.map(entry => ({
215
+ name: entry.name,
216
+ kind: entry.kind,
217
+ })));
218
+ }
@@ -0,0 +1,30 @@
1
+ import type { RunOnceDispatchPacket } from "../session/session.types.js";
2
+ import type { HostRuntimeAdmissionCheckpointHandler, HostRuntimeLeaseRuntime } from "./host-runtime-lease.js";
3
+ import { HostParallelPromotionStore, type HostParallelProofIdentity } from "./host-runtime-promotion.js";
4
+ export interface PreparedRuntimePendingSnapshot {
5
+ readonly repoId: string;
6
+ readonly dispatchAttemptId: string;
7
+ readonly fencingToken: string;
8
+ readonly probeDispatchAttemptId?: string;
9
+ readonly probeFencingToken?: string;
10
+ }
11
+ /** Foreground barrier: prepare a wave, verify its exact runtimes, then release agents. */
12
+ export declare class PreparedRuntimeReadiness {
13
+ private readonly runtime;
14
+ private readonly store;
15
+ private readonly releasePreparation;
16
+ private readonly admissionExpired;
17
+ private readonly preparations;
18
+ private readonly draining;
19
+ constructor(runtime: HostRuntimeLeaseRuntime, store: HostParallelPromotionStore, releasePreparation: (id: string, fence: string) => Promise<unknown>, admissionExpired: () => Error);
20
+ acquire(dispatch: RunOnceDispatchPacket, signal?: AbortSignal): Promise<readonly string[]>;
21
+ has(id: string): boolean;
22
+ hasPending(): boolean;
23
+ pendingSnapshots(): readonly PreparedRuntimePendingSnapshot[];
24
+ verify(dispatch: RunOnceDispatchPacket, worktree: string, checkpoint?: HostRuntimeAdmissionCheckpointHandler): Promise<HostParallelProofIdentity | undefined>;
25
+ renew(id: string, fence: string, expiresAt: string): Promise<void>;
26
+ cancel(id: string, fence: string, reason?: Error): void;
27
+ private drain;
28
+ private scheduleDeadline;
29
+ private check;
30
+ }