flowviant 0.97.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 +136 -26
  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 +166 -71
  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 +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  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 +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  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 +1192 -4584
  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
@@ -116,6 +116,10 @@ const MAX_SOURCES = 500;
116
116
 
117
117
  const digest = (s) => createHash('sha256').update(String(s)).digest();
118
118
 
119
+ /** An Authorization header in the Basic scheme — the only shape the gate's own
120
+ * password ever arrives in, and so the only one it grades. */
121
+ const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
122
+
119
123
  /** Constant-time over sha256 digests, so length never leaks and a missing
120
124
  * header costs the same as a wrong one. */
121
125
  function sameSecret(a, b) {
@@ -137,7 +141,18 @@ function sameSecret(a, b) {
137
141
  * URL under attack. A single source is blocked on its own, at MAX_FAILED,
138
142
  * without ending anybody else's share.
139
143
  */
140
- export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId, authorizeUrl }) {
144
+ export function startAuthProxy({
145
+ targetPort,
146
+ // The address that reaches the ATTRIBUTED socket (listeners.mjs `originFor`)
147
+ // — `::1` for a dev server bound to the IPv6 loopback. Defaults to the IPv4
148
+ // loopback it always dialled, for a caller that measured nothing.
149
+ targetHost = '127.0.0.1',
150
+ log,
151
+ onAbuse,
152
+ grantSecret,
153
+ shareId,
154
+ authorizeUrl,
155
+ }) {
141
156
  // ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
142
157
  // a secret with no authorize URL has nowhere to send them, an authorize URL
143
158
  // with no secret cannot verify what comes back. An older SERVER sends none of
@@ -217,7 +232,36 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
217
232
  // whole thing down. An expired-but-validly-signed grant must never count
218
233
  // either, or a viewer who left a tab open overnight closes the share on
219
234
  // their own reload.
220
- if (req.headers['authorization']) {
235
+ //
236
+ // AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
237
+ // Authorization header used to be graded against the gate password, so a
238
+ // previewed SPA calling its own `/api` with `Authorization: Bearer …` was
239
+ // answered 401 with a Basic challenge — over a valid grant cookie — and 25
240
+ // such calls blocked that viewer, eight viewers tore the share down, and
241
+ // the origin never saw its own header. A Bearer (or any other scheme) is
242
+ // the APP's credential: it is not ours to grade and it rides through.
243
+ //
244
+ // A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
245
+ // itself speaks Basic sends a header that is not our password, and a
246
+ // viewer the app has vouched for must not be graded on it. A grant is
247
+ // HMAC-verified, so asking it first opens no brute-force path.
248
+ let grantVerdict = 'none';
249
+ if (grants) {
250
+ // EVERY value for our name, not the first — a duplicate must not shadow.
251
+ for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
252
+ const r = verifyGrant(raw, { secret: grantSecret, shareId });
253
+ if (r.ok) return 'ok';
254
+ if (r.reason === 'exp' && grantVerdict === 'none') {
255
+ lastExpired = r.payload;
256
+ grantVerdict = 'expired';
257
+ }
258
+ }
259
+ if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
260
+ grantVerdict = 'forged';
261
+ }
262
+ }
263
+
264
+ if (isBasic(req.headers['authorization'])) {
221
265
  // A blocked source is refused BEFORE the comparison — a block that
222
266
  // still grades guesses would let the brute force run to a correct hit.
223
267
  if (sourceBlocked(req)) return 'blocked';
@@ -230,18 +274,7 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
230
274
  noteFailure(req);
231
275
  return 'badpass';
232
276
  }
233
-
234
- if (!grants) return 'none';
235
- // EVERY value for our name, not the first — a duplicate must not shadow.
236
- for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
237
- const r = verifyGrant(raw, { secret: grantSecret, shareId });
238
- if (r.ok) return 'ok';
239
- if (r.reason === 'exp') {
240
- lastExpired = r.payload;
241
- return 'expired';
242
- }
243
- }
244
- return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
277
+ return grantVerdict;
245
278
  };
246
279
 
