flowviant 0.116.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 +71 -139
  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
@@ -17,6 +17,14 @@
17
17
  * - ADOPT ONLY AN OPEN PR. gh's branch finder falls back to the most recent
18
18
  * MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
19
19
  * every later delivery on a long-lived branch into a silent black hole.
20
+ * - NEVER A BARE BRANCH AS A PR SELECTOR (audit 2026-10-10, F166). gh reads a
21
+ * selector that parses as a number (`42`, `#42`) as PR #42, whatever its
22
+ * head — so `gh pr view <branch>` / `gh pr merge <branch>` on a branch
23
+ * named after a ticket adopted, and then MERGED, somebody else's pull
24
+ * request under the operator's login. The PR is found by `gh pr list
25
+ * --head`, kept only when its head is exactly this branch in this
26
+ * repository (a fork's same-named branch is not ours), and merged by that
27
+ * verified URL. No verified PR, no merge.
20
28
  * - …AND ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
21
29
  * hand into another branch would be merged INTO that branch. Refused only
22
30
  * on a MEASURED mismatch: an absent or empty `baseRefName` adopts as
@@ -44,13 +52,28 @@
44
52
  */
45
53
 
46
54
  import { execFileSync } from 'node:child_process';
47
- import { git } from './git.mjs';
55
+ import { git, stripUrlUserinfo } from './git.mjs';
56
+ import { scrub } from './uplinkScrub.mjs';
48
57
 
49
- /** gh's own first line of complaint, capped — the words a failure relays. */
58
+ /**
59
+ * gh's (or git's) own first line of complaint, capped — the words a failure
60
+ * relays. USERINFO STRIPPED (audit 2026-10-10, F153): a push writes the remote
61
+ * URL to stderr and a remote can carry a token as `//user:token@host`, and
62
+ * this line is stored where the whole project reads it.
63
+ */
50
64
  export const ghFirstLine = (e) =>
51
- ((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
52
- 'failed')
53
- .slice(0, 400);
65
+ stripUrlUserinfo(
66
+ (e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) || 'failed'
67
+ ).slice(0, 400);
68
+
69
+ /**
70
+ * A FAILURE LINE FIT TO LEAVE THIS MACHINE — userinfo stripped and every
71
+ * known secret scrubbed. Every git or gh failure relayed to a team-visible
72
+ * record (a card's activity, the tab chat, an agent's mergeError) goes
73
+ * through here: the session PR lane relayed git's push stderr unscrubbed
74
+ * while the agent lane scrubbed the same line (audit 2026-10-10, F153/F167).
75
+ */
76
+ export const relayedFailure = (e) => scrub(ghFirstLine(e));
54
77
 
55
78
  /** A pull request URL worth storing. Anything else gh printed is not one. */
56
79
  export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
@@ -67,19 +90,53 @@ export function ghReady({ gh = runGh } = {}) {
67
90
  gh(['auth', 'status'], { timeout: 20_000 });
68
91
  return null;
69
92
  } catch (e) {
70
- return { missing: e?.code === 'ENOENT', error: ghFirstLine(e) };
93
+ return { missing: e?.code === 'ENOENT', error: relayedFailure(e) };
71
94
  }
72
95
  }
73
96
 
