flowviant 0.115.0 → 0.117.0

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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -11,17 +11,27 @@
11
11
  * measured "not installed" under the tray (2026-09-25).
12
12
  *
13
13
  * So the daemon asks the person's login shell for its PATH once, and keeps
14
- * every entry it already had FIRST (a terminal start is unchanged) with the
14
+ * every entry it already had FIRST (a terminal start keeps its order) with the
15
15
  * shell's additions after it; then the usual per-user bin directories that
16
16
  * exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
17
- * few seconds once, and nothing is ever removed from PATH.
17
+ * few seconds once.
18
+ *
19
+ * ONLY ABSOLUTE ENTRIES ARE KEPT, from every input — the current PATH too
20
+ * (2026-10-10, F118). A relative entry (`node_modules/.bin`, `.`, and the empty
21
+ * entry, which also means "here") resolves against whatever directory a child
22
+ * is spawned in, and the daemon spawns `git`, `gh`, `cloudflared` and the CLIs
23
+ * by bare name with `cwd` set to an agent's worktree: a binary an agent plants
24
+ * there would run as the daemon, outside any sandbox, with its credentials. A
25
+ * common ~/.bashrc idiom is enough to open that. `FLOWVIANT_KEEP_PATH=1` is the
26
+ * operator's explicit opt-out and leaves PATH exactly as they set it.
18
27
  */
19
28
 
20
- import { execFileSync, spawn } from 'node:child_process';
29
+ import { execFileSync } from 'node:child_process';
21
30
  import { existsSync, readdirSync } from 'node:fs';
22
31
  import { homedir, platform } from 'node:os';
23
32
  import { join } from 'node:path';
24
33
  import { compareVersions } from './versionOrder.mjs';
34
+ import { relaunchSelf, selfArgv } from './launchCommand.mjs';
25
35
 
26
36
  const MARK = '__FLOWVIANT_PATH__=';
27
37
 
@@ -60,12 +70,13 @@ export function userBinDirs({ home = homedir(), exists = existsSync, list = read
60
70
  return dirs.filter((d) => exists(d));
61
71
  }
62
72
 
63
- /** Current entries first, then any the others add. Order-preserving, deduped. */
73
+ /** Current entries first, then any the others add. Order-preserving, deduped,
74
+ * absolute only (see the header): an empty or relative entry is dropped. */
64
75
  export function mergePath(current, ...more) {
65
76
  const seen = new Set();
66
77
  const out = [];
67
78
  for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
68
- if (!part || seen.has(part)) continue;
79
+ if (!part || !part.startsWith('/') || seen.has(part)) continue;
69
80
  seen.add(part);
70
81
  out.push(part);
71
82
  }
@@ -91,24 +102,18 @@ export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}
91
102
  * processes are looked up and started with the environment the process
92
103
  * STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
93
104
  * invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
94
- * the change, the compiled binary did not). So when the PATH grew, the
95
- * binary restarts itself once with it: a thin proxy, the same shape as the
96
- * self-update re-exec, stdio inherited, exit code and signals passed through.
97
- * Node needs none of that and adopts in place.
105
+ * the change, the compiled binary did not). So when the PATH changed, the
106
+ * binary restarts itself once with it through the one relaunch proxy
107
+ * (launchCommand.mjs `relaunchSelf`): stdio inherited, exit code and signals
108
+ * passed through. Node needs none of that and adopts in place.
98
109
  */