247
280
  /**
@@ -338,7 +371,10 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
338
371
  // its request would be us editing their app's input.
339
372
  const forwardOpts = (req) => {
340
373
  const headers = { ...req.headers };
341
- delete headers.authorization;
374
+ // Only when it IS the gate credential. Anything else in Authorization is
375
+ // the previewed app's own (a Bearer to its API, its own Basic realm) and
376
+ // the app breaks without it; ours must still never reach branch code.
377
+ if (sameSecret(headers.authorization, expected)) delete headers.authorization;
342
378
  delete headers['proxy-authorization'];
343
379
  // ONLY OURS. The driver's app owns its own cookies and breaks without them;
344
380
  // our grant is a signed bearer token and must not reach branch code.
@@ -346,7 +382,7 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
346
382
  if (rest) headers.cookie = rest;
347
383
  else delete headers.cookie; // never send a bare empty `cookie:`
348
384
  return {
349
- host: '127.0.0.1',
385
+ host: targetHost,
350
386
  port: targetPort,
351
387
  method: req.method,
352
388
  path: req.url,
@@ -223,9 +223,41 @@ export const DEPLOY_KEEP_NAMES = [...DEPLOY_KEEP];
223
223
  * never leave the box either way; names would, and a name is enough to tell
224
224
  * somebody which credential to go looking for.
225
225
  */