74
- /** The OPEN pull request for `head`, as `{ url, base }` — or null when there
75
- * is none, or only a merged/closed one. `base` is null when gh did not say. */
97
+ /** A PR URL gh may be handed as a selector: a web address, never a bare
98
+ * number or a leading-dash value. Host-agnostic, so GitHub Enterprise merges. */
99
+ const SELECTOR_URL_RE = /^https?:\/\/\S+$/;
100
+
101
+ /**
102
+ * The OPEN pull request whose head is exactly `head` in THIS repository, as
103
+ * `{ url, base, number }` — or null when gh lists none. THROWS when gh itself
104
+ * fails, so a caller can tell "GitHub says there is none" from "we could not
105
+ * ask"; `findOpenPr` folds the two for the callers that only adopt.
106
+ */
107
+ function verifiedOpenPr(head, { cwd, gh }) {
108
+ const rows = JSON.parse(
109
+ gh(
110
+ [
111
+ 'pr', 'list', '--head', head, '--state', 'open',
112
+ '--json', 'number,url,baseRefName,headRefName,isCrossRepository',
113
+ '--limit', '30',
114
+ ],
115
+ { cwd, timeout: 30_000 }
116
+ ).toString()
117
+ );
118
+ const pr = (Array.isArray(rows) ? rows : []).find(
119
+ (r) =>
120
+ r?.headRefName === head &&
121
+ r?.isCrossRepository === false &&
122
+ typeof r?.url === 'string' &&
123
+ SELECTOR_URL_RE.test(r.url.trim())
124
+ );
125
+ if (!pr) return null;
126
+ return {
127
+ url: pr.url.trim(),
128
+ base: typeof pr.baseRefName === 'string' ? pr.baseRefName : null,
129
+ number: Number.isInteger(pr.number) ? pr.number : null,
130
+ };
131
+ }
132
+
133
+ /** The OPEN pull request for `head`, as `{ url, base, number }` — or null when
134
+ * there is none (or gh could not say). `base` is null when gh did not say. */
76
135
  export function findOpenPr(head, { cwd, gh = runGh } = {}) {
77
136
  try {
78
- const j = JSON.parse(gh(['pr', 'view', head, '--json', 'url,state,baseRefName'], { cwd, timeout: 30_000 }).toString());
79
- if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
80
- return { url: j.url.trim(), base: typeof j?.baseRefName === 'string' ? j.baseRefName : null };
137
+ return verifiedOpenPr(head, { cwd, gh });
81
138
  } catch {
82
- return null; // no PR for the branch at all
139
+ return null; // gh could not list — nothing to adopt
83
140
  }
84
141
  }
85
142
 
@@ -107,7 +164,7 @@ export function ensureOpenPr(head, base, { cwd, gh = runGh } = {}) {
107
164
  .trim();
108
165
  return { url: out.split('\n').filter(Boolean).pop() ?? null, adopted: false };
109
166
  } catch (e) {
110
- return { error: ghFirstLine(e) };
167
+ return { error: relayedFailure(e) };
111
168
  }
112
169
  }
113
170
 
@@ -141,15 +198,35 @@ export async function tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, slee
141
198
  /**
142
199
  * MERGE `head`'s PR with a merge commit and VERIFY the tip reached base.
143
200
  * Answers `{ ok: true }`, `{ error }` (gh refused, in its own words — an
144
- * "already merged" is not a refusal), or `{ notOnBase: true }` (gh said yes
145
- * and base does not have the tip: an older PR merged, or a queue holds it).
201
+ * "already merged" is not a refusal), `{ noPr: true }` (GitHub lists no open
202
+ * PR from `head` in this repository, so nothing was merged — the caller says
203
+ * so in its own words), or `{ notOnBase: true }` (gh said yes and base does
204
+ * not have the tip: an older PR merged, or a queue holds it).
205
+ *
206
+ * The PR is looked up HERE, by its head, and merged by its URL — never by
207
+ * `head` itself, which gh would read as a PR number if it looks like one. So
208
+ * no caller can hand the merge an unverified selector.
209
+ *
146
210
  * The push that must precede it is the caller's: GitHub merges the REMOTE tip.
147
211
  */