99
110
  export async function adoptLoginPathForProcess(opts = {}) {
100
111
  if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
101
112
  const before = process.env.PATH;
102
113
  const after = adoptLoginPath(opts);
103
114
  if (!process.versions.bun || after === before) return;
104
- const compiled = process.argv[1]?.startsWith('/$bunfs/');
105
- const child = spawn(process.execPath, process.argv.slice(compiled ? 2 : 1), {
106
- stdio: 'inherit',
115
+ await relaunchSelf({
116
+ argv: selfArgv(process.argv.slice(2)),
107
117
  env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
108
118
  });
109
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
110
- process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
111
- }
112
- const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
113
- process.exit(code);
114
119
  }
@@ -92,11 +92,14 @@ const JUST_NOW = 'just now';
92
92
  /**
93
93
  * A coarse age. Never "0s": a box heard a moment ago reads as "just now",
94
94
  * because a daemon that polled four hundred milliseconds ago has not been
95
- * silent for no time at all — the same rule `agoLabel` keeps in holder.mjs and
96
- * `elapsed` keeps in the web.
95
+ * silent for no time at all — the same rule `elapsed` keeps in the web.
97
96
  *
98
97
  * NULL for anything unmeasurable, so the caller DROPS the clause rather than
99
- * printing a duration nobody took.
98
+ * printing a duration nobody took — the one rule this shares with holder.mjs's
99
+ * `agoLabel`. That one is a different formatter on purpose (2026-10-10, F084:
100
+ * this comment used to claim it kept the "never 0s" rule too): it prints a
101
+ * measured zero as "0s" and steps at 60s/60m/48h, and holder.test.mjs pins
102
+ * both.
100
103
  */
101
104
  export function agoFrom(iso, now = Date.now()) {
102
105
  const t = Date.parse(iso ?? '');
@@ -17,13 +17,16 @@
17
17
  * never from a CLI credential sitting on a shared machine.
18
18
  */
19
19
 
20
- import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
20
+ import { FLEET_TOKEN, MCP_URL, FLEET_URL } from './config.mjs';
21
21
  import { terminalCommand } from './launchCommand.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
+ import { postFleetJson } from './fleetPost.mjs';
23
24
 
24
25
  const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
25
26
 
26
- export async function runMcpCommand(args = []) {
27
+ /** `timeoutMs` is the request's ceiling — injectable only so a test can prove
28
+ * a hung server is abandoned without waiting thirty seconds. */
29
+ export async function runMcpCommand(args = [], { timeoutMs = 30_000 } = {}) {
27
30
  if (!FLEET_TOKEN) {
28
31
  console.error(
29
32
  `error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
@@ -33,16 +36,12 @@ export async function runMcpCommand(args = []) {
33
36
  process.exit(1);
34
37
  }
35
38
 
39
+ // BOUNDED (audit 2026-10-10, F102): this was the one fleet request with no
40
+ // timeout, so a hung server held the command forever. A timeout lands in
41
+ // the catch below and says so like any other unreachable server.
36
42
  let res;
37
43
  try {
38
- res = await fetch(CLI_TOKEN_URL, {
39
- method: 'POST',
40
- headers: {
41
- authorization: `Bearer ${FLEET_TOKEN}`,
42
- 'content-type': 'application/json',
43
- 'user-agent': USER_AGENT,
44
- },
45
- });
44
+ res = await postFleetJson(CLI_TOKEN_URL, undefined, { timeoutMs });
46
45
  } catch (err) {
47
46
  console.error(`error: could not reach Flowviant (${err?.message || err})`);
48
47
  process.exit(1);
@@ -41,9 +41,11 @@
41
41
  * purpose through WSL interop (`explorer.exe`, `cmd.exe /c start`); the kind
42
42
  * contracts ask it not to (artifactContracts.mjs, `NO_WINDOW`).
43
43
  */
44
- import { lstatSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
44
+ import { mkdtempSync, rmSync } from 'node:fs';
45
45
  import { tmpdir } from 'node:os';
46
46
  import { join } from 'node:path';
47
+ import { withoutMachineCredentials } from './machineEnv.mjs';
48
+ import { ensureOwnDir } from './ownedDirs.mjs';
47
49
 
48
50
  /** Removed outright; WAYLAND_DISPLAY is kept but emptied (see above). */
49
51
  export const WINDOW_ENV_REMOVED = Object.freeze(['DISPLAY', 'WAYLAND_SOCKET']);
@@ -62,6 +64,24 @@ export function withoutWindows(env, browserHome = null) {
62
64
  return out;
63
65
  }
64
66
 
67
+ /**
68
+ * EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
69
+ * audit): no machine credential (machineEnv.mjs), and no way to a window
70
+ * unless a person at the keyboard asked for one (`display: true`, a Terminal
71
+ * tab's alone). It was composed by hand twice — a turn's `cliEnv`
72
+ * (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
73
+ * skipped outright by the two spawns the daemon makes for its own purposes:
74
+ * the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
75
+ * the person's stdio MCP servers and the checkout's SessionStart hooks with
76
+ * the machine credential in their environment, and the Codex offerings reads
77
+ * (runtimeOfferings.mjs), whose `codex mcp list` may start configured servers.
78
+ * Every one of them now asks here. Never mutates its input.
79
+ */
80
+ export function unattendedEnv(env = process.env, { display = false, browserHome = null } = {}) {
81
+ const out = withoutMachineCredentials(env);
82
+ return display === true ? out : withoutWindows(out, browserHome);
83
+ }
84
+
65
85
  /**
66
86
  * AN AGENT'S BROWSER HOME is kept beside its Codex home, in its worktree's
67
87
  * private git dir: never committed, the same profile turn after turn, and
@@ -82,8 +102,8 @@ export const agentBrowserHome = (sessionMetaPath, wt, agentId) =>
82
102
  export function openBrowserHome(kept) {
83
103
  if (kept) {
84
104
  try {
85
- try { if (!lstatSync(kept).isDirectory()) rmSync(kept, { force: true }); } catch { /* absent */ }
86
- mkdirSync(kept, { recursive: true, mode: 0o700 });
105
+ // ownedDirs.mjs (2026-10-10): the link goes, never its target.
106
+ ensureOwnDir(kept, { mode: 0o700 });
87
107
  return { dir: kept, cleanup: () => {} };
88
108
  } catch { /* fall through to a turn's own */ }
89
109
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DIRECTORIES THE DAEMON WRITES INTO THAT A REPOSITORY COULD HAVE PLANTED —
3
+ * one home for the rule "never follow a link a checkout carries under
4
+ * `.flowviant/`" (2026-10-10, the audit's F100 / F080).
5
+ *
6
+ * git commits symlinks. A checkout can arrive carrying `.flowviant -> ~/.ssh`,
7
+ * or `.flowviant/uploads -> ../../../../.ssh` (a worktree's depth under
8
+ * `~/.flowviant/worktrees` is deterministic), and any daemon write through
9
+ * either lands outside the place it was meant for, as the operator. The rule
10
+ * that stops it was hand-written in ten places in four shapes — a boolean, a
11
+ * true/null/false, a 'dir'/'none'/'other', a replace-then-mkdir — and the
12
+ * attachment lane, the one that writes a MEMBER'S bytes under a member's name,
13
+ * had none of them. Each new `.flowviant/<x>` writer had to rediscover it.
14
+ *
15
+ * TWO POLICIES, deliberately two doors:
16
+ *
17
+ * - REFUSED, NEVER REPLACED (`ensureRealSubdir`): `.flowviant/` and the
18
+ * directories under it a repository could hold — the artifacts directory,
19
+ * the uploads directory. `.flowviant/` is not only ours (a repo declares
20
+ * `check.json` and `deploy.json` there), so a link or a file at one of
21
+ * these names is the repository's content: Flowviant deleting it would be
22
+ * destroying what it did not create, and following it is the write outside
23
+ * the worktree. Either answer is "no", and the caller says so in words.
24
+ * - REPLACED, NEVER FOLLOWED (`ensureOwnDir`): a path that is wholly the
25
+ * daemon's — the knowledge library under a parent its caller has already
26
+ * checked, a kept browser home. A link or a file planted there is removed
27
+ * (the link, never its target) and a real directory made in its place.
28
+ *
29
+ * Leaf: node:fs and node:path only.
30
+ */
31
+ import { lstatSync, mkdirSync, rmSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+
34
+ /**
35
+ * What stands at `p`, by `lstat` and never followed: `'dir'` for a real
36
+ * directory, `'none'` for nothing at all, `'other'` for anything else — a
37
+ * symlink (to a directory or dangling), a regular file, or a path this cannot
38
+ * look at (a component that is a file, no permission). The unknown case is
39
+ * `'other'`, never `'none'`: an answer of "absent" invites a create.
40
+ */
41
+ export function realDirState(p) {
42
+ try {
43
+ const st = lstatSync(p);
44
+ return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
45
+ } catch (e) {
46
+ return e?.code === 'ENOENT' ? 'none' : 'other';
47
+ }
48
+ }
49
+
50
+ /** `<placeDir>/.flowviant`, by the same three states. */
51
+ export const flowviantParentState = (placeDir) => realDirState(join(placeDir, '.flowviant'));
52
+
53
+ /**
54
+ * `<placeDir>/<rel>` as a real directory, every component of `rel` a real
55
+ * directory too — or FALSE, with nothing replaced.
56
+ *
57
+ * Each component must already be a real directory or be absent; any one that
58
+ * is a link or a non-directory refuses before anything is made. The missing
59
+ * ones are made, and then EVERY component is checked again: a link swapped in
60
+ * between the look and the make is caught here, and the caller writes
61
+ * nothing. `rel` is a relative path of plain names (`.flowviant/uploads`); a
62
+ * `..`, an empty or an absolute component refuses.
63
+ */
64
+ export function ensureRealSubdir(placeDir, rel) {
65
+ const parts = String(rel).split('/');
66
+ if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
67
+ const paths = parts.map((_, i) => join(placeDir, ...parts.slice(0, i + 1)));
68
+ if (paths.some((p) => realDirState(p) === 'other')) return false;
69
+ try {
70
+ mkdirSync(paths[paths.length - 1], { recursive: true });
71
+ } catch {
72
+ return false;
73
+ }
74
+ return paths.every((p) => realDirState(p) === 'dir');
75
+ }
76
+
77
+ /**
78
+ * A directory that is wholly the daemon's, made real: anything at `path` that
79
+ * is not a real directory is removed — non-recursively, so a link goes and its
80
+ * target never does — and the directory made (`mode` for each directory this
81
+ * creates). Returns `path`; THROWS when a real directory cannot be had there,
82
+ * a link reappearing between the remove and the make included, so a caller
83
+ * never writes through one.
84
+ *
85
+ * Only for paths that are OURS. A path a repository may legitimately hold is
86
+ * `ensureRealSubdir`'s, which refuses rather than removes.
87
+ */
88
+ export function ensureOwnDir(path, { mode } = {}) {
89
+ if (realDirState(path) !== 'dir') {
90
+ try {
91
+ rmSync(path, { force: true });
92
+ } catch {
93
+ /* the make below says why */
94
+ }
95
+ }
96
+ mkdirSync(path, mode === undefined ? { recursive: true } : { recursive: true, mode });
97
+ if (realDirState(path) !== 'dir') throw new Error(`${path} is not a real directory`);
98
+ return path;
99
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * A SCREENSHOT OF ONE PAGE, AND ONLY THAT PAGE (audit 2026-10-10, F657/F756).
3
+ *
4
+ * `captureScreenshot` (shot.mjs) photographs whatever document Chrome ends up
5
+ * on. For a page somebody else wrote that is the wrong question: the design
6
+ * preview used to load the model's HTML as a top-level `file://` document,
7
+ * and a `<meta http-equiv=refresh>` (or `location.replace`) to
8
+ * `file:///home/<you>/.flowviant/credentials.json` made the uploaded PNG a
9
+ * picture of every machine credential on the box — past the read fence the
10
+ * design posture exists for, and past the secret scrub, which reads text, not
11
+ * pixels. CSP has no working control over top-level navigation.
12
+ *
13
+ * This capture drives Chrome over its DevTools pipe (`--remote-debugging-pipe`
14
+ * — no port, nothing another local process can reach) and holds the page to
15
+ * the one URL it was given:
16
+ *
17
+ * - EVERY network request is paused. The first Document request for `url` is
18
+ * the page and goes through; any other Document request — a meta refresh,
19
+ * a script navigation, a frame — is failed AND marks the capture as having
20
+ * left the page. Subresources go through only when `allowSubresource` says
21
+ * so (the page's own CSP hosts); the rest are failed quietly.
22
+ * - A NEW PAGE (window.open, target=_blank) is paused before it runs, closed,
23
+ * and marks the capture as having left the page.
24
+ * - Downloads are denied.
25
+ * - Before the PNG is kept, the committed document must still be `url`
26
+ * (fragment aside) and must not be an error page.
27
+ *
28
+ * Any of those failing is `{ ok: false, reason: 'left-page' }`, and the caller
29
+ * (artifactPreview.mjs) reports `renderState: 'unavailable'` with no preview.
30
+ * Chrome itself refuses a navigation from an http page to `file://`, which is
31
+ * why the caller serves the page over loopback http rather than from disk.
32
+ *
33
+ * A Windows browser reached through WSL interop cannot be driven: interop
34
+ * bridges stdin/stdout/stderr and nothing else, so the DevTools pipe on fds 3
35
+ * and 4 never arrives. That path answers 'unavailable' rather than a
36
+ * screenshot nobody verified.
37
+ *
38
+ * The launch obeys shot.mjs's policy: sandboxed unless `sandboxOff`, one retry
39
+ * without it after a launch that measured a sandbox failure, and the machine
40
+ * credential stripped from Chrome's environment.
41
+ */
42
+
43
+ import { spawn } from 'node:child_process';
44
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
45
+ import { join } from 'node:path';
46
+ import { tmpdir, platform } from 'node:os';
47
+ import { SANDBOX_FAILURE_RE, chromeEnv, chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
48
+ import { killGroup } from './processGroup.mjs';
49
+
50
+ /** How long a closing browser gets to exit before it is killed. */
51
+ const EXIT_GRACE_MS = 3_000;
52
+ /** How long the page Chrome just made gets to be auto-attached. */
53
+ const ATTACH_WAIT_MS = 2_000;
54
+
55
+ /**
56
+ * A DevTools client over a pipe pair: JSON messages, each ended by a NUL.
57
+ * `send(method, params, sessionId)` resolves the result or rejects with the
58
+ * protocol's error; `on(fn)` receives every event; `close(reason)` rejects
59
+ * whatever is still waiting (the browser is gone).
60
+ */
61
+ export function createCdpClient(writable, readable) {
62
+ let nextId = 0;
63
+ let closed = null;
64
+ const waiting = new Map();
65
+ const listeners = [];
66
+ let chunks = [];
67
+ readable.on('data', (d) => {
68
+ let start = 0;
69
+ for (let i = 0; i < d.length; i++) {
70
+ if (d[i] !== 0) continue;
71
+ chunks.push(d.subarray(start, i));
72
+ start = i + 1;
73
+ let msg;
74
+ try {
75
+ msg = JSON.parse(Buffer.concat(chunks).toString('utf8'));
76
+ } catch {
77
+ msg = null;
78
+ }
79
+ chunks = [];
80
+ if (!msg) continue;
81
+ if (msg.id != null && waiting.has(msg.id)) {
82
+ const w = waiting.get(msg.id);
83
+ waiting.delete(msg.id);
84
+ if (msg.error) w.reject(new Error(msg.error.message || 'protocol error'));
85
+ else w.resolve(msg.result ?? {});
86
+ } else if (msg.method) {
87
+ for (const fn of listeners) fn(msg);
88
+ }
89
+ }
90
+ if (start < d.length) chunks.push(d.subarray(start));
91
+ });
92
+ return {
93
+ send(method, params = {}, sessionId) {
94
+ if (closed) return Promise.reject(new Error(closed));
95
+ const id = ++nextId;
96
+ const msg = { id, method, params, ...(sessionId ? { sessionId } : {}) };
97
+ return new Promise((resolve, reject) => {
98
+ waiting.set(id, { resolve, reject });
99
+ try {
100
+ writable.write(JSON.stringify(msg) + '\0');
101
+ } catch (e) {
102
+ waiting.delete(id);
103
+ reject(e);
104
+ }
105
+ });
106
+ },
107
+ on(fn) {
108
+ listeners.push(fn);
109
+ },
110
+ close(reason) {
111
+ if (closed) return;
112
+ closed = reason;
113
+ for (const w of waiting.values()) w.reject(new Error(reason));
114
+ waiting.clear();
115
+ },
116
+ };
117
+ }
118
+
119
+ /** `a` and `b` name the same document: equal once the fragment is dropped. */
120
+ export function sameDocument(a, b) {
121
+ try {
122
+ const x = new URL(a);
123
+ const y = new URL(b);
124
+ x.hash = '';
125
+ y.hash = '';
126
+ return x.href === y.href;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * One launch, one page, one PNG. Resolves `{ ok: true }` with the PNG written
134
+ * to `out`, or `{ ok: false, reason, message, launchFailed?, sandboxFailed? }`.
135
+ * `launchFailed` means the browser never answered on the pipe — the only
136
+ * failure a different launch (classic headless, or no sandbox) can change.
137
+ */
138
+ async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env }) {
139
+ const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
140
+ const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }), '--remote-debugging-pipe', 'about:blank'];
141
+ let child;
142
+ try {
143
+ child = spawnImpl(browser.bin, args, {
144
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
145
+ env: chromeEnv(env),
146
+ // Its own process group, so the kill below takes Chrome's helpers too.
147
+ detached: platform() !== 'win32',
148
+ });
149
+ } catch (e) {
150
+ rmSync(userDataDir, { recursive: true, force: true });
151
+ return { ok: false, reason: 'spawn', message: e.message, launchFailed: true };
152
+ }
153
+
154
+ if (!child.stdio?.[3] || !child.stdio?.[4]) {
155
+ try { child.kill('SIGKILL'); } catch { /* gone */ }
156
+ rmSync(userDataDir, { recursive: true, force: true });
157
+ return { ok: false, reason: 'spawn', message: 'no DevTools pipe to the browser.', launchFailed: true };
158
+ }
159
+
160
+ let err = '';
161
+ child.stderr?.on('data', (d) => { err = (err + d.toString()).slice(-4000); });
162
+ const exited = new Promise((resolve) => {
163
+ if (child.exitCode != null) return resolve();
164
+ child.once('exit', () => resolve());
165
+ child.once('error', () => resolve());
166
+ });
167
+ const cdp = createCdpClient(child.stdio[3], child.stdio[4]);
168
+ child.once('exit', (code) => cdp.close(`Chrome exited (code ${code}).`));
169
+ child.once('error', (e) => cdp.close(e.message));
170
+ child.stdio[3]?.on?.('error', () => {});
171
+ child.stdio[4]?.on?.('error', () => {});
172
+
173
+ // The browser's whole group (processGroup.mjs). On Windows it is not
174
+ // detached, the group signal throws there, and the child itself is killed.
175
+ const kill = () => killGroup(child, 'SIGKILL');
176
+ const shutdown = async () => {
177
+ cdp.send('Browser.close').catch(() => {});
178
+ let grace;
179
+ const gone = await Promise.race([exited.then(() => true), new Promise((r) => { grace = setTimeout(() => r(false), EXIT_GRACE_MS); })]);
180
+ clearTimeout(grace);
181
+ // A browser that closed took its helpers with it; one that did not is
182
+ // killed with its whole group.
183
+ if (!gone) kill();
184
+ cdp.close('closed');
185
+ for (const fd of [3, 4]) {
186
+ try { child.stdio[fd]?.destroy?.(); } catch { /* gone */ }
187
+ }
188
+ try { child.stderr?.destroy?.(); } catch { /* gone */ }
189
+ try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* a dying helper still writing */ }
190
+ };
191
+
192
+ let left = null; // the first sign the page was left, in words
193
+ const leave = (why) => { if (!left) left = why; };
194
+ let deadline;
195
+ const timedOut = new Promise((resolve) => {
196
+ deadline = setTimeout(() => resolve({ ok: false, reason: 'timeout', message: `the page did not finish within ${timeoutMs / 1000}s.` }), timeoutMs);
197
+ });
198
+
199
+ const run = async () => {
200
+ try {
201
+ await cdp.send('Browser.getVersion');
202
+ } catch (e) {
203
+ // Give a dying browser a moment to say why on stderr.
204
+ await Promise.race([exited, new Promise((r) => setTimeout(r, 200))]);
205
+ const tail = err.trim().split('\n').slice(-2).join(' ');
206
+ return {
207
+ ok: false,
208
+ reason: 'render',
209
+ message: `Chrome did not start (${e.message})${tail ? ' ' + tail : ''}`,
210
+ launchFailed: true,
211
+ sandboxFailed: !noSandbox && SANDBOX_FAILURE_RE.test(err),
212
+ };
213
+ }
214
+
215
+ let ours = null; // { targetId, sessionId }
216
+ let ourTargetId = null;
217
+ let documentSeen = false;
218
+ const attached = new Map(); // targetId -> { sessionId, waiting, type }
219
+ let ready;
220
+ const ourSession = new Promise((r) => { ready = r; });
221
+ let budgetDone;
222
+ const budget = new Promise((r) => { budgetDone = r; });
223
+
224
+ let navigating = false;
225
+ // Called only for a target that is paused before it runs. Once the page
226
+ // is navigating, a new page is one IT opened (a popup) and never runs;
227
+ // before that, it can only be the window Chrome starts with.
228
+ const settleForeign = (targetId, info) => {
229
+ if (info.type === 'page') {
230
+ if (navigating) leave('the page opened another window');
231
+ cdp.send('Target.closeTarget', { targetId }).catch(() => {});
232
+ } else {
233
+ cdp.send('Runtime.runIfWaitingForDebugger', {}, info.sessionId).catch(() => {});
234
+ }
235
+ };
236
+
237
+ cdp.on((msg) => {
238
+ const p = msg.params || {};
239
+ if (msg.method === 'Target.attachedToTarget' && !msg.sessionId) {
240
+ const info = { sessionId: p.sessionId, waiting: !!p.waitingForDebugger, type: p.targetInfo?.type };
241
+ const targetId = p.targetInfo?.targetId;
242
+ attached.set(targetId, info);
243
+ if (ourTargetId) {
244
+ if (targetId === ourTargetId) ready(info.sessionId);
245
+ else if (info.waiting) settleForeign(targetId, info);
246
+ }
247
+ return;
248
+ }
249
+ if (!ours || msg.sessionId !== ours.sessionId) return;
250
+ if (msg.method === 'Fetch.requestPaused') {
251
+ const requestId = p.requestId;
252
+ const reqUrl = p.request?.url ?? '';
253
+ if (p.resourceType === 'Document') {
254
+ if (!documentSeen && p.frameId === ours.targetId && reqUrl === url) {
255
+ documentSeen = true;
256
+ cdp.send('Fetch.continueRequest', { requestId }, ours.sessionId).catch(() => {});
257
+ return;
258
+ }
259
+ leave(`the page navigated to ${reqUrl.slice(0, 200)}`);
260
+ cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
261
+ return;
262
+ }
263
+ let allowed = false;
264
+ try {
265
+ allowed = !!allowSubresource(reqUrl);
266
+ } catch {
267
+ allowed = false;
268
+ }
269
+ cdp.send(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
270
+ return;
271
+ }
272
+ if (msg.method === 'Emulation.virtualTimeBudgetExpired') budgetDone();
273
+ });
274
+
275
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
276
+ await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
277
+ const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
278
+ ourTargetId = created.targetId;
279
+ for (const [targetId, info] of attached) {
280
+ if (targetId === ourTargetId) ready(info.sessionId);
281
+ else if (info.waiting) settleForeign(targetId, info);
282
+ }
283
+ // A Chrome that does not auto-attach the page it just made is attached
284
+ // to it by hand.
285
+ let attachWait;
286
+ const sessionId = await Promise.race([
287
+ ourSession,
288
+ new Promise((r) => { attachWait = setTimeout(r, ATTACH_WAIT_MS, null); }),
289
+ ]).then((id) => id ?? cdp.send('Target.attachToTarget', { targetId: ourTargetId, flatten: true }).then((a) => a.sessionId));
290
+ clearTimeout(attachWait);
291
+ ours = { targetId: ourTargetId, sessionId };
292
+
293
+ await cdp.send('Page.enable', {}, sessionId);
294
+ await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
295
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
296
+ await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
297
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
298
+ navigating = true;
299
+ const nav = await cdp.send('Page.navigate', { url }, sessionId);
300
+ if (nav.errorText) {
301
+ return { ok: false, reason: 'render', message: `the page did not load (${nav.errorText}).` };
302
+ }
303
+ // Let fonts and scripts settle, as `--virtual-time-budget` does.
304
+ await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
305
+ // A Chrome that never reports the budget is captured on the wall clock,
306
+ // short of the deadline, rather than never.
307
+ let wall;
308
+ await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
309
+ clearTimeout(wall);
310
+
311
+ const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
312
+ const current = history.entries?.[history.currentIndex]?.url ?? '';
313
+ const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
314
+ const main = tree.frameTree?.frame ?? {};
315
+ if (!documentSeen) leave('the page was never requested');
316
+ if (!sameDocument(current, url) || !sameDocument(main.url ?? '', url) || main.unreachableUrl) {
317
+ leave(`the page ended on ${String(current || main.url || 'nothing').slice(0, 200)}`);
318
+ }
319
+ if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
320
+
321
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
322
+ // One last look: a navigation that landed during the capture.
323
+ if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
324
+ writeFileSync(out, Buffer.from(shot.data ?? '', 'base64'));
325
+ return { ok: true, path: out };
326
+ };
327
+
328
+ try {
329
+ return await Promise.race([
330
+ run().catch((e) => ({ ok: false, reason: 'render', message: e?.message ?? String(e) })),
331
+ timedOut,
332
+ ]);
333
+ } finally {
334
+ clearTimeout(deadline);
335
+ await shutdown();
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Capture `url` and nothing else to a PNG at `out` (see the header). Resolves
341
+ * `{ ok: true, path }` or `{ ok: false, reason, message }`; a reason of
342
+ * 'left-page' means the page tried to become something else.
343
+ *
344
+ * `browser`, `spawn`, `uid` and `env` are injectable for the tests; nothing in
345
+ * the daemon passes them.
346
+ */
347
+ export async function capturePinnedPage({
348
+ url,
349
+ out,
350
+ width = 1440,
351
+ height = 900,
352
+ timeoutMs = 60_000,
353
+ budgetMs = 5_000,
354
+ allowSubresource = () => false,
355
+ browser = resolveBrowser(),
356
+ spawn: spawnImpl = spawn,
357
+ uid = process.getuid?.(),
358
+ env = process.env,
359
+ }) {
360
+ if (!browser) {
361
+ return { ok: false, reason: 'no-browser', message: 'No Chrome/Chromium/Edge found.' };
362
+ }
363
+ if (browser.viaWindows) {
364
+ return { ok: false, reason: 'wsl', message: 'a Windows browser reached through WSL cannot be held to one page.' };
365
+ }
366
+ let noSandbox = sandboxOff({ uid, env });
367
+ let last = null;
368
+ for (const headlessFlag of ['--headless=new', '--headless']) {
369
+ const once = () =>
370
+ captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env });
371
+ let r = await once();
372
+ if (!r.ok && r.sandboxFailed) {
373
+ noSandbox = true; // sticky: the one retry this capture gets
374
+ r = await once();
375
+ }
376
+ const { launchFailed, sandboxFailed: _measured, ...result } = r;
377
+ // Only a browser that never answered is worth another launch; a page that
378
+ // left, timed out or failed to load answers the same under any flag.
379
+ if (result.ok || !launchFailed) return result;
380
+ last = result;
381
+ }
382
+ return last;
383
+ }