226
+ /**
227
+ * …AND THE DAEMON'S OWN CREDENTIALS, REDACTED AND NEVER ADMITTED (2026-09-24,
228
+ * the audit).
229
+ *
230
+ * The list above was written about the deploy lane, and it left out every
231
+ * credential the DAEMON itself runs on: the machine credential
232
+ * (`FLOWVIANT_MACHINE_TOKEN`, or its permanent `FLOWVIANT_FLEET` fallback on a
233
+ * headless box), and whatever signs the operator's CLIs and `gh` in. Those are
234
+ * the values a CLI turn can most easily reach — a turn inherits this process's
235
+ * environment, and `env` is one Bash call — and a turn trace, an answer and an
236
+ * artifact are all posted to the server. Redacting a value we never pass costs
237
+ * nothing (the `CF_*` argument above), so they join the REDACTION list only;
238
+ * `DEPLOY_KEEP` is untouched and `childEnv` still hands none of them to anybody.
239
+ *
240
+ * The machine credential is ALSO caught by shape in `env.mjs` (`fva_…`),
241
+ * because the copy that matters most is not in any environment variable at all:
242
+ * it is every project's `fleetToken` in `~/.flowviant/credentials.json`, plus
243
+ * the per-turn work/capture tokens the server mints. The names here are belt.
244
+ */
245
+ const REDACT_ONLY = [
246
+ 'CF_API_TOKEN',
247
+ 'CF_ACCOUNT_ID',
248
+ 'FLOWVIANT_MACHINE_TOKEN',
249
+ 'FLOWVIANT_FLEET',
250
+ 'ANTHROPIC_API_KEY',
251
+ 'ANTHROPIC_AUTH_TOKEN',
252
+ 'CLAUDE_CODE_OAUTH_TOKEN',
253
+ 'OPENAI_API_KEY',
254
+ 'GH_TOKEN',
255
+ 'GITHUB_TOKEN',
256
+ ];
257
+
226
258
  export function processEnvSecrets() {
227
259
  const out = [];
228
- for (const name of [...DEPLOY_KEEP, 'CF_API_TOKEN', 'CF_ACCOUNT_ID']) {
260
+ for (const name of [...DEPLOY_KEEP, ...REDACT_ONLY]) {
229
261
  const value = process.env[name];
230
262
  if (typeof value === 'string' && value) out.push({ name, value });
231
263
  }
@@ -61,10 +61,66 @@ export const READ_GUARD_SETTINGS = JSON.stringify({
61
61
  * `--settings` pair must stand before it or be swallowed as a tool name. */
62
62
  const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
63
 
64
+ /** A path a permission rule can name: absolute POSIX, and free of the glob and
65
+ * rule-syntax characters that would change what the rule means. Anything else
66
+ * gets no rule — `--add-dir` still admits the directory (measured). */
67
+ const ruleSafeAbsolute = (p) =>
68
+ typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
69
+
70
+ /**
71
+ * THE READ FENCE — the worktree, plus the knowledge library by its absolute
72
+ * path, and nothing else on the box (2026-09-24, the audit).
73
+ *
74
+ * Every curated posture used to allow bare `Read`, `Grep` and `Glob`, and a
75
+ * bare allow is not "read the repo": measured on Claude Code 2.1.281, bare
76
+ * `Read` read a file outside the working directory and bare `Grep` searched
77
+ * one. So a design, consult or capture turn — each steered by words anyone who
78
+ * can file a card can write — could read `~/.flowviant/credentials.json`, which
79
+ * holds the MACHINE CREDENTIAL of every project connected on this box, and put
80
+ * it in an artifact, a plan note or a transcript that leaves the machine. That
81
+ * is a cross-project leak from a posture this file calls read-only.
82
+ *
83
+ * The fence is research's, measured the same day in the design shape:
84
+ * `Read(./**)` and `Glob(./**)` read the worktree; the knowledge library reads
85
+ * through `--add-dir` plus its own `//` rule; a file outside both was refused
86
+ * ("Claude requested permissions to read from …, but you haven't granted it
87
+ * yet"). GREP CARRIES NO ALLOW RULE AT ALL, on purpose: with none, Grep in the
88
+ * working directory (and in the added knowledge directory) ran, and Grep of a
89
+ * path outside them was refused. The Bash readers these postures keep are
90
+ * path-validated by the CLI on its own (see CONSULT_BASH).
91
+ */
92
+ function fencedReads(knowledgeDir) {
93
+ const kd = ruleSafeAbsolute(knowledgeDir);
94
+ return [
95
+ 'Read(./**)',
96
+ ...(kd ? [`Read(/${kd}/**)`] : []),
97
+ 'Glob(./**)',
98
+ ...(kd ? [`Glob(/${kd}/**)`] : []),
99
+ ];
100
+ }
101
+
102
+ /** The shell readers CONSULT and PLAN keep. Each one is PATH-VALIDATED by the
103
+ * CLI itself — measured on 2.1.281 (2026-09-24): with `Bash(cat:*)`,
104
+ * `Bash(head:*)` and `Bash(ls:*)` allowed, `cat`/`ls`/`head` on a file outside
105
+ * the working directories were each refused ("Claude Code may only
106
+ * concatenate files from the allowed working directories") — so it is the
107
+ * Read/Grep/Glob TOOLS, not these, that needed the fence above. */
108
+ const CONSULT_BASH = [
109
+ 'Bash(ls:*)',
110
+ 'Bash(wc:*)',
111
+ 'Bash(head:*)',
112
+ 'Bash(cat:*)',
113
+ 'Bash(git log:*)',
114
+ 'Bash(git show:*)',
115
+ 'Bash(git diff:*)',
116
+ 'Bash(git rev-parse:*)',
117
+ ];
118
+
64
119
  /**
65
120
  * PLAN — read the repo, write the plan, never the code.
66
121
  *
67
- * The read half is CONSULT_PERM verbatim: this turn's prompt is steered by
122
+ * The read half is `consultPermFor`'s verbatim (the read fence and the
123
+ * path-validated shell readers): this turn's prompt is steered by
68
124
  * anything a project editor can type, so the same threat applies and the same
69
125
  * allowlist answers it. What is added is the control plane and NOTHING else —
70
126
  * `mcp__flowviant` is the plan principal's token, whose entire tool set is the
@@ -77,22 +133,15 @@ const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
77
133
  * through an API, and there is no file on this machine it has any business
78
134
  * touching.
79
135
  */
80
- const PLAN_PERM = [
81
- ...READ_GUARD,
82
- '--allowedTools',
83
- 'mcp__flowviant',
84
- 'Read',
85
- 'Grep',
86
- 'Glob',
87
- 'Bash(ls:*)',
88
- 'Bash(wc:*)',
89
- 'Bash(head:*)',
90
- 'Bash(cat:*)',
91
- 'Bash(git log:*)',
92
- 'Bash(git show:*)',
93
- 'Bash(git diff:*)',
94
- 'Bash(git rev-parse:*)',
95
- ];
136
+ export function planPermFor(knowledgeDir) {
137
+ return [
138
+ ...READ_GUARD,
139
+ '--allowedTools',
140
+ 'mcp__flowviant',
141
+ ...fencedReads(knowledgeDir),
142
+ ...CONSULT_BASH,
143
+ ];
144
+ }
96
145
 
97
146
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
98
147
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
@@ -154,21 +203,9 @@ const WIKI_PERM = [
154
203
  // someone else's machine. The permission list is the enforcement; the prompt's
155
204
  // "do not change anything" is only an instruction, and instructions are exactly
156
205
  // what an injected question competes with.
157
- const CONSULT_PERM = [
158
- ...READ_GUARD,
159
- '--allowedTools',
160
- 'Read',
161
- 'Grep',
162
- 'Glob',
163
- 'Bash(ls:*)',
164
- 'Bash(wc:*)',
165
- 'Bash(head:*)',
166
- 'Bash(cat:*)',
167
- 'Bash(git log:*)',
168
- 'Bash(git show:*)',
169
- 'Bash(git diff:*)',
170
- 'Bash(git rev-parse:*)',
171
- ];
206
+ export function consultPermFor(knowledgeDir) {
207
+ return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
208
+ }
172
209
 
173
210
  /**
174
211
  * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
@@ -195,8 +232,10 @@ const CONSULT_PERM = [
195
232
  *
196
233
  * THEY ARE NOT THE SAME READER, and the difference is the network:
197
234
  *
198
- * · DESIGN draws THIS product, so it reads the repo broadly — Read, Grep,
199
- * Glob and `ls` — and has no web at all (a mockup that went looking on the
235
+ * · DESIGN draws THIS product, so it reads the repo — the worktree and the
236
+ * knowledge library through the read fence (`fencedReads`, 2026-09-24:
237
+ * it was bare Read/Grep/Glob, which reach the whole box), Grep with no
238
+ * allow rule, `ls`, and `.env*` denied by name — and has no web at all (a mockup that went looking on the
200
239
  * web would be a mockup of somebody else's product). No git readers and no
201
240
  * cat/head/wc: Read covers every one of them, and each git reader was an
202
241
  * `--output` away from a write (see READ_GUARD, which it still carries for
@@ -233,9 +272,10 @@ const CONSULT_PERM = [
233
272
  * STATED, not closed: research may still read any file in the worktree and
234
273
  * name it in a WebFetch URL — the repository is what a research card is about,
235
274
  * and a turn that could not read it could not answer. Design may read any
236
- * file the Read tool reaches and put it in an artifact the server stores;
237
- * text artifacts are scrubbed of the machine's known secret values on the way
238
- * out (artifacts.mjs), and binary ones are withheld when they carry one.
275
+ * file in its worktree and put it in an artifact the server stores; text
276
+ * artifacts are scrubbed of the machine's known secret values — and of any
277
+ * Flowviant credential, by shape — on the way out (artifacts.mjs, env.mjs), and
278
+ * binary ones are withheld when they carry one.
239
279
  * Design still runs a plain `git log` on the CLI's own read-only classifier
240
280
  * though no git reader is allowed (measured) — the guard is what refuses its
241
281
  * `--output`. And the Agent tool is offered with no allow rule on this CLI:
@@ -274,21 +314,22 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
274
314
  * plane whose every call the CLI refuses. See work.mjs.
275
315
  */
276
316
  export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
277
- export const DESIGN_PERM = [
278
- ...READ_GUARD,
279
- '--allowedTools',
280
- 'Read',
281
- 'Grep',
282
- 'Glob',
283
- 'Bash(ls:*)',
284
- ARTIFACT_WRITE,
285
- ];
286
-
287
- /** A path a permission rule can name: absolute POSIX, and free of the glob and
288
- * rule-syntax characters that would change what the rule means. Anything else
289
- * gets no rule — `--add-dir` still admits the directory (measured). */
290
- const ruleSafeAbsolute = (p) =>
291
- typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
317
+ export function designPermFor(knowledgeDir) {
318
+ return [
319
+ ...READ_GUARD,
320
+ '--allowedTools',
321
+ ...fencedReads(knowledgeDir),
322
+ 'Bash(ls:*)',
323
+ ARTIFACT_WRITE,
324
+ // A mockup is uploaded and runs scripts in a frame that may navigate
325
+ // itself, so the checkout's own secrets are denied by name too — the same
326
+ // pair research carries, measured load-bearing there.
327
+ '--disallowedTools',
328
+ 'Read(./.env*)',
329
+ 'Read(./**/.env*)',
330
+ ];
331
+ }
332
+ export const DESIGN_PERM = designPermFor(null);
292
333
 
293
334
  /**
294
335
  * RESEARCH, built at spawn — the knowledge library is the one directory
@@ -296,14 +337,10 @@ const ruleSafeAbsolute = (p) =>
296
337
  * `//` is the CLI's own spelling for an absolute path in a rule.
297
338
  */
298
339
  export function researchPerm(knowledgeDir) {
299
- const kd = ruleSafeAbsolute(knowledgeDir);
300
340
  return [
301
341
  ...READ_GUARD,
302
342
  '--allowedTools',
303
- 'Read(./**)',
304
- ...(kd ? [`Read(/${kd}/**)`] : []),
305
- 'Glob(./**)',
306
- ...(kd ? [`Glob(/${kd}/**)`] : []),
343
+ ...fencedReads(knowledgeDir),
307
344
  'WebSearch',
308
345
  'WebFetch',
309
346
  ARTIFACT_WRITE,
@@ -322,10 +359,26 @@ export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
322
359
  // matching falsely fired when an agent merely *mentioned* the word in prose
323
360
  // (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
324
361
  // blocked loop. Anchor to a full line instead.
325
- export const sawSentinel = (out, name) => new RegExp(`^\\s*${name}\\s*$`, 'm').test(out);
362
+ //
363
+ // LINE-SPLIT, NEVER A `^...$` REGEX WITH A `\s*` ON BOTH ENDS (audit
364
+ // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` and
365
+ // `/^\s*BLOCKED:(\S+)\s*$/m` are QUADRATIC against a long run of
366
+ // whitespace on one line — the same shape `taskIdsFromMessage` already had
367
+ // to fix, measured ~0.5s at 40KB here — and a CLI's own stdout is exactly
368
+ // the kind of text a stray control sequence or a pasted blob can grow past
369
+ // that. A length cap first, then plain string work, same as there.
370
+ const SENTINEL_MAX_LINE = 2000;
371
+ export const sawSentinel = (out, name) =>
372
+ String(out ?? '')
373
+ .split('\n')
374
+ .some((l) => l.length < SENTINEL_MAX_LINE && l.trim() === name);
326
375
  export const blockedId = (out) => {
327
- const m = out.match(/^\s*BLOCKED:(\S+)\s*$/m);
328
- return m ? m[1] : null;
376
+ for (const l of String(out ?? '').split('\n')) {
377
+ if (l.length > SENTINEL_MAX_LINE) continue;
378
+ const t = l.trim();
379
+ if (t.startsWith('BLOCKED:') && /^BLOCKED:\S+$/.test(t)) return t.slice(8);
380
+ }
381
+ return null;
329
382
  };
330
383
 
331
384
  /**
@@ -539,6 +592,27 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
539
592
  }
540
593
  }
541
594
 
595
+ /**
596
+ * THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
597
+ * turn's MCP token, MINUS the machine credential (2026-09-24, the audit).
598
+ *
599
+ * A turn cannot be handed a curated environment the way `childEnv` builds one
600
+ * for a deploy: the CLI's own sign-in lives in this environment, and stripping
601
+ * it signs the CLI out. But on a headless box started as
602
+ * `FLOWVIANT_MACHINE_TOKEN=… npx flowviant` the MACHINE CREDENTIAL lives here
603
+ * too, and every turn — and every dev server and script a turn starts, which
604
+ * inherit it — could read it with one `env`. Nothing a CLI or its children do
605
+ * needs it: the daemon authenticates to the server itself, and a turn that
606
+ * needs the project's tools gets its own per-turn token through `mcpEnv` or
607
+ * the MCP config file. So those two names, and only those, are removed.
608
+ */
609
+ const MACHINE_CREDENTIAL_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
610
+ export function cliEnv(mcpEnv) {
611
+ const env = { ...process.env, ...(mcpEnv ?? {}) };
612
+ for (const k of MACHINE_CREDENTIAL_ENV) delete env[k];
613
+ return env;
614
+ }
615
+
542
616
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
543
617
  // through, line-prefixed with the worker label so a fleet stays legible.
544
618
  //
@@ -548,7 +622,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
548
622
  // returned string for sentinel detection, and each activity is handed to
549
623
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
550
624
  // off and keep the raw text passthrough + line sentinels.
551
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
625
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, onAnswer, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
552
626
  return new Promise((resolve) => {
553
627
  const rt = runtimeById(runtime);
554
628
  // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
@@ -622,6 +696,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
622
696
  resume,
623
697
  streamJson,
624
698
  profile,
699
+ agentTools,
625
700
  // Adopting a terminal session (work.mjs): Claude turns it into
626
701
  // `--resume <id> --fork-session` (a FORK — the original is untouched);
627
702
  // agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
@@ -652,15 +727,19 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
652
727
  perm: planMode
653
728
  ? PLAN_MODE_PERM
654
729
  : profile === 'design'
655
- ? DESIGN_PERM
730
+ ? designPermFor(knowledgeDir)
656
731
  : profile === 'research'
657
732
  ? researchPerm(knowledgeDir)
658
- : planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
733
+ : planPerm
734
+ ? planPermFor(knowledgeDir)
735
+ : readOnly
736
+ ? consultPermFor(knowledgeDir)
737
+ : wikiPerm ? WIKI_PERM : PERM,
659
738
  // Handed to the adapter rather than appended here, because WHERE these go
660
739
  // is a property of the CLI: Codex reads its prompt as a trailing
661
740
  // positional, so a flag after it is a flag in the wrong place.
662
741
  // Wiki-vault turns are pure file work and pass neither — no MCP at all.
663
- mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
742
+ mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
664
743
  // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
665
744
  // sessions). Runtimes without a by-id resume ignore it and keep their
666
745
  // `resume` behavior unchanged.
@@ -706,8 +785,9 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
706
785
  detached: true,
707
786
  // Only ADDS to the environment (the worker token, for runtimes that read
708
787
  // it from there). Never replaces it: the CLI's own credentials live in
709
- // this environment, and handing it a curated one signs it out.
710
- ...(mcpEnv ? { env: { ...process.env, ...mcpEnv } } : {}),
788
+ // this environment, and handing it a curated one signs it out. The one
789
+ // thing it REMOVES is this daemon's machine credential — see `cliEnv`.
790
+ env: cliEnv(mcpEnv),
711
791
  });
712
792
  onSpawn?.(child);
713
793
  let out = '';
@@ -734,14 +814,28 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
734
814
  // thread.started). Purely additive: callers that pass no onThreadId —
735
815
  // every dispatch path — see zero behavior change.
736
816
  if (ev.threadId) onThreadId?.(ev.threadId);
817
+ // The runtime's own token count (codex's `turn.completed`) — the same
818
+ // `onUsage` Claude's result event feeds, so a caller charges a turn
819
+ // identically whichever CLI ran it.
820
+ if (ev.usage) onUsage?.(ev.usage);
821
+ // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
822
+ // wins at the caller, which is what "the final answer" means.
823
+ if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
737
824
  if (ev.text) appendText(ev.text);
738
825
  if (ev.activity) {
739
826
  emit(`${ev.activity.label}\n`);
740
827
  onActivity?.(ev.activity);
741
828
  }
742
829
  };
830
+ // DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
831
+ // pipe read can end inside a multi-byte character — the em dash models
832
+ // write constantly — and `d.toString()` per chunk turned each half into
833
+ // U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
834
+ // `setEncoding` holds the partial bytes over to the next chunk.
835
+ child.stdout.setEncoding('utf8');
836
+ child.stderr.setEncoding('utf8');
743
837
  child.stdout.on('data', (d) => {
744
- buf += d.toString();
838
+ buf += d;
745
839
  let nl;
746
840
  while ((nl = buf.indexOf('\n')) >= 0) {
747
841
  const line = buf.slice(0, nl);
@@ -750,8 +844,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
750
844
  }
751
845
  });
752
846
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
753
- child.stderr.on('data', (d) => {
754
- const s = d.toString();
847
+ child.stderr.on('data', (s) => {
755
848
  out += s;
756
849
  emit(s);
757
850
  });
@@ -780,11 +873,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
780
873
  return;
781
874
  }
782
875
 
783
- const onChunk = (d) => {
784
- const s = d.toString();
876
+ const onChunk = (s) => {
785
877
  out += s;
786
878
  emit(s);
787
879
  };
880
+ // Stream-decoded for the same reason as the line-parsed path above.
881
+ child.stdout.setEncoding('utf8');
882
+ child.stderr.setEncoding('utf8');
788
883
  child.stdout.on('data', onChunk);
789
884
  child.stderr.on('data', onChunk);
790
885
  child.on('error', (e) => {
@@ -327,7 +327,30 @@ export let FLEET_TOKEN =
327
327
  * A frozen copy read at import would salt a picked project's report with null
328
328
  * and report nothing at all, for the whole life of the process.
329
329
  */
330
- export let PROJECT_ID = CREDENTIAL.entry?.projectId ?? null;
330
+ export let PROJECT_ID =
331
+ CREDENTIAL.entry?.fleetToken && CREDENTIAL.entry.fleetToken === FLEET_TOKEN
332
+ ? (CREDENTIAL.entry.projectId ?? null)
333
+ : null;
334
+
335
+ /**
336
+ * THE STORE'S PROJECT IS THIS DAEMON'S ONLY WHEN IT IS THIS DAEMON'S TOKEN.
337
+ * `FLEET_TOKEN` prefers `--fleet` and the environment over the store, and the
338
+ * id above used to come from the store unconditionally — so a box holding one
339
+ * unbound project A, started with project B's token in the environment, salted
340
+ * B's env report with A's id (every variable then read `differs` beside B's
341
+ * other boxes, the confident wrong answer the salt exists to prevent) and
342
+ * printed "serves · A". It starts null in that case, and the ROSTER's own word
343
+ * settles it here on the first poll — the "names no project until the roster
344
+ * does" the paragraph above always promised and nothing ever did.
345
+ */
346
+ export function learnProjectId(id) {
347
+ if (typeof id === 'string' && id) PROJECT_ID = id;
348
+ }
349
+
350
+ /** Whether the stored resolution describes the token actually in use. */
351
+ export function storedCredentialInUse() {
352
+ return Boolean(CREDENTIAL.entry?.fleetToken) && CREDENTIAL.entry.fleetToken === FLEET_TOKEN;
353
+ }
331
354
 
332
355
  /** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
333
356
  * re-authenticates a connection already made. */
@@ -48,6 +48,7 @@ import { execFileSync } from 'node:child_process';
48
48
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
49
49
  import { homedir } from 'node:os';
50
50
  import { join } from 'node:path';
51
+ import { printable } from './printable.mjs';
51
52
 
52
53
  const CRED_DIR = join(homedir(), '.flowviant');
53
54
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -133,7 +134,28 @@ export function listStoredProjects() {
133
134
 
134
135
  /** What a project is CALLED on a terminal: its name, or an id you can grep. */
135
136
  export function projectLabel(e) {
136
- return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
+ return safeName(e?.name) ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
138
+ }
139
+
140
+ /**
141
+ * A SERVER-SUPPLIED NAME, SAFE TO PRINT ON A TERMINAL — or null.
142
+ *
143
+ * A project's name is whatever an owner typed in the app, and the server's
144
+ * schema caps its length and nothing else. Printed raw it reaches every
145
+ * terminal that connects a machine to the project — `login`, the banner, the
146
+ * picker, `projects`, `machines` — and a name carrying an escape sequence can
147
+ * rewrite the clipboard (OSC 52 in kitty, WezTerm, Windows Terminal) or hide
148
+ * lines of the very listing whose job is to say which project is which. The
149
+ * scrub is `printable()` (C0, DEL, C1 — the server's own `parseBoxName` makes
150
+ * the same cut for hostnames — plus the bidi/format controls that reorder a
151
+ * line, added 2026-09-24 rather than re-derived: see printable.mjs), and what
152
+ * is left is trimmed; nothing left is null, so the caller's id fallback
153
+ * speaks instead of an empty label.
154
+ */
155
+ export function safeName(name) {
156
+ if (typeof name !== 'string') return null;
157
+ const clean = printable(name).trim();
158
+ return clean || null;
137
159
  }
138
160
 
139
161
  /**
@@ -316,7 +338,7 @@ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
316
338
  ...(f.projects[projectId] ?? {}),
317
339
  fleetToken,
318
340
  mcpUrl: mcpUrl ?? null,
319
- ...(name ? { name } : {}),
341
+ ...(safeName(name) ? { name: safeName(name) } : {}),
320
342
  ...(repoRoot ? { repoRoot } : {}),
321
343
  savedAt: new Date().toISOString(),
322
344
  };
@@ -391,8 +413,9 @@ export function bindStoredRepo(projectId, repoRoot) {
391
413
 
392
414
  /** The roster names the project on every poll; remember it so the picker and
393
415
  * `flowviant projects` can say a NAME instead of an id. */
394
- export function setStoredProjectName(projectId, name) {
395
- if (!projectId || typeof name !== 'string' || !name) return;
416
+ export function setStoredProjectName(projectId, raw) {
417
+ const name = safeName(raw);
418
+ if (!projectId || !name) return;
396
419
  const entries = listStoredProjects();
397
420
  const e = entries.find((x) => x.projectId === projectId);
398
421
  if (!e || e.name === name) return;