148
212
  export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fetchOrigin, gh = runGh, sleep, gitImpl }) {
213
+ let pr;
214
+ try {
215
+ pr = verifiedOpenPr(head, { cwd, gh });
216
+ } catch (e) {
217
+ return { error: relayedFailure(e) };
218
+ }
219
+ if (!pr) {
220
+ // Nothing open to merge — and a PR that already MERGED leaves nothing
221
+ // open either (a re-offered job whose report was lost). "Already merged"
222
+ // stays a success only when it is measured: the tip on base.
223
+ const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
224
+ return landed ? { ok: true } : { noPr: true };
225
+ }
149
226
  try {
150
- gh(['pr', 'merge', head, '--merge'], { cwd, timeout: 120_000 });
227
+ gh(['pr', 'merge', pr.url, '--merge'], { cwd, timeout: 120_000 });
151
228
  } catch (e) {
152
- const line = ghFirstLine(e);
229
+ const line = relayedFailure(e);
153
230
  if (!/already merged/i.test(line)) return { error: line };
154
231
  }
155
232
  const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
@@ -4,31 +4,13 @@
4
4
  * what's missing, rather than failing cryptically mid-run.
5
5
  */
6
6
 
7
- import { execFileSync } from 'node:child_process';
8
7
  import { ok, warn, info, c } from './ui.mjs';
9
- import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
10
- import { RUNTIMES, mediatedSafeGap } from './runtimes.mjs';
8
+ import { addLocalBinToPath, commandPresent as present, promptYesNo, installClaude, installGh } from './install.mjs';
9
+ import { ghReady } from './prWorkflow.mjs';
10
+ import { RUNTIMES, mediatedSafeGap, runtimeById } from './runtimes.mjs';
11
11
  import { detectRuntimes } from './runtimeDetection.mjs';
12
12
  import { terminalCommand } from './launchCommand.mjs';
13
13
 
14
- function present(cmd) {
15
- try {
16
- execFileSync(cmd, ['--version'], { stdio: 'ignore' });
17
- return true;
18
- } catch {
19
- return false;
20
- }
21
- }
22
-
23
- function ghAuthed() {
24
- try {
25
- execFileSync('gh', ['auth', 'status'], { stdio: 'ignore', timeout: 20_000 });
26
- return true;
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
14
  /** Prints a checklist and, when a missing prereq is auto-installable, OFFERS to
33
15
  * install it (consent-based, never silent). Returns false only if a *fatal*
34
16
  * prereq (SOME drivable runtime, or git when worktrees are used) is still
@@ -53,7 +35,7 @@ export async function preflight({ needGit = true } = {}) {
53
35
  let detected = detectRuntimes({ refresh: true });
54
36
  const shown = detected.filter((r) => r.installed || r.id === 'claude');
55
37
  for (const r of shown) {
56
- const rt = RUNTIMES[r.id];
38
+ const rt = runtimeById(r.id);
57
39
  if (r.dispatchable) {
58
40
  ok(
59
41
  `${rt.bin} installed ${c.dim(`· ${rt.label} · must be signed in — run \`${rt.login}\` once if you haven’t`)}`
@@ -77,9 +59,10 @@ export async function preflight({ needGit = true } = {}) {
77
59
  // permission control is all-or-nothing. Say so at startup rather than letting
78
60
  // an operator believe a setting is in force that isn't.
79
61
  for (const r of drivable) {
80
- if (mediatedSafeGap(RUNTIMES[r.id])) {
62
+ const rt = runtimeById(r.id);
63
+ if (mediatedSafeGap(rt)) {
81
64
  warn(
82
- `${RUNTIMES[r.id].bin}: FLOWVIANT_SAFE cannot narrow this runtime — it has no per-invocation permission control, so its builds run unrestricted`
65
+ `${rt.bin}: FLOWVIANT_SAFE cannot narrow this runtime — it has no per-invocation permission control, so its builds run unrestricted`
83
66
  );
84
67
  }
85
68
  }
@@ -98,7 +81,7 @@ export async function preflight({ needGit = true } = {}) {
98
81
  // agent, not a requirement. Hence the offer no longer defaults to yes: an
99
82
  // install nobody asked for, defaulted on, is how a preflight installs
100
83
  // software on a machine whose operator only wanted a status line.
101
- if (gh && ghAuthed()) {
84
+ if (gh && ghReady() === null) {
102
85
  ok('gh authenticated');
103
86
  } else if (gh) {
104
87
  warn('gh not signed in — run: gh auth login');
@@ -32,10 +32,12 @@ import { spawn, execFileSync } from 'node:child_process';
32
32
  import { createHash } from 'node:crypto';
33
33
  import { writeFileSync, renameSync, existsSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
- import { homedir, platform, arch } from 'node:os';
35
+ import { platform, arch } from 'node:os';
36
36
  import { startAuthProxy } from './authproxy.mjs';
37
+ import { LOCAL_BIN, commandPresent } from './install.mjs';
37
38
  import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
38
39
  import { forgetPreviewPid, recordPreviewPid } from './previewRegistry.mjs';
40
+ import { killGroup } from './processGroup.mjs';
39
41
 
40
42
  // ── cloudflared: pinned, verified, or not fetched at all ───────────────────
41
43
 
@@ -53,15 +55,6 @@ const CF_SHA256 = {
53
55
  'darwin-arm64': '9042c2c5d8b2de78e60f313d5fb31b6c5c1cebde787a3caf1f2c9588084ac442',
54
56
  };
55
57
 
56
- function onPath() {
57
- try {
58
- execFileSync('cloudflared', ['--version'], { stdio: 'ignore' });
59
- return true;
60
- } catch {
61
- return false;
62
- }
63
- }
64
-
65
58
  /**
66
59
  * Resolve a cloudflared binary: PATH → previously fetched → download.
67
60
  *
@@ -74,12 +67,12 @@ function onPath() {
74
67
  * to be relayed verbatim rather than replaced with a Flowviant-authored one.
75
68
  */
76
69
  async function ensureCloudflared(log) {
77
- if (onPath()) return { bin: 'cloudflared' };
70
+ if (commandPresent('cloudflared')) return { bin: 'cloudflared' };
78
71
 
79
72
  const os = platform();
80
73
  const a = arch() === 'arm64' ? 'arm64' : 'amd64';
81
74
  const key = `${os === 'darwin' ? 'darwin' : 'linux'}-${a}`;
82
- const dir = join(homedir(), '.flowviant', 'bin');
75
+ const dir = LOCAL_BIN;
83
76
  // Version-stamped, so a pin bump fetches rather than reusing the old binary.
84
77
  const bin = join(dir, `cloudflared-${CF_VERSION}${os === 'win32' ? '.exe' : ''}`);
85
78
  if (existsSync(bin)) return { bin };
@@ -162,7 +155,7 @@ const TAIL_BYTES = 2000;
162
155
  * Flowviant asserting a state it never measured.
163
156
  *
164
157
  * `stillServing` (optional, async → boolean) is the ATTRIBUTION check — the
165
- * same `listenersIn(worktree)` predicate the caller ran at the boundary —
158
+ * same `originFor(worktree, port)` attribution the caller ran at the boundary —
166
159
  * and it guards EVERY gate in here, not just the probe: the open-time
167
160
  * re-validation, one more look immediately before cloudflared spawns, and the
168
161
  * recurring probe. Ports are global to a box and a worktree is not: when the
@@ -245,15 +238,7 @@ export async function openTunnel({
245
238
  /* best-effort */
246
239
  }
247
240
  if (tunnel?.pid) {
248
- try {
249
- process.kill(-tunnel.pid, 'SIGKILL'); // the whole detached group
250
- } catch {
251
- try {
252
- tunnel.kill('SIGKILL');
253
- } catch {
254
- /* already gone */
255
- }
256
- }
241
+ killGroup(tunnel, 'SIGKILL'); // the whole detached group
257
242
  forgetPreviewPid(tunnel.pid);
258
243
  forgetInfraPid(tunnel.pid);
259
244
  }
@@ -301,7 +286,15 @@ export async function openTunnel({
301
286
  return { error: `nothing is listening on port ${port} in this worktree any more.` };
302
287
  }
303
288
 
304
- const args = ['tunnel', '--url', `http://localhost:${gate.port}`];
289
+ // DIAL THE ADDRESS THE GATE BOUND (audit 2026-10-10, F757). The gate
290
+ // listens on 127.0.0.1 only; `localhost` resolves IPv6-first on macOS and
291
+ // on some Linux setups, and Go's dialer tries [::1]:<port> first — where
292
+ // any other local process could bind the same port number (it is readable
293
+ // from /proc/net/tcp and from this argv) and receive every public request,
294
+ // grant cookies and the automation password included. The same
295
+ // two-loopback-families hazard `originFor` closes on the origin side.
296
+ const gateUrl = `http://127.0.0.1:${gate.port}`;
297
+ const args = ['tunnel', '--url', gateUrl];
305
298
  // Send the origin the Host it expects. Vite and Next reject a Host they do
306
299
  // not recognise, so without this the tunnel resolves and then 403s.
307
300
  args.push('--http-host-header', 'localhost');
@@ -310,8 +303,9 @@ export async function openTunnel({
310
303
  // The signature names THIS tunnel's gate port, not the bare word
311
304
  // 'cloudflared': the reap matches cmdline.includes(sig), and the generic
312
305
  // word would let a recycled pid land on an operator's own unrelated
313
- // cloudflared and group-SIGKILL it.
314
- recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
306
+ // cloudflared and group-SIGKILL it. Entries written before 2026-10-10 keep
307
+ // their own `--url http://localhost:<port>` signature and still reap.
308
+ recordPreviewPid(tunnel.pid, `--url ${gateUrl}`);
315
309
  // Keep our own plumbing out of the listeners measurement (see listeners.mjs:
316
310
  // cloudflared's metrics socket lives in the checkout's cwd).
317
311
  noteInfraPid(tunnel.pid);
@@ -63,10 +63,21 @@ export const sourceOf = (req) =>
63
63
  * says is cross-site is refused unless it is a plain navigation GET/HEAD —
64
64
  * the two shapes this product legitimately serves cross-site (the Open
65
65
  * link, the Workbench frame's own src; a rendered attacker frame is then
66
- * stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
67
- * auth: a password in an Authorization header is never attached cross-site
68
- * by a browser, and clients that send no Sec-Fetch headers never held a
69
- * None cookie, so absence stays permitted.
66
+ * stopped by the frame-ancestors rewrite). Applied in full to grant-cookie
67
+ * auth; clients that send no Sec-Fetch headers never held a None cookie, so
68
+ * absence stays permitted.
69
+ *
70
+ * The PASSWORD is not exempt from CSRF (corrected 2026-10-10, F116). It was
71
+ * held that "a password in an Authorization header is never attached
72
+ * cross-site by a browser" — false for HTTP Basic: the gate's own 401 makes
73
+ * the browser prompt, and the browser then replays the cached credentials on
74
+ * later requests to the origin, a cross-site auto-submitted form POST
75
+ * included (the classic router CSRF). So a password-authenticated request —
76
+ * and every request on a password-only share — is refused when it is
77
+ * cross-site AND not a safe method (`crossSiteWrite`). Cross-site
78
+ * GET/HEAD/OPTIONS still ride (a frontend preview calling an API preview on
79
+ * another tunnel), and so does anything without Sec-Fetch headers (curl,
80
+ * native tools, the automation the password exists for).
70
81
  *
71
82
  * Why a backstop at all: Chromium 76–113 (and same-vintage webviews/forks)
72
83
  * sends Sec-Fetch-Dest — so it takes the framed-cookie branch — while
@@ -85,6 +96,16 @@ export const crossSiteAbuse = (req) => {
85
96
  return !(req.method === 'GET' || req.method === 'HEAD');
86
97
  };
87
98
 
99
+ /** Methods a cross-site page may send with a cached Basic credential: none
100
+ * of them changes state on a well-behaved origin. */
101
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
102
+
103
+ /** A cross-site request that can change state — the shape Basic-auth CSRF
104
+ * rides (see `crossSiteAbuse`'s second paragraph). */
105
+ export const crossSiteWrite = (req) =>
106
+ String(req.headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site' &&
107
+ !SAFE_METHODS.has(String(req.method || '').toUpperCase());
108
+
88
109
  /** A top-level browser navigation, and nothing else. A 302 is re-issued as
89
110
  * GET and drops the body, so bouncing a POST would turn an unauthenticated
90
111
  * write into a mystery GET; and this is what keeps curl and native clients
@@ -207,15 +228,18 @@ export function createGatePolicy({ grants, grantSecret, shareId, expected, log,
207
228
  };
208
229
 
209
230
  /** True when THIS request authenticated with the password rather than the
210
- * cookie — the credential class CSRF cannot ride. */
231
+ * cookie. Not a CSRF exemption: a cached Basic credential rides a
232
+ * cross-site navigation too (see `crossSiteAbuse`). */
211
233
  const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
212
234
 
213
235
  /**
214
236
  * THE ONE ADMISSION ORDER both transports ask: the credential first, then —
215
- * only for a caller who HAS one — the cross-site backstop. The grant is a
216
- * cookie, and a cookie can be ridden (header rule 4a's backstop); a
217
- * refusal there is never a bounce, because the caller has a credential and
218
- * the request SHAPE is what is refused.
237
+ * only for a caller who HAS one — the cross-site backstops. The grant is a
238
+ * cookie, and a cookie can be ridden (header rule 4a's backstop: every
239
+ * cross-site shape but a GET/HEAD navigation); a cached Basic password can
240
+ * be ridden too, by a cross-site write (2026-10-10, F116). A refusal there
241
+ * is never a bounce, because the caller has a credential and the request
242
+ * SHAPE is what is refused.
219
243
  *
220
244
  * Returns the credential verdict ('blocked', 'none', 'expired', 'forged',
221
245
  * 'badpass'), 'cross-site', or 'ok'.
@@ -224,6 +248,7 @@ export function createGatePolicy({ grants, grantSecret, shareId, expected, log,
224
248
  const verdict = credential(req);
225
249
  if (verdict !== 'ok') return verdict;
226
250
  if (grants && crossSiteAbuse(req) && !viaPassword(req)) return 'cross-site';
251
+ if (crossSiteWrite(req)) return 'cross-site';
227
252
  return 'ok';
228
253
  };
229
254
 
@@ -56,6 +56,7 @@ import { execFileSync } from 'node:child_process';
56
56
  import { join } from 'node:path';
57
57
  import { homedir, platform } from 'node:os';
58
58
  import { mutateRegistry, processAlive, processStartTime, readRegistry } from './procRegistry.mjs';
59
+ import { killGroup } from './processGroup.mjs';
59
60
 
60
61
  const FLOWVIANT_DIR = join(homedir(), '.flowviant');
61
62
  const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
@@ -218,17 +219,9 @@ export function reapOrphanPreviews(log) {
218
219
  if (ours === null) continue;
219
220
  handled.add(pid);
220
221
  if (!ours) continue;
221
- try {
222
- process.kill(-pid, 'SIGKILL'); // whole group
223
- killed++;
224
- } catch {
225
- try {
226
- process.kill(pid, 'SIGKILL');
227
- killed++;
228
- } catch {
229
- /* already gone */
230
- }
231
- }
222
+ // The whole group, else the pid — by number, as `process.kill` always
223
+ // coerced a row's pid (processGroup.mjs refuses anything that is not one).
224
+ if (killGroup(Number(pid), 'SIGKILL')) killed++;
232
225
  }
233
226
  if (handled.size) mutatePreviews((cur) => cur.filter((e) => !handled.has(e.pid)));
234
227
  if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
@@ -6,8 +6,8 @@
6
6
  * the server's own `parseBoxName`/`parseCheckoutPath` did — an escape
7
7
  * sequence in a project or box name can clear the screen or, via OSC 52,
8
8
  * rewrite the terminal's clipboard — but it stopped at C1 and never scrubbed
9
- * the BIDI/format controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) that
10
- * reorder a line to hide or forge part of it: a box named
9
+ * the BIDI/format controls (U+061C, U+200E/U+200F, U+202A–U+202E,
10
+ * U+2066–U+2069) that reorder a line to hide or forge part of it: a box named
11
11
  * `evil‮txt.crt⁦` reads backwards on a bidi-aware terminal, so
12
12
  * "evil" can visually swap places with an extension that was never there.
13
13
  *
@@ -19,10 +19,17 @@
19
19
  * with zero imports is the one shape both can depend on without adding an
20
20
  * edge neither wants.
21
21
  *
22
- * THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`,
23
- * machineBoxes.ts, 2026-09-24) — copied rather than re-derived, because the
24
- * point of a BELT is that the two ends agree about what counts as printable
25
- * without either one trusting the other to have applied it. The server scrub
22
+ * THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`, now
23
+ * `packages/shared/src/displayText.ts`; machineBoxes.ts when it was copied,
24
+ * 2026-09-24) — copied rather than re-derived, because the point of a BELT is
25
+ * that the two ends agree about what counts as printable without either one
26
+ * trusting the other to have applied it. The copy fell behind once: the
27
+ * server's set gained U+061C, the Arabic letter mark (the one Bidi_Control
28
+ * code point it lacked), on 2026-10-10 (audit F231), and this copy gained it
29
+ * in the audit's final cleanup the same day. The release gate
30
+ * (`scripts/check-app-parity.mjs`, rule 28) now walks every code point
31
+ * through `printable`, `printableText` and the shared `stripDisplayControl`,
32
+ * so the sets cannot drift apart unnoticed again. The server scrub
26
33
  * is the primary fix (it is what a stale stored row, upserted before this
27
34
  * belt existed, is read back through); this is defence in depth, because the
28
35
  * terminal is where the harm happens and the CLI should not trust that every
@@ -32,7 +39,7 @@
32
39
  * `safeName`.
33
40
  */
34
41
  // eslint-disable-next-line no-control-regex
35
- const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
42
+ const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
36
43
 
37
44
  /** Strip the class above. Non-strings pass through unchanged — this is a
38
45
  * scrub, not a coercion, and a caller that hands it `undefined` should get
@@ -40,3 +47,59 @@ const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
40
47
  export function printable(raw) {
41
48
  return typeof raw === 'string' ? raw.replace(DISPLAY_CONTROL, '') : raw;
42
49
  }
50
+
51
+ /**
52
+ * ONE LINE OF SOMEBODY ELSE'S PROSE, made safe to print (2026-10-10, the
53
+ * audit's F084) — the CLI twin of the server's `normalizeStopReason`.
54
+ *
55
+ * A SENTENCE, so the class is BLANKED to a space rather than stripped (two
56
+ * words must not run together where a newline or a control sat between them),
57
+ * then whitespace collapses to one line, and the cap counts CODE POINTS: a
58
+ * `slice` on UTF-16 units can end on half a surrogate pair, and half a pair is
59
+ * what would be printed. The commanded stop's reason had its own copy of this
60
+ * that stopped at C0 and DEL — the 8-bit CSI `\u009b` and every bidi override
61
+ * went straight to the operator's terminal whenever the server-side scrub was
62
+ * bypassed (the column is also written by hand, machineStop.ts).
63
+ *
64
+ * Not a string in, '' out: every caller prints the result, and a sentence
65
+ * nobody wrote has no words.
66
+ */
67
+ export function printableLine(raw, maxCodePoints) {
68
+ if (typeof raw !== 'string') return '';
69
+ const line = raw.replace(DISPLAY_CONTROL, ' ').replace(/\s+/g, ' ').trim();
70
+ return [...line].slice(0, maxCodePoints).join('').trim();
71
+ }
72
+
73
+ /**
74
+ * OUTPUT A CLI OR A MODEL WROTE, made safe for the operator's terminal and log
75
+ * (2026-10-10, the audit's F133/F097): the same class, minus the two controls
76
+ * multi-line text is made of — `\n` and `\t` survive, so a CLI's stderr and a
77
+ * replayed log keep their shape.
78
+ *
79
+ * The ESC-introduced sequences go WHOLE first — CSI (`ESC [ … final`, which
80
+ * includes a colour) and OSC (`ESC ] …` ended by BEL or ST, which includes the
81
+ * OSC 52 clipboard write and the OSC 8 link) — so their parameter bytes do not
82
+ * survive as litter; any lone or unterminated ESC, every other C0 (a carriage
83
+ * return overwrites the line above it), DEL, C1 (the 8-bit CSI/OSC need no
84
+ * ESC at all) and the bidi controls then go one by one.
85
+ *
86
+ * NEVER APPLIED INSIDE ui.mjs's `line()`. The daemon colours its own words
87
+ * with SGR (`c.*`) around text it has already composed, so a sink-wide scrub
88
+ * would have to keep SGR — and cannot tell the daemon's from an attacker's.
89
+ * Text is cleaned where it ENTERS instead: server strings at their intake
90
+ * (`printable`), and CLI/model output at the one sink that writes it raw
91
+ * (runTurn's `emit`, before the daemon's coloured prefix is added). The one
92
+ * other reader is `flowviant logs` (views.mjs), which replays a log file that
93
+ * an older daemon may have written raw — so a replay loses the log's colours
94
+ * along with anybody else's sequences.
95
+ */
96
+ // eslint-disable-next-line no-control-regex
97
+ const ESC_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)/g;
98
+ // eslint-disable-next-line no-control-regex
99
+ const TEXT_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
100
+
101
+ /** Strip the class above, keeping `\n` and `\t`. Non-strings pass through
102
+ * unchanged, as `printable` does. */
103
+ export function printableText(raw) {
104
+ return typeof raw === 'string' ? raw.replace(ESC_SEQUENCE, '').replace(TEXT_CONTROL, '') : raw;
105
+ }
@@ -30,6 +30,7 @@ import {
30
30
  unlinkSync,
31
31
  writeFileSync,
32
32
  } from 'node:fs';
33
+ import { procStat } from './procStat.mjs';
33
34
 
34
35
  const LOCK_STALE_MS = 15_000;
35
36
  const MAX_ENTRIES = 32;
@@ -132,23 +133,21 @@ export function sameBoot(mark, now = bootMark()) {
132
133
  * WHEN A PROCESS STARTED, as an opaque token — so a lock that remembers a pid
133
134
  * can tell the process it meant from one that inherited the number.
134
135
  *
135
- * Linux: field 22 of `/proc/<pid>/stat` (start time in clock ticks since boot),
136
- * read after the LAST `)` because the comm field may itself contain one.
136
+ * Linux: `starttime` (field 22 of `/proc/<pid>/stat`, clock ticks since boot),
137
+ * read through procStat.mjs, the one parser of that line.
137
138
  * macOS: `ps -o lstart=`. Null when it cannot be read, which callers treat as
138
139
  * "cannot tell" — never as a mismatch.
140
+ *
141
+ * The darwin token keeps the reader's locale (no LC_ALL pin, 2026-10-10): it is
142
+ * compared for EQUALITY against tokens older daemons already wrote into live
143
+ * turn locks, and pinning it would read every one of those holders as a
144
+ * different process for one upgrade.
139
145
  */
140
146
  export function processStartTime(pid) {
141
147
  if (!Number.isInteger(pid) || pid <= 0) return null;
142
- try {
143
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
144
- const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
145
- // `rest[0]` is field 3 (state), so field 22 is rest[19].
146
- const st = rest[19];
147
- return st && /^\d+$/.test(st) ? `${bootMark()}:${st}` : null;
148
- } catch {
149
- /* not Linux, or the pid is gone */
150
- }
151
- if (platform() !== 'darwin') return null;
148
+ const linux = procStat(pid);
149
+ if (linux) return `${bootMark()}:${linux.starttime}`;
150
+ if (platform() !== 'darwin') return null; // not Linux, or the pid is gone
152
151
  try {
153
152
  const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
154
153
  encoding: 'utf8',
@@ -161,7 +160,12 @@ export function processStartTime(pid) {
161
160
  }
162
161
  }
163
162
 
164
- /** Signal-0 liveness. EPERM means alive and not ours, which is still alive. */
163
+ /**
164
+ * Signal-0 liveness — THE probe (2026-10-10, F050/F090: instance.mjs kept a
165
+ * byte-for-byte copy and uninstall a drifted one). EPERM means alive and not
166
+ * ours, which is still alive: a reader deciding whether something may be
167
+ * removed or replaced from under a process errs toward the process.
168
+ */
165
169
  export function processAlive(pid) {
166
170
  if (!Number.isInteger(pid) || pid <= 0) return false;
167
171
  try {
@@ -172,6 +176,22 @@ export function processAlive(pid) {
172
176
  }
173
177
  }
174
178
 
179
+ /**
180
+ * Alive AND signalable by us — the reading for a pid that can only ever be a
181
+ * process THIS uid spawned (the turn lock's CLI, turnLock.mjs). There EPERM is
182
+ * by definition not ours: a pid we may not signal was recycled to another
183
+ * uid, so the record naming it is stale. Everywhere else, `processAlive`.
184
+ */
185
+ export function aliveAndOurs(pid) {
186
+ if (!Number.isInteger(pid) || pid <= 0) return false;
187
+ try {
188
+ process.kill(pid, 0);
189
+ return true;
190
+ } catch {
191
+ return false; // ESRCH: gone. EPERM: another uid's process — never ours.
192
+ }
193
+ }
194
+
175
195
  /**
176
196
  * THE DEFAULT PRUNE: a row whose pid is alive, or dead for less than a week,
177
197
  * capped at the newest `MAX_ENTRIES`. An entry whose pid has been dead for a
@@ -34,7 +34,7 @@
34
34
  */
35
35
 
36
36
  import { readdirSync, readlinkSync, realpathSync } from 'node:fs';
37
- import { sep } from 'node:path';
37
+ import { isPathInside } from './repoPath.mjs';
38
38
 
39
39
  /** Rows collected per scan before the walk stops early — a bound on the ROWS
40
40
  * held (and the per-row work: an fd walk, a stat read) for one pathological
@@ -75,8 +75,7 @@ export function insideDir(dir) {
75
75
  } catch {
76
76
  return null;
77
77
  }
78
- const prefix = root.endsWith(sep) ? root : root + sep;
79
- return (p) => p === root || p.startsWith(prefix);
78
+ return (p) => isPathInside(p, root);
80
79
  }
81
80
 
82
81
  function cwdOf(raw) {