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.
- package/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +174 -33
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +377 -45
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +146 -12
- package/bin/lib/fleet.mjs +125 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +162 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +460 -23
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +354 -32
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +152 -11
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1261 -4476
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
package/bin/lib/claude.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { SAFE } from './config.mjs';
|
|
16
17
|
import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
|
|
17
18
|
|
|
@@ -20,10 +21,106 @@ import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
|
|
|
20
21
|
// the strings live.
|
|
21
22
|
export * from './prompts.mjs';
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
|
|
26
|
+
* hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
|
|
27
|
+
*
|
|
28
|
+
* A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
|
|
29
|
+
* about the arguments, and `git log --output=<path>` writes any file while
|
|
30
|
+
* `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
|
|
31
|
+
* `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
|
|
32
|
+
* no denial, and it re-measured the same way here. So every list below that
|
|
33
|
+
* promises "reads, and nothing else" carries a `PreToolUse` hook through
|
|
34
|
+
* `--settings`, measured on Claude Code 2.1.281: it fires under
|
|
35
|
+
* `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
|
|
36
|
+
*
|
|
37
|
+
* NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
|
|
38
|
+
* operator's own choice for a turn that is meant to write, and a guard there
|
|
39
|
+
* would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
|
|
40
|
+
* posture on the owner's own tab, not a list this file promises anything about.
|
|
41
|
+
*
|
|
42
|
+
* The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
|
|
43
|
+
* `process.execPath`, never a `node` looked up on a PATH the operator may not
|
|
44
|
+
* have, because a hook that fails to START is a non-blocking error and the
|
|
45
|
+
* command runs anyway.
|
|
46
|
+
*/
|
|
47
|
+
export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
|
|
48
|
+
const hookQuote = (s) =>
|
|
49
|
+
process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
50
|
+
export const READ_GUARD_SETTINGS = JSON.stringify({
|
|
51
|
+
hooks: {
|
|
52
|
+
PreToolUse: [
|
|
53
|
+
{
|
|
54
|
+
matcher: 'Bash',
|
|
55
|
+
hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
/** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
|
|
61
|
+
* `--settings` pair must stand before it or be swallowed as a tool name. */
|
|
62
|
+
const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
|
|
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
|
+
|
|
23
119
|
/**
|
|
24
120
|
* PLAN — read the repo, write the plan, never the code.
|
|
25
121
|
*
|
|
26
|
-
* The read half is
|
|
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
|
|
27
124
|
* anything a project editor can type, so the same threat applies and the same
|
|
28
125
|
* allowlist answers it. What is added is the control plane and NOTHING else —
|
|
29
126
|
* `mcp__flowviant` is the plan principal's token, whose entire tool set is the
|
|
@@ -36,21 +133,15 @@ export * from './prompts.mjs';
|
|
|
36
133
|
* through an API, and there is no file on this machine it has any business
|
|
37
134
|
* touching.
|
|
38
135
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'Bash(cat:*)',
|
|
49
|
-
'Bash(git log:*)',
|
|
50
|
-
'Bash(git show:*)',
|
|
51
|
-
'Bash(git diff:*)',
|
|
52
|
-
'Bash(git rev-parse:*)',
|
|
53
|
-
];
|
|
136
|
+
export function planPermFor(knowledgeDir) {
|
|
137
|
+
return [
|
|
138
|
+
...READ_GUARD,
|
|
139
|
+
'--allowedTools',
|
|
140
|
+
'mcp__flowviant',
|
|
141
|
+
...fencedReads(knowledgeDir),
|
|
142
|
+
...CONSULT_BASH,
|
|
143
|
+
];
|
|
144
|
+
}
|
|
54
145
|
|
|
55
146
|
// Unattended (default) skips prompts so the agent never stalls with no terminal;
|
|
56
147
|
// FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
|
|
@@ -81,6 +172,7 @@ const PERM = SAFE
|
|
|
81
172
|
// after every wiki turn, and the vault has its own git history.
|
|
82
173
|
// (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
|
|
83
174
|
const WIKI_PERM = [
|
|
175
|
+
...READ_GUARD,
|
|
84
176
|
'--allowedTools',
|
|
85
177
|
'Read',
|
|
86
178
|
'Grep',
|
|
@@ -111,20 +203,155 @@ const WIKI_PERM = [
|
|
|
111
203
|
// someone else's machine. The permission list is the enforcement; the prompt's
|
|
112
204
|
// "do not change anything" is only an instruction, and instructions are exactly
|
|
113
205
|
// what an injected question competes with.
|
|
114
|
-
|
|
115
|
-
'--allowedTools',
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
206
|
+
export function consultPermFor(knowledgeDir) {
|
|
207
|
+
return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
|
|
212
|
+
* WRITE, and only under `.flowviant/artifacts/`.
|
|
213
|
+
*
|
|
214
|
+
* The card's words are steered by anyone who can file a card, so "change
|
|
215
|
+
* nothing" as prose is only an instruction, and the permission list is the
|
|
216
|
+
* enforcement. What each carries is the ONE write its contract needs:
|
|
217
|
+
*
|
|
218
|
+
* · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
|
|
219
|
+
* relying on it (2026-09-23): with `--allowedTools 'Read'
|
|
220
|
+
* 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
|
|
221
|
+
* `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
|
|
222
|
+
* was DENIED the second. The Edit rule governs every file-writing tool,
|
|
223
|
+
* Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
|
|
224
|
+
* `//abs`-anchored — the same probe was denied BOTH files, so that spelling
|
|
225
|
+
* is not a scoped write at all, it is no write. Relative to the turn's cwd,
|
|
226
|
+
* which is the agent's own worktree, which is where the artifact scan looks.
|
|
227
|
+
* `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
|
|
228
|
+
* let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
|
|
229
|
+
* did, so the single-level spelling is not narrower on this CLI and would
|
|
230
|
+
* only read as if it were. The scan keeps top-level files, so the
|
|
231
|
+
* contracts say "no subfolders" — the prompt is the only thing that can.
|
|
232
|
+
*
|
|
233
|
+
* THEY ARE NOT THE SAME READER, and the difference is the network:
|
|
234
|
+
*
|
|
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
|
|
239
|
+
* web would be a mockup of somebody else's product). No git readers and no
|
|
240
|
+
* cat/head/wc: Read covers every one of them, and each git reader was an
|
|
241
|
+
* `--output` away from a write (see READ_GUARD, which it still carries for
|
|
242
|
+
* the `ls` it keeps).
|
|
243
|
+
* · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
|
|
244
|
+
* read a secret and fetch a URL can send one. So its reading is FENCED to
|
|
245
|
+
* what the card is about: `Read(./**)` (the worktree) plus the project's
|
|
246
|
+
* knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
|
|
247
|
+
* (an unscoped search of the home directory prints matching lines), NO
|
|
248
|
+
* Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
|
|
249
|
+
* list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
|
|
250
|
+
* a sibling directory's file and `~/.flowviant/credentials.json` were each
|
|
251
|
+
* refused ("Claude requested permissions to read from …, but you haven't
|
|
252
|
+
* granted it yet"); `.env` was refused ("File is in a directory that is
|
|
253
|
+
* denied by your permission settings"); the knowledge file read. Two
|
|
254
|
+
* controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
|
|
255
|
+
* — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
|
|
256
|
+
* while `Glob(./**)` refused it and still listed the cwd and the added
|
|
257
|
+
* directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
|
|
258
|
+
* admitted the file, and the rule keeps the read allowed should the
|
|
259
|
+
* adapter ever stop passing that flag.
|
|
260
|
+
* AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
|
|
261
|
+
* not "no Bash": measured the same day, with Bash absent from
|
|
262
|
+
* `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
|
|
263
|
+
* its own read-only classifier (it did refuse `git log … --output=…`,
|
|
264
|
+
* "This command requires approval" — the reviewer's write needed the
|
|
265
|
+
* explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
|
|
266
|
+
* `--disallowedTools` the tool was not there at all.
|
|
267
|
+
*
|
|
268
|
+
* NO MCP, and no shell that could commit: the agent turn has no MCP anyway
|
|
269
|
+
* (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
|
|
270
|
+
* commit the mockup it was told to keep out of git.
|
|
271
|
+
*
|
|
272
|
+
* STATED, not closed: research may still read any file in the worktree and
|
|
273
|
+
* name it in a WebFetch URL — the repository is what a research card is about,
|
|
274
|
+
* and a turn that could not read it could not answer. Design may read any
|
|
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.
|
|
279
|
+
* Design still runs a plain `git log` on the CLI's own read-only classifier
|
|
280
|
+
* though no git reader is allowed (measured) — the guard is what refuses its
|
|
281
|
+
* `--output`. And the Agent tool is offered with no allow rule on this CLI:
|
|
282
|
+
* a research turn with Bash denied launched a subagent to run one, and the
|
|
283
|
+
* subagent had no Bash either (measured) — the rules are inherited. Whether
|
|
284
|
+
* the guard's hook sees a subagent's own calls was not measured here.
|
|
285
|
+
*/
|
|
286
|
+
const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
|
|
287
|
+
/**
|
|
288
|
+
* PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
|
|
289
|
+
* own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
|
|
290
|
+
* decides and answers with a plan, and refuses each write itself.
|
|
291
|
+
*
|
|
292
|
+
* PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
|
|
293
|
+
* git repo, `-p … --output-format stream-json --verbose`:
|
|
294
|
+
*
|
|
295
|
+
* · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
|
|
296
|
+
* reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
|
|
297
|
+
* `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
|
|
298
|
+
* file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
|
|
299
|
+
* tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
|
|
300
|
+
* and ended with the plan as its result text. app.js was untouched.
|
|
301
|
+
* · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
|
|
302
|
+
* `permissionMode: "bypassPermissions"` — no error, no warning — and the
|
|
303
|
+
* edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
|
|
304
|
+
* `perm`, never an addition, and a plan turn can never carry both.
|
|
305
|
+
* · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
|
|
306
|
+
* connected and its tools were listed, but a call was refused — "Cannot
|
|
307
|
+
* call mcp__flowviant__log_work while in plan mode." — and without the
|
|
308
|
+
* `--allowedTools` entry, "Claude requested permissions to use
|
|
309
|
+
* mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
|
|
310
|
+
* admitted the call ONLY when the server annotated the tool
|
|
311
|
+
* `readOnlyHint: true`, and none of the session tools (stream_session_turn,
|
|
312
|
+
* log_work, file_card …) are read-only or annotated so. So a plan turn runs
|
|
313
|
+
* PLAIN — no MCP, the plain tab's contract — rather than mounting a control
|
|
314
|
+
* plane whose every call the CLI refuses. See work.mjs.
|
|
315
|
+
*/
|
|
316
|
+
export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
|
|
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);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* RESEARCH, built at spawn — the knowledge library is the one directory
|
|
336
|
+
* outside the worktree it may read, and only the spawn knows where that is.
|
|
337
|
+
* `//` is the CLI's own spelling for an absolute path in a rule.
|
|
338
|
+
*/
|
|
339
|
+
export function researchPerm(knowledgeDir) {
|
|
340
|
+
return [
|
|
341
|
+
...READ_GUARD,
|
|
342
|
+
'--allowedTools',
|
|
343
|
+
...fencedReads(knowledgeDir),
|
|
344
|
+
'WebSearch',
|
|
345
|
+
'WebFetch',
|
|
346
|
+
ARTIFACT_WRITE,
|
|
347
|
+
'--disallowedTools',
|
|
348
|
+
'Read(./.env*)',
|
|
349
|
+
'Read(./**/.env*)',
|
|
350
|
+
'Bash',
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
/** The list with no library — what a project that keeps none runs under. */
|
|
354
|
+
export const RESEARCH_PERM = researchPerm(null);
|
|
128
355
|
|
|
129
356
|
export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
130
357
|
|
|
@@ -132,10 +359,26 @@ export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
|
132
359
|
// matching falsely fired when an agent merely *mentioned* the word in prose
|
|
133
360
|
// (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
|
|
134
361
|
// blocked loop. Anchor to a full line instead.
|
|
135
|
-
|
|
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);
|
|
136
375
|
export const blockedId = (out) => {
|
|
137
|
-
const
|
|
138
|
-
|
|
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;
|
|
139
382
|
};
|
|
140
383
|
|
|
141
384
|
/**
|
|
@@ -300,10 +543,17 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
300
543
|
// ADOPTABLE — so any headless turn we run for our own purposes would leave
|
|
301
544
|
// a phantom untitled session in the `+` menu. The caller that needs to
|
|
302
545
|
// clean up after itself cannot do so without this id.
|
|
303
|
-
|
|
546
|
+
//
|
|
547
|
+
// `mcpServers` (0.97.0) is the same kind of fact from the same event: the
|
|
548
|
+
// MCP servers and claude.ai connectors the CLI mounted, each with its own
|
|
549
|
+
// status (`needs-auth` above all — a sign-in that has to happen at this
|
|
550
|
+
// box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
|
|
551
|
+
// drops this daemon's own `flowviant` server.
|
|
552
|
+
if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
|
|
304
553
|
onInit?.({
|
|
305
554
|
skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
|
|
306
555
|
sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
|
|
556
|
+
mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
|
|
307
557
|
});
|
|
308
558
|
}
|
|
309
559
|
} else if (ev.type === 'result') {
|
|
@@ -342,6 +592,27 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
342
592
|
}
|
|
343
593
|
}
|
|
344
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
|
+
|
|
345
616
|
// One Claude Code turn. Output is captured (for sentinel detection) and streamed
|
|
346
617
|
// through, line-prefixed with the worker label so a fleet stays legible.
|
|
347
618
|
//
|
|
@@ -351,9 +622,32 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
351
622
|
// returned string for sentinel detection, and each activity is handed to
|
|
352
623
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
353
624
|
// off and keep the raw text passthrough + line sentinels.
|
|
354
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, 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 }) {
|
|
355
626
|
return new Promise((resolve) => {
|
|
356
627
|
const rt = runtimeById(runtime);
|
|
628
|
+
// PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
|
|
629
|
+
// from `profile` and never read `perm`, so a codex or agy turn handed
|
|
630
|
+
// `planMode` would run a BUILD turn wearing the word "plan". The caller
|
|
631
|
+
// refuses first, in words; this is the belt — fail the turn, never
|
|
632
|
+
// substitute.
|
|
633
|
+
if (planMode && rt.id !== 'claude') {
|
|
634
|
+
console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
|
|
635
|
+
resolve('');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
// …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
|
|
639
|
+
// same shape. The agent lane asks `canRun` first and settles `nothing` in
|
|
640
|
+
// words; this is the layer no caller can skip. A codex or agy adapter
|
|
641
|
+
// handed `posture: 'design'` never reads `perm` — it builds from `profile`
|
|
642
|
+
// and runs whatever that falls back to, which for codex is its build
|
|
643
|
+
// branch, `--sandbox danger-full-access`: a research card's words with the
|
|
644
|
+
// whole machine to act on. Asked against the runtime's own declared profiles,
|
|
645
|
+
// so the day an adapter learns to express one this opens by itself.
|
|
646
|
+
if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
|
|
647
|
+
console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
|
|
648
|
+
resolve('');
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
357
651
|
if (!rt.args) {
|
|
358
652
|
// Reached only if a brief names a runtime this daemon declares but cannot
|
|
359
653
|
// drive. Fail as a turn with no sentinel — the loop already treats that as
|
|
@@ -385,7 +679,15 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
385
679
|
// of the two and a planning turn that fell through to 'consult' would lose
|
|
386
680
|
// the control plane it exists to use — it would read the repo, decide what
|
|
387
681
|
// the slices are, and have no way to write any of them down.
|
|
388
|
-
|
|
682
|
+
// A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
|
|
683
|
+
// them, by NAME, because it is the narrowest promise here: write only the
|
|
684
|
+
// artifacts directory. It is set only by the agent lane, from the card's
|
|
685
|
+
// kind; every other caller passes nothing and gets exactly the branch it
|
|
686
|
+
// got before this existed.
|
|
687
|
+
const profile =
|
|
688
|
+
posture === 'design' || posture === 'research'
|
|
689
|
+
? posture
|
|
690
|
+
: planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
|
|
389
691
|
const args = rt.args({
|
|
390
692
|
prompt,
|
|
391
693
|
system,
|
|
@@ -394,6 +696,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
394
696
|
resume,
|
|
395
697
|
streamJson,
|
|
396
698
|
profile,
|
|
699
|
+
agentTools,
|
|
397
700
|
// Adopting a terminal session (work.mjs): Claude turns it into
|
|
398
701
|
// `--resume <id> --fork-session` (a FORK — the original is untouched);
|
|
399
702
|
// agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
|
|
@@ -418,12 +721,25 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
418
721
|
// prompt as a trailing positional, so a flag after it is in the wrong
|
|
419
722
|
// place.
|
|
420
723
|
resultSchemaArgs,
|
|
421
|
-
|
|
724
|
+
// PLAN MODE REPLACES THE POSTURE, never joins it: beside
|
|
725
|
+
// `--dangerously-skip-permissions` the bypass wins silently (measured —
|
|
726
|
+
// see PLAN_MODE_PERM).
|
|
727
|
+
perm: planMode
|
|
728
|
+
? PLAN_MODE_PERM
|
|
729
|
+
: profile === 'design'
|
|
730
|
+
? designPermFor(knowledgeDir)
|
|
731
|
+
: profile === 'research'
|
|
732
|
+
? researchPerm(knowledgeDir)
|
|
733
|
+
: planPerm
|
|
734
|
+
? planPermFor(knowledgeDir)
|
|
735
|
+
: readOnly
|
|
736
|
+
? consultPermFor(knowledgeDir)
|
|
737
|
+
: wikiPerm ? WIKI_PERM : PERM,
|
|
422
738
|
// Handed to the adapter rather than appended here, because WHERE these go
|
|
423
739
|
// is a property of the CLI: Codex reads its prompt as a trailing
|
|
424
740
|
// positional, so a flag after it is a flag in the wrong place.
|
|
425
741
|
// Wiki-vault turns are pure file work and pass neither — no MCP at all.
|
|
426
|
-
mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
|
|
742
|
+
mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
|
|
427
743
|
// Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
|
|
428
744
|
// sessions). Runtimes without a by-id resume ignore it and keep their
|
|
429
745
|
// `resume` behavior unchanged.
|
|
@@ -469,8 +785,9 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
469
785
|
detached: true,
|
|
470
786
|
// Only ADDS to the environment (the worker token, for runtimes that read
|
|
471
787
|
// it from there). Never replaces it: the CLI's own credentials live in
|
|
472
|
-
// this environment, and handing it a curated one signs it out.
|
|
473
|
-
|
|
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),
|
|
474
791
|
});
|
|
475
792
|
onSpawn?.(child);
|
|
476
793
|
let out = '';
|
|
@@ -497,14 +814,28 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
497
814
|
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
498
815
|
// every dispatch path — see zero behavior change.
|
|
499
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);
|
|
500
824
|
if (ev.text) appendText(ev.text);
|
|
501
825
|
if (ev.activity) {
|
|
502
826
|
emit(`${ev.activity.label}\n`);
|
|
503
827
|
onActivity?.(ev.activity);
|
|
504
828
|
}
|
|
505
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');
|
|
506
837
|
child.stdout.on('data', (d) => {
|
|
507
|
-
buf += d
|
|
838
|
+
buf += d;
|
|
508
839
|
let nl;
|
|
509
840
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
510
841
|
const line = buf.slice(0, nl);
|
|
@@ -513,8 +844,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
513
844
|
}
|
|
514
845
|
});
|
|
515
846
|
// stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
|
|
516
|
-
child.stderr.on('data', (
|
|
517
|
-
const s = d.toString();
|
|
847
|
+
child.stderr.on('data', (s) => {
|
|
518
848
|
out += s;
|
|
519
849
|
emit(s);
|
|
520
850
|
});
|
|
@@ -543,11 +873,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
543
873
|
return;
|
|
544
874
|
}
|
|
545
875
|
|
|
546
|
-
const onChunk = (
|
|
547
|
-
const s = d.toString();
|
|
876
|
+
const onChunk = (s) => {
|
|
548
877
|
out += s;
|
|
549
878
|
emit(s);
|
|
550
879
|
};
|
|
880
|
+
// Stream-decoded for the same reason as the line-parsed path above.
|
|
881
|
+
child.stdout.setEncoding('utf8');
|
|
882
|
+
child.stderr.setEncoding('utf8');
|
|
551
883
|
child.stdout.on('data', onChunk);
|
|
552
884
|
child.stderr.on('data', onChunk);
|
|
553
885
|
child.on('error', (e) => {
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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 =
|
|
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. */
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -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,
|
|
395
|
-
|
|
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;
|