flowviant 0.78.0 → 0.79.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.
package/README.md CHANGED
@@ -47,7 +47,11 @@ Launch with `@latest` so each start pulls the newest published version — a bar
47
47
 
48
48
  Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
49
49
 
50
- Nothing starts work except you opening a tab and typing in it.
50
+ Nothing starts a session except you opening a tab and typing in it.
51
+
52
+ ## Agents
53
+
54
+ Press **Deploy** on the board and this machine runs a read-only scratch turn that proposes how the selected cards should be split across agents; accepting the proposal is what cuts worktrees and starts work. From **0.79.0** that planning turn is relayed the same way a session turn is — the reads, greps and thoughts the daemon was already printing behind `[plan]` now reach the press itself, along with the two facts only this side can see: the CLI actually starting, and the press waiting for the checkout while a ship or another turn holds it. A planning CLI that wedges is stopped after fifteen minutes and the press is reported failed in the machine's own words, rather than sitting silent until the server expires it half an hour later.
51
55
 
52
56
  ## Sharing a preview
53
57
 
@@ -92,12 +92,28 @@ import { createServer, request } from 'node:http';
92
92
  import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
93
93
  import { GRANT_COOKIE, cookieValues, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
94
94
 
95
- /** Failed attempts before the proxy stops answering at all. A quick tunnel's
96
- * hostname is unguessable, so this is not the primary control — it is what
97
- * turns a discovered URL from an offline guessing target into a visible,
98
- * self-closing incident. */
95
+ /** Wrong-password attempts from ONE source before that source's Basic
96
+ * attempts are refused outright. A quick tunnel's hostname is unguessable,
97
+ * so this is not the primary control but it is KNOWN to every past
98
+ * member, tester and password recipient, whose access cannot be recalled.
99
+ * The threshold is per source because one global counter made 25 wrong
100
+ * guesses from any URL holder a kill switch on everyone else's share. */
99
101
  const MAX_FAILED = 25;
100
102
 
103
+ /** Counted failures across ALL sources before the whole share tears down.
104
+ * The per-source block above answers a single abuser; this answers a
105
+ * DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
106
+ * each) — the original self-closing-incident property, kept at a threshold
107
+ * ordinary use cannot reach: a blocked source stops counting, so getting
108
+ * here takes eight independent sources each burning their full allowance. */
109
+ const MAX_FAILED_TOTAL = 200;
110
+
111
+ /** Bound on the per-source map — a rotating attacker must not grow daemon
112
+ * memory without limit. Eviction is oldest-first and CAN un-block an evicted
113
+ * source, but cycling 500 fresh sources costs at least 500 counted failures,
114
+ * and the global backstop closes the share long before that. */
115
+ const MAX_SOURCES = 500;
116
+
101
117
  const digest = (s) => createHash('sha256').update(String(s)).digest();
102
118
 
103
119
  /** Constant-time over sha256 digests, so length never leaks and a missing
@@ -116,8 +132,10 @@ function sameSecret(a, b) {
116
132
  * hard failure. Binds loopback only; cloudflared connects locally, and the
117
133
  * password is what gates the public hostname.
118
134
  *
119
- * `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
120
- * tear the whole share down rather than leaving a URL under attack.
135
+ * `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
136
+ * sources, so the caller can tear the whole share down rather than leaving a
137
+ * URL under attack. A single source is blocked on its own, at MAX_FAILED,
138
+ * without ending anybody else's share.
121
139
  */
122
140
  export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId, authorizeUrl }) {
123
141
  // ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
@@ -134,44 +152,82 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
134
152
  const password = randomBytes(24).toString('base64url');
135
153
  const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
136
154
 
137
- let failed = 0;
155
+ let failedTotal = 0;
138
156
  let abused = false;
157
+ /** source → wrong-password count, insertion-ordered so eviction below is
158
+ * oldest-first. A source at MAX_FAILED is BLOCKED: its Basic attempts are
159
+ * refused BEFORE the comparison and stop counting toward the total. */
160
+ const failedBySource = new Map();
139
161
  /** The payload of the last validly-signed but EXPIRED grant seen, so a
140
162
  * tester can be re-bounced with the token inside it. */
141
163
  let lastExpired = null;
142
164
 
165
+ /** cloudflared forwards the real client address in Cf-Connecting-Ip; a
166
+ * direct local connection (the machine's own curl, the tests) has only the
167
+ * socket. The header is attacker-writable in principle, but lying in it
168
+ * only SPREADS one attacker across per-source counters — which is exactly
169
+ * the shape MAX_FAILED_TOTAL exists to answer. */
170
+ const sourceOf = (req) =>
171
+ String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
172
+
173
+ const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
174
+
175
+ const noteFailure = (req) => {
176
+ const src = sourceOf(req);
177
+ const count = (failedBySource.get(src) ?? 0) + 1;
178
+ // Delete-then-set so Map insertion order tracks recency, making the
179
+ // eviction below an LRU rather than "whoever failed first".
180
+ failedBySource.delete(src);
181
+ failedBySource.set(src, count);
182
+ if (failedBySource.size > MAX_SOURCES) {
183
+ failedBySource.delete(failedBySource.keys().next().value);
184
+ }
185
+ if (count === MAX_FAILED) {
186
+ log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
187
+ }
188
+ // MONOTONE, never reset by a success: a distributed run has no successes
189
+ // to hide behind, and a legitimate share cannot reach the backstop —
190
+ // every source stops counting at MAX_FAILED, so 200 needs eight distinct
191
+ // sources each exhausting their own allowance.
192
+ failedTotal += 1;
193
+ if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
194
+ abused = true;
195
+ log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
196
+ try {
197
+ onAbuse?.();
198
+ } catch {
199
+ /* the caller's teardown is best-effort */
200
+ }
201
+ }
202
+ };
203
+
143
204
  /**
144
205
  * A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
145
206
  * lets the websocket upgrade handler reuse it verbatim, and it is why routing
146
207
  * decisions live in the request handler instead.
147
208
  *
148
- * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass'.
209
+ * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
149
210
  */
