flowviant 0.96.0 → 0.99.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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +174 -33
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +377 -45
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1261 -4476
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -29,7 +29,7 @@ import { join } from 'node:path';
29
29
  import { homedir } from 'node:os';
30
30
  import { createHash } from 'node:crypto';
31
31
  import { git, baseBranchName } from './git.mjs';
32
- import { stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
32
+ import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
33
33
  import { warn } from './ui.mjs';
34
34
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
35
35
 
@@ -95,41 +95,39 @@ export function createLandedObserver({ repoRoot, baseRef }) {
95
95
  * merge commit describes a range rather than doing work, and its
96
96
  * constituents are walked as themselves.
97
97
  *
98
- * THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. Git
99
- * preserves the 0x1e/0x1f delimiter bytes inside a commit BODY (verified
100
- * empirically), so a crafted message can fabricate whole records — an
101
- * arbitrary sha plus Flowviant-Task ids that /fleet/base-landed would close
102
- * cards on. rev-list prints nothing an author controls, so its output is
103
- * the set of commits that exist: a parsed record whose sha is not in the
104
- * batch is a forgery and is dropped, a repeated sha is the same forgery
105
- * wearing a real commit's name, and the delimiter bytes are stripped from
106
- * every surviving field.
98
+ * THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG, and each
99
+ * commit is then read BY ITS OWN SHA (`commitRecord`, one `git show` per
100
+ * commit) rather than as one delimited multi-record log. A batched log
101
+ * used to print every commit's `%H%x1f%s%x1f%B%x1e` back to back and split
102
+ * the blob on those same delimiter bytes — bytes git preserves verbatim
103
+ * inside a commit BODY (verified empirically), so a crafted message could
104
+ * embed its own fake `%x1e`/`%x1f%1f` run and fabricate an extra "record"
105
+ * naming an arbitrary sha and Flowviant-Task ids that /fleet/base-landed
106
+ * would close cards on. Reading one sha at a time removes the multi-record
107
+ * parse entirely — there is no blob to split, so there is nothing for an
108
+ * embedded delimiter to forge a SECOND record out of. `stripDelims` still
109
+ * scrubs whatever lands in the subject/body of the commit it actually
110
+ * belongs to, the same defence-in-depth `branchCommits` above keeps.
107
111
  *
108
- * BOUNDING THE BODY FETCH TO THE BATCH is what makes the header's batching
109
- * contract true at any range size: `%B` over the whole range grows without
110
- * bound, so the formatted log runs over exactly the shas being reported
111
- * this beat (`--no-walk=unsorted` shows precisely the commits named, in
112
- * argv order — measured). */
112
+ * ONE UNREADABLE COMMIT IS SKIPPED, NOT AN ABORT OF THE WHOLE BATCH:
113
+ * `commitRecord` runs through `gitRaw`, whose `maxBuffer` is git.mjs's
114
+ * plain default (1MiB) rather than this module's widened `gitWide` — a
115
+ * single commit whose message alone exceeds that returns null and is
116
+ * dropped, while every other commit in the batch still reports. */
113
117
  const walk = (from, to) => {
114
118
  const shas = gitWide(['rev-list', '--reverse', '--no-merges', `${from}..${to}`])
115
119
  .split('\n')
116
120
  .filter((s) => SHA_RE.test(s));
117
121
  const batch = shas.slice(0, MAX_COMMITS);
118
122
  const tipAfter = shas.length > MAX_COMMITS ? batch[batch.length - 1] : null;
119
- if (batch.length === 0) return { commits: [], tipAfter };
120
- const real = new Set(batch);
121
- const raw = gitWide(['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%B%x1e', ...batch]);
122
123
  const out = [];
123
- for (const rec of raw.split('\x1e')) {
124
- const line = rec.replace(/^\n+/, '');
125
- if (!line.trim()) continue;
126
- const [sha, subject, ...bodyParts] = line.split('\x1f');
127
- if (!real.has(sha)) continue;
128
- real.delete(sha);
124
+ for (const sha of batch) {
125
+ const rec = commitRecord(sha, repoRoot);
126
+ if (!rec) continue;
129
127
  out.push({
130
128
  sha,
131
- subject: stripDelims(subject).slice(0, 200),
132
- taskIds: taskIdsFromMessage(stripDelims(bodyParts.join('\n'))).slice(0, 8),
129
+ subject: stripDelims(rec.subject).slice(0, 200),
130
+ taskIds: taskIdsFromMessage(stripDelims(rec.body)).slice(0, 8),
133
131
  });
134
132
  }
135
133
  return { commits: out, tipAfter };
@@ -202,13 +200,23 @@ export function createLandedObserver({ repoRoot, baseRef }) {
202
200
  for (const r of j?.data?.deployRefused ?? []) {
203
201
  warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
204
202
  }
203
+ } else if (res.status === 408 || res.status === 429) {
204
+ // A TIMEOUT OR A RATE LIMIT IS "NOT NOW", NOT "NEVER". Both used to
205
+ // land in the drop below, so one 429 — a busy operator IP past the
206
+ // limiter — skipped the range for good: its trailers and receipt shas
207
+ // never closed their cards (in PR mode the observer is the ONLY road
208
+ // to Done) and deploy-on-merge for that tip was never queued. Keep the
209
+ // stored tip, exactly as a 5xx does; the next beat re-walks it.
205
210
  } else if (res.status >= 400 && res.status < 500) {
206
211
  // A persistent 4xx (deploy skew, a payload this server refuses) would
207
212
  // otherwise re-send the same poison range on every beat forever.
208
- // Drop the range — the closes it carried re-run at the next REAL tip
209
- // move only if their cards are still open, which is the idempotent
210
- // half; the honest cost is stated out loud.
211
- writeState({ ref, tip });
213
+ // Drop THIS BATCH — to `reportedTip`, the last commit it carried, never
214
+ // the full tip: a range past the cap has a remainder this batch never
215
+ // sent, and advancing to the tip discarded that too. The closes it
216
+ // carried re-run at the next REAL tip move only if their cards are
217
+ // still open, which is the idempotent half; the honest cost is stated
218
+ // out loud.
219
+ writeState({ ref, tip: reportedTip });
212
220
  warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
213
221
  }
214
222
  } catch {
@@ -509,3 +509,214 @@ export function isListening(port, timeoutMs = 1500) {
509
509
  sock.once('error', () => finish(false));
510
510
  });
511
511
  }
512
+
513
+ // ── the address a share dials ────────────────────────────────────────────────
514
+
515
+ /**
516
+ * WHICH ADDRESS REACHES THE ATTRIBUTED PROCESS on `port`, or why none does
517
+ * (audit 2026-09-24).
518
+ *
519
+ * Attribution above is keyed on the PORT, and the gate used to dial
520
+ * `127.0.0.1:<port>` whatever socket had been attributed. Linux and macOS let
521
+ * two processes hold one port on the two loopback families side by side, so a
522
+ * dev server in this worktree on `[::1]:5173` and an unrelated process on
523
+ * `127.0.0.1:5173` passed the attribution check while the tunnel published the
524
+ * UNRELATED one — the exact thing attribution exists to prevent. And the
525
+ * ordinary case broke the other way: Vite's default `localhost` bind resolves
526
+ * to `::1` on a Mac, so the share opened and every request answered 502.
527
+ *
528
+ * So the dial address is DERIVED FROM THE ATTRIBUTED SOCKET: a wildcard bind is
529
+ * reached on its own family's loopback, a specific bind on its own address —
530
+ * and a candidate is REFUSED when a process outside the worktree holds that
531
+ * same address (or the same wildcard, via SO_REUSEPORT) on that port, because
532
+ * the kernel may then hand the connection to either. IPv4 is preferred when
533
+ * both reach the attributed process, since that is what every dev server's
534
+ * own printed URL means.
535
+ *
536
+ * `{ host }` on success, `{ error }` with a sentence otherwise. A platform this
537
+ * file cannot measure answers the loopback it always dialled — the same
538
+ * behaviour as before, never a refusal invented from ignorance.
539
+ */
540
+ export function originFor(worktree, port) {
541
+ if (!worktree || !Number.isInteger(port) || port <= 0 || port > 65535) {
542
+ return { error: `nothing is listening on port ${port} in this worktree.` };
543
+ }
544
+ let sockets;
545
+ try {
546
+ sockets =
547
+ platform() === 'linux'
548
+ ? socketsOnPortLinux(worktree, port)
549
+ : platform() === 'darwin'
550
+ ? socketsOnPortDarwin(worktree, port)
551
+ : null;
552
+ } catch {
553
+ sockets = null;
554
+ }
555
+ if (sockets == null) return { host: '127.0.0.1' };
556
+ return pickOrigin(sockets, port);
557
+ }
558
+
559
+ /**
560
+ * The rule alone, over `[{ family: 4|6, addr, ours }]` — split out so it is
561
+ * testable without two address families on the test box.
562
+ */
563
+ export function pickOrigin(sockets, port) {
564
+ const ours = sockets.filter((s) => s.ours);
565
+ if (!ours.length) return { error: `nothing is listening on port ${port} in this worktree.` };
566
+ const foreign = sockets.filter((s) => !s.ours);
567
+ const dialOf = (s) =>
568
+ s.family === 6 ? (s.addr === '::' ? '::1' : s.addr) : s.addr === '0.0.0.0' ? '127.0.0.1' : s.addr;
569
+ const rank = (s) => {
570
+ const d = dialOf(s);
571
+ return d === '127.0.0.1' ? 0 : d === '::1' ? 1 : s.family === 4 ? 2 : 3;
572
+ };
573
+ let contested = false;
574
+ for (const s of [...ours].sort((a, b) => rank(a) - rank(b))) {
575
+ const dial = dialOf(s);
576
+ const shadowed = foreign.some((f) => f.family === s.family && (f.addr === dial || f.addr === s.addr));
577
+ if (shadowed) {
578
+ contested = true;
579
+ continue;
580
+ }
581
+ return { host: dial };
582
+ }
583
+ return contested
584
+ ? {
585
+ error: `another process on this machine, outside this worktree, also listens on port ${port} at the same address, so it was not shared.`,
586
+ }
587
+ : { error: `nothing is listening on port ${port} in this worktree.` };
588
+ }
589
+
590
+ /** /proc/net/tcp{,6} address hex → a printable address. Each 32-bit word is
591
+ * stored little-endian. */
592
+ function hexAddr(hex, family) {
593
+ const words = hex.match(/.{8}/g) || [];
594
+ const bytes = [];
595
+ for (const w of words) for (let i = 6; i >= 0; i -= 2) bytes.push(parseInt(w.slice(i, i + 2), 16));
596
+ if (family === 4) return bytes.join('.');
597
+ if (bytes.slice(0, 10).every((b) => b === 0) && bytes[10] === 0xff && bytes[11] === 0xff) {
598
+ return `::ffff:${bytes.slice(12).join('.')}`;
599
+ }
600
+ const groups = [];
601
+ for (let i = 0; i < 16; i += 2) groups.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
602
+ // Compress the longest run of zero groups, the way the address is written.
603
+ let best = [-1, 0];
604
+ for (let i = 0; i < 8; ) {
605
+ if (groups[i] !== '0') {
606
+ i++;
607
+ continue;
608
+ }
609
+ let j = i;
610
+ while (j < 8 && groups[j] === '0') j++;
611
+ if (j - i > best[1]) best = [i, j - i];
612
+ i = j;
613
+ }
614
+ if (best[1] < 2) return groups.join(':');
615
+ const head = groups.slice(0, best[0]).join(':');
616
+ const tail = groups.slice(best[0] + best[1]).join(':');
617
+ return `${head}::${tail}`;
618
+ }
619
+
620
+ function socketsOnPortLinux(worktree, port) {
621
+ const byInode = new Map(); // inode -> { family, addr }
622
+ for (const [f, family] of [
623
+ ['/proc/net/tcp', 4],
624
+ ['/proc/net/tcp6', 6],
625
+ ]) {
626
+ let text;
627
+ try {
628
+ text = readFileSync(f, 'utf8');
629
+ } catch {
630
+ continue;
631
+ }
632
+ for (const line of text.split('\n').slice(1)) {
633
+ const c = line.trim().split(/\s+/);
634
+ if (c.length < 10 || c[3] !== '0A') continue;
635
+ const [a, p] = String(c[1]).split(':');
636
+ if (parseInt(p, 16) !== port) continue;
637
+ if (c[9] && c[9] !== '0') byInode.set(c[9], { family, addr: hexAddr(a, family) });
638
+ }
639
+ }
640
+ if (byInode.size === 0) return [];
641
+ let root;
642
+ try {
643
+ root = realpathSync(worktree);
644
+ } catch {
645
+ return [];
646
+ }
647
+ const prefix = root.endsWith(sep) ? root : root + sep;
648
+ const ours = new Set();
649
+ let pids;
650
+ try {
651
+ pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
652
+ } catch {
653
+ return null;
654
+ }
655
+ if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
656
+ for (const pid of pids) {
657
+ let cwd;
658
+ try {
659
+ cwd = readlinkSync(`/proc/${pid}/cwd`);
660
+ } catch {
661
+ continue;
662
+ }
663
+ if (cwd !== root && !cwd.startsWith(prefix)) continue;
664
+ if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
665
+ let fds;
666
+ try {
667
+ fds = readdirSync(`/proc/${pid}/fd`);
668
+ } catch {
669
+ continue;
670
+ }
671
+ for (const fd of fds) {
672
+ let link;
673
+ try {
674
+ link = readlinkSync(`/proc/${pid}/fd/${fd}`);
675
+ } catch {
676
+ continue;
677
+ }
678
+ const m = /^socket:\[(\d+)\]$/.exec(link);
679
+ if (m && byInode.has(m[1])) ours.add(m[1]);
680
+ }
681
+ }
682
+ // A socket whose holder we could not read (another user's process) is
683
+ // FOREIGN — the safe reading for a refusal check.
684
+ return [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) }));
685
+ }
686
+
687
+ function socketsOnPortDarwin(worktree, port) {
688
+ const rows = []; // { pid, family, addr }
689
+ let pid = null;
690
+ let family = null;
691
+ for (const line of lsof(['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'ptn']).split('\n')) {
692
+ if (line.startsWith('p')) pid = line.slice(1);
693
+ else if (line.startsWith('t')) family = line.slice(1) === 'IPv6' ? 6 : line.slice(1) === 'IPv4' ? 4 : null;
694
+ else if (line.startsWith('n') && pid && family) {
695
+ const m = /^n(.*):(\d+)$/.exec(line);
696
+ if (!m || Number(m[2]) !== port) continue;
697
+ let addr = m[1].replace(/^\[|\]$/g, '');
698
+ if (addr === '*') addr = family === 6 ? '::' : '0.0.0.0';
699
+ rows.push({ pid, family, addr });
700
+ }
701
+ }
702
+ if (!rows.length) return [];
703
+ let root;
704
+ try {
705
+ root = realpathSync(worktree);
706
+ } catch {
707
+ return [];
708
+ }
709
+ const prefix = root.endsWith(sep) ? root : root + sep;
710
+ const inside = new Set();
711
+ const self = String(process.pid);
712
+ let cur = null;
713
+ const pids = [...new Set(rows.map((r) => r.pid))];
714
+ for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', pids.join(',')]).split('\n')) {
715
+ if (line.startsWith('p')) cur = line.slice(1);
716
+ else if (line.startsWith('n') && cur) {
717
+ const cwd = line.slice(1);
718
+ if ((cwd === root || cwd.startsWith(prefix)) && cur !== self && !infraPids.has(Number(cur))) inside.add(cur);
719
+ }
720
+ }
721
+ return rows.map((r) => ({ family: r.family, addr: r.addr, ours: inside.has(r.pid) }));
722
+ }
@@ -105,7 +105,7 @@ export function titleForSession(cwd, sessionId) {
105
105
  /* the place is gone — nothing to read */
106
106
  }
107
107
  for (const dir of dirs) {
108
- const file = join(projects, dir.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
108
+ const file = join(projects, claudeProjectDir(dir), `${sessionId}.jsonl`);
109
109
  try {
110
110
  return transcriptTitle(file, statSync(file).mtimeMs);
111
111
  } catch {
@@ -115,6 +115,27 @@ export function titleForSession(cwd, sessionId) {
115
115
  return null;
116
116
  }
117
117
 
118
+ /**
119
+ * THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
120
+ * copied exactly (2.1.281): every character that is not an ASCII letter or
121
+ * digit becomes `-`, and a name past 200 characters is cut there and suffixed
122
+ * with a base-36 Java-style string hash of the ORIGINAL path.
123
+ *
124
+ * This file used `replace(/[/.]/g, '-')`, which agrees with Claude Code only
125
+ * while a path holds nothing but letters, digits, `/` and `.` — so a repo at
126
+ * `~/code/my_app` or `~/My Projects/x` never offered an ended terminal session
127
+ * for adoption and never got a tab auto-title, silently (audit 2026-09-24).
128
+ */
129
+ const CLAUDE_DIR_MAX = 200;
130
+ export function claudeProjectDir(path) {
131
+ const p = String(path);
132
+ const s = p.replace(/[^a-zA-Z0-9]/g, '-');
133
+ if (s.length <= CLAUDE_DIR_MAX) return s;
134
+ let h = 0;
135
+ for (let i = 0; i < p.length; i++) h = ((h << 5) - h + p.charCodeAt(i)) | 0;
136
+ return `${s.slice(0, CLAUDE_DIR_MAX)}-${Math.abs(h).toString(36)}`;
137
+ }
138
+
118
139
  /** Path-prefix containment on already-realpath'd absolute paths. */
119
140
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
120
141
 
@@ -373,7 +394,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
373
394
  homedir(),
374
395
  '.claude',
375
396
  'projects',
376
- cwd.replace(/[/.]/g, '-'),
397
+ claudeProjectDir(cwd),
377
398
  `${rec.sessionId}.jsonl`
378
399
  );
379
400
  liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
@@ -400,7 +421,11 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
400
421
  // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
401
422
  // which is why every candidate is verified against the cwd its own records
402
423
  // embed rather than trusted on its directory name.
403
- const munged = realRoot.replace(/[/.]/g, '-');
424
+ const munged = claudeProjectDir(realRoot);
425
+ // A root past Claude's 200-character cut carries a hash of ITSELF, which a
426
+ // subdirectory's name does not share — so the prefix a subdirectory can
427
+ // match is the cut, and the cwd verification below does the rest.
428
+ const mungedStem = munged.length > CLAUDE_DIR_MAX ? munged.slice(0, CLAUDE_DIR_MAX) : `${munged}-`;
404
429
  const projectsDir = join(homedir(), '.claude', 'projects');
405
430
  let projDirs = [];
406
431
  try {
@@ -411,7 +436,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
411
436
  const cutoff = Date.now() - ENDED_WINDOW_MS;
412
437
  const candidates = [];
413
438
  for (const dirName of projDirs) {
414
- if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
439
+ if (dirName !== munged && !dirName.startsWith(mungedStem)) continue;
415
440
  let entries = [];
416
441
  try {
417
442
  entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
package/bin/lib/login.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
- import { saveLogin, detectRepoRoot, projectLabel, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
10
+ import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './claude.mjs';
13
13
 
@@ -70,7 +70,7 @@ export async function runLogin({ thenStart = false } = {}) {
70
70
  fleetToken: poll.machineToken ?? poll.fleetToken,
71
71
  projectId: poll.projectId,
72
72
  mcpUrl: poll.mcpUrl,
73
- name: typeof poll.projectName === 'string' && poll.projectName ? poll.projectName : null,
73
+ name: safeName(poll.projectName),
74
74
  repoRoot,
75
75
  };
76
76
  // A DIRECTORY SERVES ONE PROJECT (2026-09-23). The owner, verbatim: "a
@@ -72,7 +72,8 @@
72
72
  */
73
73
 
74
74
  import { USER_AGENT } from './config.mjs';
75
- import { projectLabel, repoCollisions } from './credentials.mjs';
75
+ import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
76
+ import { credentialRejected } from './authReject.mjs';
76
77
 
77
78
  /** The boxes read, derived from the roster URL the way the diffstat post is —
78
79
  * one place configures the API base and everything else is a suffix swap. */
@@ -178,7 +179,13 @@ function roleWord(role) {
178
179
  * does not — and the words beside it are all the server's or the box's own. */
179
180
  export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
180
181
  const mark = box.fresh ? '●' : '○';
181
- const name = box.boxName || 'an unnamed machine';
182
+ // SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
183
+ // own display scrub, in defence of a stale stored row or a server this
184
+ // fix has not reached: the terminal is where an escape sequence in a
185
+ // box's name or its checkout path actually does harm, so this is the one
186
+ // place it must never be trusted to have already been cleaned. See
187
+ // printable.mjs.
188
+ const name = safeName(box.boxName) ?? 'an unnamed machine';
182
189
  // "LAST heard" is the staleness mark, and the row is never hidden for it —
183
190
  // the owner's answer to "what about a box that stopped" was exactly this.
184
191
  const heard = heardPhrase(box, now);
@@ -190,7 +197,7 @@ export function renderBox(box, { me = null, latest = null } = {}, now = Date.now
190
197
  const cells = [
191
198
  `${mark} ${name}`,
192
199
  roleWord(box.role),
193
- box.checkoutPath || '(checkout not reported)',
200
+ safeName(box.checkoutPath) ?? '(checkout not reported)',
194
201
  box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
195
202
  heard,
196
203
  ].filter(Boolean);
@@ -223,7 +230,7 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
223
230
  // to notice a suffix to know whether it matters has already been asked to do
224
231
  // the work this listing is for.
225
232
  for (const e of entries) {
226
- const label = e.name || `project ${e.projectId.slice(0, 8)}…`;
233
+ const label = projectLabel(e);
227
234
  const connected = connectedOn(e.savedAt);
228
235
  const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
229
236
  lines.push(
@@ -340,7 +347,8 @@ export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } =
340
347
  headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
341
348
  signal: AbortSignal.timeout(15_000),
342
349
  });
343
- if (res.status === 401 || res.status === 403) return { rejected: true };
350
+ if (await credentialRejected(res)) return { rejected: true };
351
+ if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
344
352
  if (res.status === 404) return { unsupported: true };
345
353
  if (!res.ok) return { error: `HTTP ${res.status}` };
346
354
  const body = await res.json();
@@ -367,7 +375,7 @@ export function otherBoxesLine(boxes, me, now = Date.now()) {
367
375
  if (others.length === 0) return null;
368
376
  const parts = others.slice(0, 6).map((b) => {
369
377
  const role = roleWord(b.role);
370
- return `${b.boxName || 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
378
+ return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
371
379
  });
372
380
  const more = others.length - parts.length;
373
381
  return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
@@ -403,7 +411,8 @@ export async function leaveBoxFor(entry, { url, envpub, fetchImpl = fetch } = {}
403
411
  body: JSON.stringify({ envpub }),
404
412
  signal: AbortSignal.timeout(15_000),
405
413
  });
406
- if (res.status === 401 || res.status === 403) return { rejected: true };
414
+ if (await credentialRejected(res)) return { rejected: true };
415
+ if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
407
416
  if (res.status === 404) return { unsupported: true };
408
417
  if (!res.ok) return { error: `HTTP ${res.status}` };
409
418
  const body = await res.json();
@@ -46,7 +46,7 @@ import {
46
46
  import { join } from 'node:path';
47
47
  import { homedir, platform, arch } from 'node:os';
48
48
  import { startAuthProxy } from './authproxy.mjs';
49
- import { forgetInfraPid, isListening, noteInfraPid } from './listeners.mjs';
49
+ import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
50
50
 
51
51
  // ── cloudflared: pinned, verified, or not fetched at all ───────────────────
52
52
 
@@ -138,7 +138,19 @@ async function ensureCloudflared(log) {
138
138
  }
139
139
  }
140
140
 
141
- const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
141
+ /**
142
+ * The hostname cloudflared ASSIGNED, and nothing else it happens to print.
143
+ *
144
+ * `[a-z0-9-]+\.trycloudflare\.com` also matched cloudflared's own FAILURE line
145
+ * — `failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel":
146
+ * dial tcp: lookup api.trycloudflare.com: …` — so on any box that could not
147
+ * reach the quick-tunnel API (offline, DNS failure, a proxy blocking it) the
148
+ * share was reported LIVE at Cloudflare's own API host, and cloudflared's real
149
+ * sentence, which the tail exists to relay, was replaced by a wrong one (audit
150
+ * 2026-09-24). `api.` is the service's endpoint and never an assigned name, and
151
+ * the trailing guard stops a longer hostname matching on its prefix.
152
+ */
153
+ export const TUNNEL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com(?![a-z0-9.-])/i;
142
154
 
143
155
  // ── Orphan reaping ─────────────────────────────────────────────────────────
144
156
  // cloudflared is detached so we can kill its whole group — which also means it
@@ -228,6 +240,39 @@ function mutateRegistry(fn) {
228
240
  }
229
241
  }
230
242
 
243
+ /**
244
+ * WHEN a process started, as the kernel reports it — or null when this box
245
+ * will not say. Paired with a pid it names ONE process: a pid alone is
246
+ * recycled, and the owner check below trusted signal 0 on a bare pid.
247
+ */
248
+ export function processStartOf(pid) {
249
+ if (!Number.isInteger(pid) || pid <= 0) return null;
250
+ if (platform() === 'linux') {
251
+ try {
252
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
253
+ // Field 22 (starttime), counted after the LAST ')' — the comm field in
254
+ // parentheses may itself hold spaces and parentheses.
255
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
256
+ return rest[19] ? `l:${rest[19]}` : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+ if (platform() === 'darwin') {
262
+ try {
263
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
264
+ encoding: 'utf8',
265
+ timeout: 5_000,
266
+ stdio: ['ignore', 'pipe', 'ignore'],
267
+ }).trim();
268
+ return out ? `d:${out}` : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
231
276
  /** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
232
277
  function processAlive(pid) {
233
278
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -246,7 +291,15 @@ function recordPreviewPid(pid, sig) {
246
291
  // starting daemon reaped its PEER's live tunnels: killed them, wiped their
247
292
  // entries, and the peer kept heartbeating a URL that 530s (its probe watches
248
293
  // the origin port, which was still alive).
249
- mutateRegistry((list) => [...list, { pid, sig, owner: process.pid }]);
294
+ //
295
+ // `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
296
+ // is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
297
+ // by the next start — any process, another user's included, since EPERM
298
+ // counts as alive — a pid-only check read the entry as a live peer's and
299
+ // skipped it forever, leaving a public hostname aimed at a dead gate's
300
+ // ephemeral port for anything that binds it next.
301
+ const ownerStart = processStartOf(process.pid);
302
+ mutateRegistry((list) => [...list, { pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}) }]);
250
303
  }
251
304
 
252
305
  function forgetPreviewPid(pid) {
@@ -311,13 +364,24 @@ function stillOurs(pid, sig) {
311
364
  * own teardown handles it) — killing those and wiping their entries was a
312
365
  * peer daemon's startup silently breaking every live share on the box. Only
313
366
  * the entries this pass handled are removed; a peer's records survive. */
367
+ /** Is the daemon that recorded an entry still the process at that pid? With
368
+ * a recorded start time, only when the pid's start time still matches (a
369
+ * recycled pid is an orphaned entry); where this box cannot report a start
370
+ * time now, or the entry predates the field, signal 0 is all there is. */
371
+ function ownerStillRunning(owner, ownerStart) {
372
+ if (!processAlive(owner)) return false;
373
+ if (typeof ownerStart !== 'string') return true;
374
+ const now = processStartOf(owner);
375
+ return now == null ? true : now === ownerStart;
376
+ }
377
+
314
378
  export function reapOrphanPreviews(log) {
315
379
  const list = readRegistry();
316
380
  if (list.length === 0) return;
317
381
  let killed = 0;
318
382
  const handled = new Set();
319
- for (const { pid, sig, owner } of list) {
320
- if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
383
+ for (const { pid, sig, owner, ownerStart } of list) {
384
+ if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart)) continue;
321
385
  const ours = stillOurs(pid, sig);
322
386
  /**
323
387
  * THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
@@ -396,6 +460,11 @@ export async function openTunnel({
396
460
  onAbuse,
397
461
  onTunnelGone,
398
462
  stillServing,
463
+ // The worktree the port was attributed to. When given, the gate dials the
464
+ // address the ATTRIBUTED socket holds (`originFor`) rather than assuming
465
+ // 127.0.0.1, and every liveness check re-derives it — so a neighbour on the
466
+ // other loopback family is never what the tunnel publishes.
467
+ worktree,
399
468
  probeMs = 20_000,
400
469
  // The members-gate triple, all three or none. Absent = an older server, or a
401
470
  // password-mode share: the gate runs exactly as it always has.
@@ -406,8 +475,15 @@ export async function openTunnel({
406
475
  // ONE predicate for every liveness question this function asks. Attribution
407
476
  // when the caller gave it, a bare TCP connect only when it did not; an
408
477
  // attribution check that errors is not a "yes".
478
+ // `origin` is decided once, at the gate's start; after that a change of
479
+ // address is a different process and reads as "not serving".
480
+ let origin = null;
409
481
  const serving = async () => {
410
482
  try {
483
+ if (worktree) {
484
+ const o = originFor(worktree, port);
485
+ if (o.error || (origin && o.host !== origin)) return false;
486
+ }
411
487
  return stillServing ? await stillServing() : await isListening(port);
412
488
  } catch {
413
489
  return false;
@@ -455,8 +531,14 @@ export async function openTunnel({
455
531
 
456
532
  // The gate comes up FIRST and the tunnel points at it, never at the origin —
457
533
  // so there is no window in which the public hostname is un-gated.
534
+ if (worktree) {
535
+ const o = originFor(worktree, port);
536
+ if (o.error) return { error: o.error };
537
+ origin = o.host;
538
+ }
458
539
  gate = await startAuthProxy({
459
540
  targetPort: port,
541
+ ...(origin ? { targetHost: origin } : {}),
460
542
  log,
461
543
  // Never logged, never written to previews.json, never in the reap
462
544
  // signature, never in argv or a child env — /proc/<pid>/cmdline is
@@ -529,7 +611,14 @@ export async function openTunnel({
529
611
  const onOut = (d) => {
530
612
  const s = d.toString();
531
613
  tail = (tail + s).slice(-TAIL_BYTES);
532
- const m = TUNNEL_RE.exec(s);
614
+ // ONE URL, ONE PROBE. A second match after we settled (cloudflared
615
+ // repeats the banner, or a later line names another host) used to start
616
+ // a second probe interval and a second close listener on a tunnel whose
617
+ // answer had already been given.
618
+ if (settled) return;
619
+ // Matched over the TAIL, not the chunk: a pipe read can split the
620
+ // hostname across two chunks, and a half never matches.
621
+ const m = TUNNEL_RE.exec(tail);
533
622
  if (!m) return;
534
623
 
535
624
  // Watch the ORIGIN — with the caller's ATTRIBUTION check when it gave