150
211
  const credential = (req) => {
151
212
  if (abused) return 'badpass';
152
- if (sameSecret(req.headers['authorization'], expected)) {
153
- failed = 0;
154
- return 'ok';
155
- }
156
213
  // ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
157
214
  // than a preference. A forged HMAC is not brute-forceable, so counting it
158
215
  // buys nothing — while counting it would hand any stranger who finds the
159
- // hostname a 25-request KILL SWITCH on the owner's share, because onAbuse
160
- // tears the whole thing down. An expired-but-validly-signed grant must
161
- // never count either, or a viewer who left a tab open overnight closes the
162
- // share on their own reload. MAX_FAILED keeps its exact meaning, which is
163
- // also why the 'abuse' ended-reason sentence stays true.
216
+ // hostname a kill switch on the owner's share, because onAbuse tears the
217
+ // whole thing down. An expired-but-validly-signed grant must never count
218
+ // either, or a viewer who left a tab open overnight closes the share on
219
+ // their own reload.
164
220
  if (req.headers['authorization']) {
165
- failed += 1;
166
- if (failed >= MAX_FAILED && !abused) {
167
- abused = true;
168
- log?.(`preview gate: ${failed} failed attempts — closing the share.`);
169
- try {
170
- onAbuse?.();
171
- } catch {
172
- /* the caller's teardown is best-effort */
173
- }
221
+ // A blocked source is refused BEFORE the comparison — a block that
222
+ // still grades guesses would let the brute force run to a correct hit.
223
+ if (sourceBlocked(req)) return 'blocked';
224
+ if (sameSecret(req.headers['authorization'], expected)) {
225
+ // Per-source only: a shared NAT recovers when one person behind it
226
+ // gets the password right; failedTotal stays monotone (see above).
227
+ failedBySource.delete(sourceOf(req));
228
+ return 'ok';
174
229
  }
230
+ noteFailure(req);
175
231
  return 'badpass';
176
232
  }
177
233
 
@@ -188,8 +244,6 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
188
244
  return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
189
245
  };
190
246
 
191
- const authed = (req) => credential(req) === 'ok';
192
-
193
247
  /**
194
248
  * The CSRF backstop for the partitioned cookie (header rule 4a): a browser
195
249
  * old enough to ignore `Partitioned` while honouring `SameSite=None` will
@@ -388,6 +442,14 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
388
442
  }
389
443
  // 3. One predicate.
390
444
  const verdict = credential(req);
445
+ // A blocked source gets 429, not the challenge — a WWW-Authenticate here
446
+ // would invite the retry the block exists to end. Only that source's
447
+ // Basic attempts are refused; every other viewer's share is untouched.
448
+ if (verdict === 'blocked') {
449
+ res.writeHead(429, { 'Content-Type': 'text/plain', ...noStore });
450
+ res.end('too many failed attempts from this address');
451
+ return;
452
+ }
391
453
  if (verdict !== 'ok') {
392
454
  if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
393
455
  return challenge(res);
@@ -435,10 +497,17 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
435
497
  }
436
498
  // NEVER 302 HERE — browsers fail an upgrade rather than following a 3xx, so
437
499
  // a bounce would read as a dead dev server. The cookie IS sent on a
438
- // same-origin handshake, so `authed` works unchanged; a cookie-less upgrade
439
- // stays a 401 and the page's HMR client reconnects once the human has
440
- // re-authenticated in the main document.
441
- if (!authed(req)) {
500
+ // same-origin handshake, so the predicate works unchanged; a cookie-less
501
+ // upgrade stays a 401 and the page's HMR client reconnects once the human
502
+ // has re-authenticated in the main document. A blocked source's Basic
503
+ // handshake gets the same 429 the request path sends, and no challenge.
504
+ const upgradeVerdict = credential(req);
505
+ if (upgradeVerdict === 'blocked') {
506
+ socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
507
+ socket.destroy();
508
+ return;
509
+ }
510
+ if (upgradeVerdict !== 'ok') {
442
511
  socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
443
512
  socket.destroy();
444
513
  return;
@@ -12,10 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
- import { mkdtempSync, writeFileSync } from 'node:fs';
16
- import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
18
- import { SAFE, MODEL } from './config.mjs';
15
+ import { SAFE } from './config.mjs';
19
16
  import { runtimeById, humanizeClaudeTool } from './runtimes.mjs';
20
17
 
21
18
  // Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
@@ -141,20 +138,6 @@ export const blockedId = (out) => {
141
138
  return m ? m[1] : null;
142
139
  };
143
140
 
144
- export function mcpConfigFor(token, mcpUrl) {
145
- const dir = mkdtempSync(join(tmpdir(), 'flowviant-'));
146
- const p = join(dir, 'mcp.json');
147
- writeFileSync(
148
- p,
149
- JSON.stringify({
150
- mcpServers: {
151
- flowviant: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } },
152
- },
153
- })
154
- );
155
- return { dir, path: p };
156
- }
157
-
158
141
  /**
159
142
  * Hand a runtime the flowviant MCP server, however that runtime wants it.
160
143
  *
@@ -257,7 +257,13 @@ export function processDeployJobs(jobs, ctx) {
257
257
  */
258
258
  let missed = 0;
259
259
  beat = setInterval(() => {
260
- void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() })
260
+ void post('deploy-heartbeat', {
261
+ jobId: job.id,
262
+ pubkey: ctx.myPubB64(),
263
+ // Instance rides the heartbeat too, or a same-box sibling's beat
264
+ // could keep a dead claimer's job "running" past the stale sweep.
265
+ instance: DAEMON_INSTANCE,
266
+ })
261
267
  .then(() => {
262
268
  missed = 0;
263
269
  })
@@ -385,6 +391,12 @@ async function report(job, ctx, outcome, stillBeating = () => true) {
385
391
  const body = {
386
392
  jobId: job.id,
387
393
  pubkey: ctx.myPubB64(),
394
+ // The same term the claim carries, for the same reason: the pubkey is one
395
+ // keypair per home directory, so two daemons on one box share it, and a
396
+ // stale holder's late report would otherwise settle the RECLAIMER's
397
+ // running job. The server matches it when present; an older server
398
+ // ignores the extra key.
399
+ instance: DAEMON_INSTANCE,
388
400
  ok: !!outcome.ok,
389
401
  deploymentId: outcome.deploymentId ?? null,
390
402
  healthOk: outcome.healthOk ?? null,
package/bin/lib/env.mjs CHANGED
@@ -249,7 +249,9 @@ export async function loadCachedEnv(projectId) {
249
249
  if (!cached) return false;
250
250
  values = cached.values ?? [];
251
251
  bundleVersion = cached.bundleVersion ?? -1;
252
- knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
252
+ // Filtered even though we wrote the cache: this set feeds removeStaleEnvFile,
253
+ // and a cache file predates whatever rules the running daemon enforces.
254
+ knownTargetFiles = new Set((cached.knownFiles ?? values.map((v) => v.targetFile)).filter(isSafeTarget));
253
255
  cachedProjectId = projectId;
254
256
  return values.length > 0;
255
257
  }
@@ -264,8 +266,8 @@ export async function loadCachedEnv(projectId) {
264
266
  * only and never touches the user's repo". Git does not read that file: it
265
267
  * resolves `info/exclude` against $GIT_COMMON_DIR — the main `.git` — so in
266
268
  * every linked worktree the daemon creates, the exclusion did nothing at all.
267
- * The plaintext secret files stayed visible to `git add -A`, which is what
268
- * `checkpointWip` runs before force-pushing a WIP commit to the remote.
269
+ * The plaintext secret files stayed visible to `git add -A` the first thing
270
+ * an agent runs before committing and pushing the branch it is working on.
269
271
  *
270
272
  * `--git-common-dir` is asked of git rather than derived, because that is the
271
273
  * one answer that cannot drift from what git itself will consult. The file is
@@ -307,13 +309,25 @@ export function excludeInWorktree(wt, relPaths) {
307
309
  }
308
310
  }
309
311
 
312
+ /** MUST match the server's isSafeEnvTargetFile (env.schema.ts) — the server
313
+ * validates at intake, but this file also refills paths from the on-disk
314
+ * cache and hands them to rmSync, so the daemon holds its own line rather
315
+ * than trusting either source. `.git` is refused at ANY depth and
316
+ * case-insensitively (git treats `.GIT` the same on case-insensitive
317
+ * filesystems): a target of `.git/hooks/pre-commit` would turn a
318
+ * materialized value into code git runs on the operator's next commit —
319
+ * check-ignore alone must not be the only thing standing there. The control
320
+ * range subsumes the old bare `\0` check and keeps a newline out of a PATH,
321
+ * where nothing downstream expects one. */
310
322
  const isSafeTarget = (p) =>
311
- p &&
323
+ typeof p === 'string' &&
324
+ p.length > 0 &&
312
325
  p.length <= 200 &&
313
326
  !p.includes('\\') &&
314
- !p.includes('\0') &&
327
+ // eslint-disable-next-line no-control-regex
328
+ !/[\x00-\x1f\x7f]/.test(p) &&
315
329
  !p.startsWith('/') &&
316
- p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
330
+ p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..' && s.toLowerCase() !== '.git');
317
331
 
318
332
  /** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
319
333
  * materializing secrets into a tracked file would make them stageable and
@@ -406,8 +420,16 @@ function renderEnvFile(list) {
406
420
  }
407
421
 
408
422
  /** Delete a materialized file from a worktree, but ONLY if it's ours (carries
409
- * our header) and not git-tracked — never touch a file we didn't write. */
423
+ * our header) and not git-tracked — never touch a file we didn't write.
424
+ *
425
+ * `rel` gets the SAME gate the write path has: it arrives via
426
+ * knownTargetFiles, which is refilled from the server bundle and from the
427
+ * on-disk cache, and this function joins it to a worktree and calls rmSync.
428
+ * The isTrackedInGit check cannot stand in for validation — `ls-files` on a
429
+ * path outside the worktree THROWS, the catch reads as "not tracked", and
430
+ * the deletion proceeds. A delete primitive validates its own input. */
410
431
  function removeStaleEnvFile(wt, rel) {
432
+ if (!isSafeTarget(rel)) return;
411
433
  if (isTrackedInGit(wt, rel)) return;
412
434
  const abs = join(wt, rel);
413
435
  try {
@@ -460,8 +482,8 @@ export function materializeInto(wt) {
460
482
 
461
483
  // Exclude BEFORE writing, not after. The old order wrote plaintext first and
462
484
  // tried to hide it afterwards, so every failure mode — and the exclude file
463
- // being the wrong one, which it was — left a readable secret in a tree that
464
- // `checkpointWip` force-pushes.
485
+ // being the wrong one, which it was — left a readable secret sitting where
486
+ // the next `git add -A` in that tree would stage it for a push.
465
487
  excludeInWorktree(wt, [...byFile.keys()]);
466
488
 
467
489
  const written = [];
@@ -477,9 +499,9 @@ export function materializeInto(wt) {
477
499
  anyProblem = true;
478
500
  continue;
479
501
  }
480
- // The load-bearing check. A materialized secret sits in a worktree whose
481
- // whole tree gets `git add -A`'d and force-pushed by the WIP checkpoint, so
482
- // "git cannot see this file" is a precondition for writing it, not a nicety.
502
+ // The load-bearing check. A materialized secret sits in a worktree where an
503
+ // agent runs `git add -A` and pushes as a matter of course, so "git cannot
504
+ // see this file" is a precondition for writing it, not a nicety.
483
505
  if (!isIgnoredInGit(wt, file)) {
484
506
  warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
485
507
  refusedForGit.push(file);
@@ -557,17 +579,6 @@ export function materializeInto(wt) {
557
579
  everMaterialized = true;
558
580
  }
559
581
 
560
- /**
561
- * The secret files this daemon has materialized into `wt`.
562
- *
563
- * Exists so anything that stages the whole tree can subtract them by pathspec.
564
- * Belt to the check-ignore braces: `git add -A` obeys .gitignore, so a properly
565
- * ignored file is already safe — but "already safe" was the assumption that put
566
- * plaintext on a remote branch, and a second, independent mechanism is cheap.
567
- */
568
- export function materializedFiles(wt) {
569
- return [...(lastFilesByWorktree.get(wt) ?? [])];
570
- }
571
582
 
572
583
  // ── Uplink scrubbing ───────────────────────────────────────────────────────
573
584
 
@@ -704,7 +715,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
704
715
  bundleVersion = bundle.bundleVersion;
705
716
  // Fold the current target files into the persisted known set so stale
706
717
  // cleanup survives a restart (a key deleted while down still gets swept).
707
- for (const v of values) knownTargetFiles.add(v.targetFile);
718
+ // Filtered at the fill, not just at the delete: this set is persisted,
719
+ // so an unsafe path admitted here would outlive the bundle that sent it.
720
+ for (const v of values) if (isSafeTarget(v.targetFile)) knownTargetFiles.add(v.targetFile);
708
721
  if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
709
722
  ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
710
723
  return { changed: true };
package/bin/lib/fleet.mjs CHANGED
@@ -38,7 +38,6 @@ import { handleVersionSignal } from './update.mjs';
38
38
  import {
39
39
  git,
40
40
  resetWorktree,
41
- ensureWorktree,
42
41
  repoRootOrDie,
43
42
  detectBaseRef,
44
43
  originSlug,
@@ -46,7 +45,6 @@ import {
46
45
  isValidPrUrl,
47
46
  isValidBranch,
48
47
  isSafePathSegment,
49
- worktreeDiffstat,
50
48
  } from './git.mjs';
51
49
  import { c, info, note, ok, warn, fail } from './ui.mjs';
52
50
  import { revertPatch, withPatchLock } from './patch.mjs';
@@ -321,7 +319,13 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
321
319
  repoStateScanAt = Date.now();
322
320
  let payload;
323
321
  try {
324
- const state = repoState(repoRoot, getBaseRef());
322
+ // The PARAM, and nothing else: this function sits at MODULE scope, where
323
+ // runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
324
+ // reached for it anyway, the ReferenceError landed in the catch below —
325
+ // written for an unreadable repo, silent by design — and this endpoint was
326
+ // never once posted to. The caller reads the loop's live `baseRef` at call
327
+ // time, so the value here is always current.
328
+ const state = repoState(repoRoot, baseRef);
325
329
  if (!state) return; // not readable — say nothing rather than say "none"
326
330
  payload = JSON.stringify(state);
327
331
  } catch {
@@ -773,33 +777,6 @@ export async function runFleetDaemon() {
773
777
  return run;
774
778
  };
775
779
 
776
- /** A clean detached checkout at base — what "the real code" has to mean for a
777
- * question about the repo, rather than whatever half-finished state an agent
778
- * worktree happens to be in. Shared by the plan check and consults. */
779
- const ensureWikiWorktree = () => {
780
- if (!existsSync(wikiWt)) {
781
- try {
782
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
783
- } catch {
784
- git(['worktree', 'prune'], repoRoot);
785
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
786
- }
787
- return;
788
- }
789
- // It already exists — which means it is pinned to whatever base pointed at
790
- // when it was FIRST created, possibly weeks ago. "Reads the real code" has
791
- // to mean the current base, so re-point it. Best-effort: a stale answer
792
- // beats no answer, and the next turn tries again.
793
- try {
794
- git(['fetch', 'origin', '--quiet'], repoRoot);
795
- git(['checkout', '--detach', baseRef], wikiWt);
796
- git(['reset', '--hard', baseRef], wikiWt);
797
- git(['clean', '-fd'], wikiWt);
798
- } catch {
799
- /* offline, or a turn left it dirty — read what we have */
800
- }
801
- };
802
-
803
780
  // Machine telemetry — what the box is doing with itself, for the admin view.
804
781
  const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
805
782
 
@@ -1146,7 +1123,24 @@ export async function runFleetDaemon() {
1146
1123
  // the turn still reads the real changed files; this catches pages whose
1147
1124
  // frontmatter file list has drifted, or that document a concept rather
1148
1125
  // than a directory.
1149
- dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
1126
+ //
1127
+ // SANITIZED AT THE INTAKE, because every entry is server-supplied text
1128
+ // that ends up interpolated into a prompt and printed by the drain's
1129
+ // narration: a control byte can repaint the console it lands on, a
1130
+ // newline can break out of the prompt's own list framing, and an
1131
+ // unbounded array of unbounded strings is an unbounded prompt. This is
1132
+ // the belt at the intake; the prompt keeps its own fence at the
1133
+ // interpolation.
1134
+ dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
1135
+ .filter((p) => typeof p === 'string')
1136
+ .slice(0, 40)
1137
+ .map((p) =>
1138
+ p
1139
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
1140
+ .trim()
1141
+ .slice(0, 300)
1142
+ )
1143
+ .filter(Boolean),
1150
1144
  // THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
1151
1145
  // Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
1152
1146
  // shas on every reground job, this function never stored them, and the
@@ -1686,12 +1680,7 @@ export async function runFleetDaemon() {
1686
1680
  // tab. Without this the daemon that lost the lease removes the worktree the
1687
1681
  // winner is working in — absence would mean "somebody else won" instead of
1688
1682
  // "the tab closed".
1689
- retireWorkSessions(
1690
- Array.isArray(roster.activeWorkSessions)
1691
- ? roster.activeWorkSessions
1692
- : roster.activeWorkSessions,
1693
- roster.sessionsHeldElsewhere
1694
- );
1683
+ retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
1695
1684
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1696
1685
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1697
1686
  // tab's branch and a shipped commit on main alike.