flowviant 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -91,12 +91,22 @@ function fencedReads(knowledgeDir) {
|
|
|
91
91
|
];
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** The shell readers CONSULT and PLAN keep.
|
|
95
|
-
* CLI itself — measured on 2.1.281 (2026-09-24): with
|
|
96
|
-
* `Bash(head:*)` and `Bash(ls:*)` allowed,
|
|
97
|
-
* the working directories
|
|
98
|
-
*
|
|
99
|
-
*
|
|
94
|
+
/** The shell readers CONSULT and PLAN keep. `cat`, `ls` and `head` are
|
|
95
|
+
* PATH-VALIDATED by the CLI itself — measured on 2.1.281 (2026-09-24): with
|
|
96
|
+
* `Bash(cat:*)`, `Bash(head:*)` and `Bash(ls:*)` allowed, each was refused a
|
|
97
|
+
* file outside the working directories ("Claude Code may only concatenate
|
|
98
|
+
* files from the allowed working directories") — so it is the Read/Grep/Glob
|
|
99
|
+
* TOOLS, not these, that needed the fence above.
|
|
100
|
+
*
|
|
101
|
+
* THE GIT READERS WERE NEVER MEASURED THAT WAY (2026-10-10, the audit), and
|
|
102
|
+
* `git diff` reads any file on the box: `--no-index` compares two arbitrary
|
|
103
|
+
* paths, and git switches to it on its own when a path lies outside the work
|
|
104
|
+
* tree (`git diff ~/.ssh/id_ed25519 /dev/null` prints the key). So the read
|
|
105
|
+
* guard is their path fence: it refuses `--no-index`, and a git argument that
|
|
106
|
+
* is absolute, `~`-rooted or climbs a `..` segment (revision ranges `a..b`
|
|
107
|
+
* still pass). It also refuses every one of these readers the checkout's
|
|
108
|
+
* secret files by name — the shell's half of CHECKOUT_SECRETS below, since a
|
|
109
|
+
* Read deny says nothing about `cat`. See hooks/readGuard.mjs. */
|
|
100
110
|
const CONSULT_BASH = [
|
|
101
111
|
'Bash(ls:*)',
|
|
102
112
|
'Bash(wc:*)',
|
|
@@ -108,6 +118,14 @@ const CONSULT_BASH = [
|
|
|
108
118
|
'Bash(git rev-parse:*)',
|
|
109
119
|
];
|
|
110
120
|
|
|
121
|
+
/** The checkout's own secrets, denied BY NAME on the postures that read the
|
|
122
|
+
* repo and hand what they read to someone (2026-10-10, the audit): consult
|
|
123
|
+
* (intake's brief lands with no press), plan and the wiki (every page is
|
|
124
|
+
* uploaded). Research's measured pair — `Read(./**)` read `.env` without it —
|
|
125
|
+
* plus Wrangler's `.dev.vars`, which no root `.env*` scan sees. The read guard
|
|
126
|
+
* refuses the same files to the shell readers (hooks/readGuard.mjs). */
|
|
127
|
+
const CHECKOUT_SECRETS = ['Read(./.env*)', 'Read(./**/.env*)', 'Read(./**/.dev.vars)'];
|
|
128
|
+
|
|
111
129
|
/**
|
|
112
130
|
* PLAN — read the repo, write the plan, never the code.
|
|
113
131
|
*
|
|
@@ -120,10 +138,11 @@ const CONSULT_BASH = [
|
|
|
120
138
|
* hijacked turn's most destructive reachable act is dropping a slice from the
|
|
121
139
|
* plan it is already in, which a human can see and undo in the thread.
|
|
122
140
|
*
|
|
123
|
-
* Note what is absent versus
|
|
124
|
-
* cartographer needs those because it authors files
|
|
125
|
-
* through an API, and there is no file on
|
|
126
|
-
* touching.
|
|
141
|
+
* Note what is absent versus the wiki's list: Edit, mkdir and rm. The
|
|
142
|
+
* cartographer needs those because it authors files (in the vault, and only
|
|
143
|
+
* there); a planner authors records through an API, and there is no file on
|
|
144
|
+
* this machine it has any business touching. The checkout's secrets are denied
|
|
145
|
+
* by name (CHECKOUT_SECRETS), as on consult.
|
|
127
146
|
*/
|
|
128
147
|
export function planPermFor(knowledgeDir) {
|
|
129
148
|
return [
|
|
@@ -132,6 +151,8 @@ export function planPermFor(knowledgeDir) {
|
|
|
132
151
|
'mcp__flowviant',
|
|
133
152
|
...fencedReads(knowledgeDir),
|
|
134
153
|
...CONSULT_BASH,
|
|
154
|
+
'--disallowedTools',
|
|
155
|
+
...CHECKOUT_SECRETS,
|
|
135
156
|
];
|
|
136
157
|
}
|
|
137
158
|
|
|
@@ -153,24 +174,13 @@ const PERM = SAFE
|
|
|
153
174
|
]
|
|
154
175
|
: ['--dangerously-skip-permissions'];
|
|
155
176
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// it), and the blast radius is bounded — the daemon resets the repo worktree
|
|
164
|
-
// after every wiki turn, and the vault has its own git history.
|
|
165
|
-
// (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
|
|
166
|
-
const WIKI_PERM = [
|
|
167
|
-
...READ_GUARD,
|
|
168
|
-
'--allowedTools',
|
|
169
|
-
'Read',
|
|
170
|
-
'Grep',
|
|
171
|
-
'Glob',
|
|
172
|
-
'Edit',
|
|
173
|
-
'Write',
|
|
177
|
+
/** The cartographer's shell: the consult readers, plus `mkdir` and `rm` for
|
|
178
|
+
* the vault (pruning a stale page needs a real deletion — that is how the
|
|
179
|
+
* sync protocol learns of it) and `git status`. The CLI path-validates
|
|
180
|
+
* `mkdir`/`rm` to the working directories, which is why the vault is handed
|
|
181
|
+
* over by `--add-dir` (runtimeClaude.mjs). No `find` (-exec/-delete) and no
|
|
182
|
+
* `git grep` (-O<pager> runs a shell; the Grep tool covers search). */
|
|
183
|
+
const WIKI_BASH = [
|
|
174
184
|
'Bash(ls:*)',
|
|
175
185
|
'Bash(wc:*)',
|
|
176
186
|
'Bash(head:*)',
|
|
@@ -184,19 +194,86 @@ const WIKI_PERM = [
|
|
|
184
194
|
'Bash(git rev-parse:*)',
|
|
185
195
|
];
|
|
186
196
|
|
|
197
|
+
/**
|
|
198
|
+
* THE CARTOGRAPHER — read the repo, write the vault, and nothing else
|
|
199
|
+
* (2026-10-10, the audit: it joins the fenced postures of 2026-09-24).
|
|
200
|
+
*
|
|
201
|
+
* It ran on bare Read/Grep/Glob/Edit/Write, and its comment said file writes
|
|
202
|
+
* "never" exfiltrate and the worktree reset was the backstop. Neither held.
|
|
203
|
+
* The vault is an UPLINK — every page is synced to the project's wiki — so a
|
|
204
|
+
* bare Read of `~/.flowviant/credentials.json` copied into a page leaves the
|
|
205
|
+
* box; and the reset cleans the wiki worktree only, never a write outside it:
|
|
206
|
+
* `<vault>/.git/config` with a `core.fsmonitor` the daemon's own `git add`
|
|
207
|
+
* then ran, the checkout's `.flowviant/check.json`, a shell rc file. This turn
|
|
208
|
+
* runs UNATTENDED after every merge, on a member-written card title and on
|
|
209
|
+
* repo text, so the list is the enforcement and the prompt is only prose.
|
|
210
|
+
*
|
|
211
|
+
* Reads are the read fence (`fencedReads`: the worktree, the knowledge library
|
|
212
|
+
* when one is passed) plus the vault by its absolute path; Grep carries no
|
|
213
|
+
* allow rule, as in consult. The ONE write is `Edit(//<vault>/**)`, the vault's
|
|
214
|
+
* `.git` is denied both ways, and the checkout's secrets are denied by name.
|
|
215
|
+
*
|
|
216
|
+
* PROBED on Claude Code 2.1.296 before relying on it (2026-10-10), this exact
|
|
217
|
+
* list as the adapter builds it (`--add-dir <vault>`, stated `default` mode),
|
|
218
|
+
* cwd a detached worktree, the vault under `<home>/.flowviant/vaults/<id>`, a
|
|
219
|
+
* `-p` turn told to try each in turn:
|
|
220
|
+
* · ALLOWED: Write of a new `<vault>/pages/x.md` and Edit of `<vault>/index.md`
|
|
221
|
+
* (the Edit rule governs Write too; where ARTIFACT_WRITE's 2.1.281 note
|
|
222
|
+
* below reads anchored spellings as no write, this `//`-anchored Edit rule
|
|
223
|
+
* scoped as written on 2.1.296); `ls`, `rm` and `mkdir` in the vault; Read
|
|
224
|
+
* and Glob in the vault and the worktree.
|
|
225
|
+
* · REFUSED: Write of a canary outside both ("Claude requested permissions to
|
|
226
|
+
* write to …, but you haven't granted it yet"), of the checkout's
|
|
227
|
+
* `.flowviant/check.json`, of a file in the worktree itself, and of
|
|
228
|
+
* `<vault>/.git/config` ("File is in a directory that is denied by your
|
|
229
|
+
* permission settings"); Read of `<home>/.flowviant/credentials.json`, of the
|
|
230
|
+
* checkout's `.git/config`, and of the worktree's `.env` (denied by name).
|
|
231
|
+
* Every refused write left the disk unchanged.
|
|
232
|
+
* · CONTROLS: without the Edit rule the vault page was refused too — so
|
|
233
|
+
* `--add-dir` admits reads, never writes, and the rule is load-bearing;
|
|
234
|
+
* without the `.git` deny, the `.git/config` write was still refused by the
|
|
235
|
+
* CLI's own check ("… which is a sensitive file") — so the deny is a belt
|
|
236
|
+
* on this CLI, kept because that check is the vendor's and unpinned.
|
|
237
|
+
*
|
|
238
|
+
* A vault no permission rule can name (not absolute, or carrying glob or rule
|
|
239
|
+
* syntax) gets NO list: `claudePermFor` answers null and Claude's adapter
|
|
240
|
+
* refuses the turn in words, rather than running it unfenced or unable to
|
|
241
|
+
* write.
|
|
242
|
+
*/
|
|
243
|
+
export function wikiPermFor(knowledgeDir, vaultDir) {
|
|
244
|
+
const vault = ruleSafeAbsolute(vaultDir);
|
|
245
|
+
if (!vault) return null;
|
|
246
|
+
return [
|
|
247
|
+
...READ_GUARD,
|
|
248
|
+
'--allowedTools',
|
|
249
|
+
...fencedReads(knowledgeDir),
|
|
250
|
+
`Read(/${vault}/**)`,
|
|
251
|
+
`Glob(/${vault}/**)`,
|
|
252
|
+
`Edit(/${vault}/**)`,
|
|
253
|
+
...WIKI_BASH,
|
|
254
|
+
'--disallowedTools',
|
|
255
|
+
...CHECKOUT_SECRETS,
|
|
256
|
+
`Read(/${vault}/.git/**)`,
|
|
257
|
+
`Edit(/${vault}/.git/**)`,
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
187
261
|
// A CONSULT reads and answers. Nothing else.
|
|
188
262
|
//
|
|
189
|
-
// It used to run on
|
|
190
|
-
// Write/Edit "can't be path-scoped here; the worktree reset is the
|
|
191
|
-
// That
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
263
|
+
// It used to run on the cartographer's old list, whose comment said the quiet
|
|
264
|
+
// part: Write/Edit "can't be path-scoped here; the worktree reset is the
|
|
265
|
+
// backstop". That was the wrong trade for a consult, whose prompt is steered
|
|
266
|
+
// by a question ANY project editor can write and which had no reset behind it
|
|
267
|
+
// — so a sentence in a chat box could reach Write, rm and mkdir on someone
|
|
268
|
+
// else's machine (and it was the wrong trade for the cartographer too: see
|
|
269
|
+
// `wikiPermFor`). The permission list is the enforcement; the prompt's "do not
|
|
270
|
+
// change anything" is only an instruction, and instructions are exactly what
|
|
271
|
+
// an injected question competes with.
|
|
272
|
+
//
|
|
273
|
+
// The checkout's secrets are denied by name (2026-10-10): a consult's answer
|
|
274
|
+
// leaves the box, and intake's drafted brief lands on the board with no press.
|
|
198
275
|
export function consultPermFor(knowledgeDir) {
|
|
199
|
-
return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
|
|
276
|
+
return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH, '--disallowedTools', ...CHECKOUT_SECRETS];
|
|
200
277
|
}
|
|
201
278
|
|
|
202
279
|
/**
|
|
@@ -306,7 +383,7 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
|
|
|
306
383
|
* `readOnlyHint: true`, and none of the session tools (stream_session_turn,
|
|
307
384
|
* log_work, file_card …) are read-only or annotated so. So a plan turn runs
|
|
308
385
|
* PLAIN — no MCP, the plain tab's contract — rather than mounting a control
|
|
309
|
-
* plane whose every call the CLI refuses. See
|
|
386
|
+
* plane whose every call the CLI refuses. See workTurnShape.mjs.
|
|
310
387
|
*/
|
|
311
388
|
export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
|
|
312
389
|
export function designPermFor(knowledgeDir) {
|
|
@@ -355,7 +432,7 @@ export const RESEARCH_PERM = researchPerm(null);
|
|
|
355
432
|
*/
|
|
356
433
|
const CLAUDE_PERM = {
|
|
357
434
|
build: () => PERM,
|
|
358
|
-
wiki: () =>
|
|
435
|
+
wiki: (kd, vaultDir) => wikiPermFor(kd, vaultDir),
|
|
359
436
|
consult: (kd) => consultPermFor(kd),
|
|
360
437
|
plan: (kd) => planPermFor(kd),
|
|
361
438
|
'plan-mode': () => PLAN_MODE_PERM,
|
|
@@ -370,8 +447,8 @@ const CLAUDE_PERM = {
|
|
|
370
447
|
image: (kd) => consultPermFor(kd),
|
|
371
448
|
};
|
|
372
449
|
export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
|
|
373
|
-
export function claudePermFor(profileName, knowledgeDir) {
|
|
450
|
+
export function claudePermFor(profileName, knowledgeDir, vaultDir) {
|
|
374
451
|
const build = Object.hasOwn(CLAUDE_PERM, profileName) ? CLAUDE_PERM[profileName] : null;
|
|
375
452
|
if (!build) throw new Error(`no Claude permission list for profile '${profileName}'`);
|
|
376
|
-
return build(knowledgeDir);
|
|
453
|
+
return build(knowledgeDir, vaultDir);
|
|
377
454
|
}
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { parseProcStat } from './procStat.mjs';
|
|
28
|
+
import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
|
|
27
29
|
|
|
28
30
|
// ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
|
|
29
31
|
// newest per directory, few. (The window itself is the coordinator's.)
|
|
@@ -87,7 +89,7 @@ function transcriptTitle(file, mtimeMs) {
|
|
|
87
89
|
* the tab keeps whatever name it has.
|
|
88
90
|
*/
|
|
89
91
|
export function titleForSession(cwd, sessionId) {
|
|
90
|
-
if (!cwd || typeof sessionId !== 'string' ||
|
|
92
|
+
if (!cwd || typeof sessionId !== 'string' || !CLAUDE_SESSION_RE.test(sessionId)) {
|
|
91
93
|
return null;
|
|
92
94
|
}
|
|
93
95
|
const projects = join(homedir(), '.claude', 'projects');
|
|
@@ -137,8 +139,8 @@ export function claudeProjectDir(path) {
|
|
|
137
139
|
* else, the file stays. `/proc/<pid>` existing only proves A process; the
|
|
138
140
|
* starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
|
|
139
141
|
* field (parenthesised, may itself contain spaces and parens) makes naive
|
|
140
|
-
* whitespace-splitting wrong, so
|
|
141
|
-
*
|
|
142
|
+
* whitespace-splitting wrong, so the line is read by procStat.mjs, the one
|
|
143
|
+
* parser that knows where `comm` ends.
|
|
142
144
|
*/
|
|
143
145
|
function pidAlive(pid, procStart) {
|
|
144
146
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -174,10 +176,8 @@ function pidAlive(pid, procStart) {
|
|
|
174
176
|
return false; // /proc is real and has no entry — the process is gone
|
|
175
177
|
}
|
|
176
178
|
if (procStart == null) return true; // nothing recorded to compare against
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const fields = stat.slice(close + 1).trim().split(/\s+/);
|
|
180
|
-
return fields[19] === String(procStart);
|
|
179
|
+
const fields = parseProcStat(stat);
|
|
180
|
+
return fields !== null && String(fields.starttime) === String(procStart);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -12,16 +12,12 @@
|
|
|
12
12
|
import { humanizeClaudeTool, THINK_MARKER } from './runtimeEvents.mjs';
|
|
13
13
|
import { turnModelOf } from './turnModel.mjs';
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
// tools not worth surfacing
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// the same job for two vendors and keeping them apart is how the two activity
|
|
22
|
-
// vocabularies drift. Re-exported under its original name: a dozen call sites
|
|
23
|
-
// know it, and none of them care where it lives.
|
|
24
|
-
export const humanizeToolUse = humanizeClaudeTool;
|
|
15
|
+
// One Claude tool_use becomes a compact activity {kind, label}, or null for
|
|
16
|
+
// tools not worth surfacing, through `humanizeClaudeTool` — in runtimeEvents.mjs,
|
|
17
|
+
// beside Codex's equivalent, because they are the same job for two vendors and
|
|
18
|
+
// keeping them apart is how the two activity vocabularies drift. (Its old
|
|
19
|
+
// alias here, `humanizeToolUse`, claimed a dozen call sites; the stream loop
|
|
20
|
+
// below was the only one, and it calls the real name.)
|
|
25
21
|
|
|
26
22
|
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
27
23
|
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
@@ -141,7 +137,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
141
137
|
// the page to read.
|
|
142
138
|
push({ kind: 'say', label: oneLine(b.text), full: b.text });
|
|
143
139
|
} else if (b.type === 'tool_use') {
|
|
144
|
-
push(
|
|
140
|
+
push(humanizeClaudeTool(b.name, b.input || {}, cwd));
|
|
145
141
|
// The STRUCTURED form of the same event, for the transcript's tool
|
|
146
142
|
// cards — raw name + input, so the collector can keep what the
|
|
147
143
|
// one-line humanizer drops (an Edit's counts, the plan's items).
|
package/bin/lib/cliLimit.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* which parks on it, and the pre-review, which only skips) and neither owns
|
|
6
6
|
* it: it is a fact about what the CLIs print.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { scrubCut } from './uplinkScrub.mjs';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* DID THE ACCOUNT HIT A LIMIT?
|
|
@@ -28,7 +28,7 @@ const LIMIT_PHRASES = [
|
|
|
28
28
|
export const limitLine = (text) => {
|
|
29
29
|
for (const line of String(text ?? '').split('\n')) {
|
|
30
30
|
const t = line.trim();
|
|
31
|
-
if (t && LIMIT_PHRASES.some((re) => re.test(t))) return
|
|
31
|
+
if (t && LIMIT_PHRASES.some((re) => re.test(t))) return scrubCut(t, 300);
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THIS ENVIRONMENT'S CODEX KEEPS ITS FILES (audit 2026-10-10, F126) —
|
|
3
|
+
* `$CODEX_HOME`, else `~/.codex`, with `~` resolved the way the CLI resolves
|
|
4
|
+
* it: the environment's own HOME first, the process's home only without one.
|
|
5
|
+
*
|
|
6
|
+
* A LEAF: node imports only. The login reader (codexPersonal.mjs), the
|
|
7
|
+
* rollout reader behind plan limits and "which model ran" (runtimeLimits.mjs),
|
|
8
|
+
* the offerings readout (runtimeOfferings.mjs) and an agent's isolated home
|
|
9
|
+
* (projectToolRuntimes.mjs) each spelled this rule, one of them differently;
|
|
10
|
+
* every one of them is a relay that must look where the CLI wrote.
|
|
11
|
+
*/
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export function codexHome(env = process.env) {
|
|
16
|
+
return env.CODEX_HOME || join(env.HOME || homedir(), '.codex');
|
|
17
|
+
}
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
* config read "Not logged in").
|
|
56
56
|
*/
|
|
57
57
|
import { readFileSync, statSync } from 'node:fs';
|
|
58
|
-
import { homedir } from 'node:os';
|
|
59
58
|
import { join } from 'node:path';
|
|
60
59
|
import { toml } from './projectToolRuntimes.mjs';
|
|
60
|
+
import { codexHome } from './codexHome.mjs';
|
|
61
61
|
|
|
62
62
|
/** Where the login lives and which endpoint it signs into — top-level keys,
|
|
63
63
|
* each confirmed in codex-cli 0.156.1 (`preferred_auth_method` is not one:
|
|
@@ -365,8 +365,7 @@ export function parseCodexToml(text) {
|
|
|
365
365
|
* not a document this reader can read whole.
|
|
366
366
|
*/
|
|
367
367
|
export function readPersonalCodexConfig(env = process.env) {
|
|
368
|
-
const
|
|
369
|
-
const path = join(home, 'config.toml');
|
|
368
|
+
const path = join(codexHome(env), 'config.toml');
|
|
370
369
|
try {
|
|
371
370
|
if (statSync(path).size > CONFIG_MAX_BYTES) return null;
|
|
372
371
|
return parseCodexToml(readFileSync(path, 'utf8'));
|
|
@@ -461,6 +460,48 @@ export function codexPersonalCarry(config, { model = null, effort = null, env: t
|
|
|
461
460
|
return { args, env };
|
|
462
461
|
}
|
|
463
462
|
|
|
463
|
+
/**
|
|
464
|
+
* THE PERSON'S CODEX PROVIDER SECRETS, FOR THE UPLINK SCRUBBER ONLY
|
|
465
|
+
* (2026-10-10, the audit's F073) — `[{ name, value }]`, never a report.
|
|
466
|
+
*
|
|
467
|
+
* The header above says why the turn's own model can read these (`env` lists
|
|
468
|
+
* the carried literals; the read-only sandbox reads config.toml), and nothing
|
|
469
|
+
* taught the scrubber any of them, so a turn that printed one relayed it to
|
|
470
|
+
* every member of the project. The SELECTED provider's
|
|
471
|
+
* `experimental_bearer_token` and literal `http_headers` values, named as the
|
|
472
|
+
* carry names them (`CODEX_BEARER_ENV`, `CODEX_HEADER_ENV_PREFIX` + n), plus
|
|
473
|
+
* the present values of the variables its `env_key` and `env_http_headers`
|
|
474
|
+
* name, under those names. Whatever the posture: a build turn's Codex reads
|
|
475
|
+
* the same file. An unreadable or provider-less config gives `[]`, which the
|
|
476
|
+
* scrubber treats as "nothing new", never as "forget".
|
|
477
|
+
*/
|
|
478
|
+
export function codexSecretsOf(config, env = {}) {
|
|
479
|
+
const out = [];
|
|
480
|
+
if (!isTable(config)) return out;
|
|
481
|
+
const id = config.model_provider;
|
|
482
|
+
const table = isString(id) && id && isTable(config.model_providers) ? config.model_providers[id] : undefined;
|
|
483
|
+
if (!isTable(table)) return out;
|
|
484
|
+
if (nonEmpty(table.experimental_bearer_token)) {
|
|
485
|
+
out.push({ name: CODEX_BEARER_ENV, value: table.experimental_bearer_token });
|
|
486
|
+
}
|
|
487
|
+
if (isTable(table.http_headers)) {
|
|
488
|
+
let n = 0;
|
|
489
|
+
for (const literal of Object.values(table.http_headers)) {
|
|
490
|
+
if (nonEmpty(literal)) out.push({ name: `${CODEX_HEADER_ENV_PREFIX}${n++}`, value: literal });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const named = [table.env_key, ...(isTable(table.env_http_headers) ? Object.values(table.env_http_headers) : [])];
|
|
494
|
+
for (const name of named) {
|
|
495
|
+
if (nonEmpty(name) && nonEmpty(env[name])) out.push({ name, value: env[name] });
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** `codexSecretsOf` over the config this process's Codex would read. */
|
|
501
|
+
export function readPersonalCodexSecrets(env = process.env) {
|
|
502
|
+
return codexSecretsOf(readPersonalCodexConfig(env), env);
|
|
503
|
+
}
|
|
504
|
+
|
|
464
505
|
/** The carry for one turn: the config its Codex would read, reduced. */
|
|
465
506
|
export function codexPersonalTurn({ env = process.env, model = null, effort = null } = {}) {
|
|
466
507
|
return codexPersonalCarry(readPersonalCodexConfig(env), { model, effort, env });
|
package/bin/lib/codexRelay.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* classifier. The sentence names what was measured and the one remedy the
|
|
21
21
|
* machine's own CLI offers; Codex's own words stay beside it.
|
|
22
22
|
*/
|
|
23
|
-
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
23
|
+
import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
|
|
24
24
|
import { CODEX_RUNTIME } from './runtimeCodex.mjs';
|
|
25
25
|
|
|
26
26
|
const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
|
|
@@ -55,9 +55,25 @@ export function codexApprovalSentence(refused) {
|
|
|
55
55
|
return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* WHAT A TERMINAL OR NEW TASK REPLY MAY CARRY BACK (2026-10-10, the audit's
|
|
60
|
+
* final cleanup; F042's daemon half). The server's `SESSION_ANSWER_MAX`
|
|
61
|
+
* (shared `fieldCaps.ts`): the settle route (`/fleet/work-turn-done`) cuts an
|
|
62
|
+
* answer there and the session writer clamps every message to it. The daemon
|
|
63
|
+
* cuts there too before it sends — `withRelay` below and the plain reply in
|
|
64
|
+
* `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
|
|
65
|
+
* `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
|
|
66
|
+
* holds this copy equal to the shared one.
|
|
67
|
+
*/
|
|
68
|
+
export const SESSION_ANSWER_MAX = 16_000;
|
|
69
|
+
|
|
58
70
|
/** A reply with the sentence first and Codex's own words kept under it,
|
|
59
|
-
* scrubbed like every reply and cut to the lane's cap
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
* scrubbed like every reply and cut to the lane's cap — the words scrubbed
|
|
72
|
+
* whole before they are trimmed, and the reply cut only after (`scrubCut`). */
|
|
73
|
+
export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
|
|
74
|
+
const reply = (s) => {
|
|
75
|
+
const words = envScrub(s).trim();
|
|
76
|
+
return words ? `${sentence}\n\n${words}` : sentence;
|
|
77
|
+
};
|
|
78
|
+
return scrubCut(raw, cap, reply);
|
|
63
79
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Codex research reads a copied view, never the host checkout or login store.
|
|
2
|
+
* Linux/WSL only: the CLI process retains its login outside its tool sandbox;
|
|
3
|
+
* tools get root-denied reads, a clean environment and no shell snapshots.
|
|
4
|
+
* Web search is a CLI tool, not direct shell network access.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { git } from './git.mjs';
|
|
8
|
+
import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
9
|
+
import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 20_000;
|
|
14
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
15
|
+
const omitted = (name) => /^(\.git|\.env.*|\.claude|\.codex|\.mcp\.json|node_modules|credentials(?:\..*)?|auth\.json)$/i.test(name) || /\.(pem|key|p12|pfx)$/i.test(name);
|
|
16
|
+
|
|
17
|
+
/** Fresh copies also break hardlinks. Symlinks, devices and sockets never ride.
|
|
18
|
+
* Refuse over the bound instead of presenting a silently incomplete repo.
|
|
19
|
+
*/
|
|
20
|
+
export function copyResearchView(source, destination, budget = { files: 0, bytes: 0 }) {
|
|
21
|
+
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
22
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
23
|
+
if (omitted(entry.name)) continue;
|
|
24
|
+
const from = join(source, entry.name);
|
|
25
|
+
const to = join(destination, entry.name);
|
|
26
|
+
const stat = lstatSync(from);
|
|
27
|
+
if (stat.isSymbolicLink()) continue;
|
|
28
|
+
if (stat.isDirectory()) {
|
|
29
|
+
// Only the card's files and kept library are inputs from .flowviant.
|
|
30
|
+
if (entry.name === '.flowviant') {
|
|
31
|
+
for (const input of ['uploads', 'knowledge', 'artifacts']) {
|
|
32
|
+
const dir = join(from, input);
|
|
33
|
+
if (realDirState(dir) === 'dir')
|
|
34
|
+
copyResearchView(dir, join(to, input), budget);
|
|
35
|
+
}
|
|
36
|
+
} else copyResearchView(from, to, budget);
|
|
37
|
+
} else if (stat.isFile()) {
|
|
38
|
+
budget.files += 1;
|
|
39
|
+
budget.bytes += stat.size;
|
|
40
|
+
if (budget.files > MAX_FILES || budget.bytes > MAX_BYTES)
|
|
41
|
+
throw new Error('The isolated research copy exceeds 20,000 files or 128 MB. Narrow the repository inputs before trying again.');
|
|
42
|
+
// A file replaced by a symlink between enumeration and copy must not
|
|
43
|
+
// bring host data into the readable view. Copy through a no-follow fd.
|
|
44
|
+
const fd = openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
45
|
+
try {
|
|
46
|
+
if (!fstatSync(fd).isFile()) throw new Error('A research input changed while copying.');
|
|
47
|
+
copyFileSync(`/proc/self/fd/${fd}`, to);
|
|
48
|
+
} finally { closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve the actual sandbox helper without granting the person's bin/home.
|
|
54
|
+
* Support native installs and the npm wrapper's platform package. Unknown
|
|
55
|
+
* installations refuse; granting the whole home to find a helper is no fence.
|
|
56
|
+
*/
|
|
57
|
+
export function codexResearchHelper(env = process.env) {
|
|
58
|
+
const command = (env.PATH ?? '').split(':').map((dir) => join(dir, 'codex')).find((file) => existsSync(file));
|
|
59
|
+
if (!command) throw new Error('Codex is not installed on PATH.');
|
|
60
|
+
const executable = realpathSync(command);
|
|
61
|
+
const fd = openSync(executable, 'r');
|
|
62
|
+
const magic = Buffer.alloc(4);
|
|
63
|
+
try { readSync(fd, magic, 0, 4, 0); } finally { closeSync(fd); }
|
|
64
|
+
if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return executable;
|
|
65
|
+
const target = process.arch === 'arm64' ? 'aarch64-unknown-linux-musl' : 'x86_64-unknown-linux-musl';
|
|
66
|
+
const platform = `@openai/codex-linux-${process.arch === 'arm64' ? 'arm64' : 'x64'}`;
|
|
67
|
+
let root;
|
|
68
|
+
try { root = dirname(createRequire(executable).resolve(`${platform}/package.json`)); }
|
|
69
|
+
catch { root = resolve(dirname(executable), '..'); }
|
|
70
|
+
const binary = join(root, 'vendor', target, 'bin', 'codex');
|
|
71
|
+
if (!existsSync(binary)) throw new Error('This Codex installation has no measured Linux research sandbox helper.');
|
|
72
|
+
return realpathSync(binary);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The research posture's own pairs: the copied view's filesystem table, no
|
|
76
|
+
* shell network, live web, a clean shell environment. runtimeCodex.mjs ends it
|
|
77
|
+
* in the shared fenced tail (sub-agents, browser, image generation, the
|
|
78
|
+
* person's config and rules), with the web left on: it is this posture's tool.
|
|
79
|
+
*/
|
|
80
|
+
export function researchFence({ cwd, helper }) {
|
|
81
|
+
if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
|
|
82
|
+
throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
|
|
83
|
+
const artifacts = join(cwd, '.flowviant/artifacts');
|
|
84
|
+
const scratch = join(cwd, '.flowviant/tmp');
|
|
85
|
+
const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
|
|
86
|
+
const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
|
|
87
|
+
return [
|
|
88
|
+
'-c', `permissions.flowviantresearch.filesystem=${table}`,
|
|
89
|
+
'-c', 'permissions.flowviantresearch.network.enabled=false',
|
|
90
|
+
'-c', 'default_permissions="flowviantresearch"',
|
|
91
|
+
'-c', 'approval_policy="never"',
|
|
92
|
+
'-c', 'web_search="live"', '-c', 'tools.web_search=true',
|
|
93
|
+
'-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
|
|
94
|
+
'-c', 'shell_environment_policy.inherit="none"',
|
|
95
|
+
'-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
|
|
96
|
+
'--disable', 'view_image',
|
|
97
|
+
'--skip-git-repo-check',
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Copy only regular Markdown outputs back. Never follow a model-written link,
|
|
102
|
+
* nor one standing in the worktree: a destination directory must be a real one
|
|
103
|
+
* or absent (ownedDirs.mjs, the daemon's one link rule), and a file is written
|
|
104
|
+
* only where nothing or a regular file stands, created exclusively so a link
|
|
105
|
+
* swapped in after the look is never followed. A write-up already holding
|
|
106
|
+
* these exact bytes is left alone: the copied view carried it in, and its
|
|
107
|
+
* mtime is the artifact scan's evidence that the turn did not write it
|
|
108
|
+
* (artifacts.mjs `writtenSince`), so a turn that wrote nothing delivers
|
|
109
|
+
* nothing. Artifact publication stays in the original worktree for the
|
|
110
|
+
* existing uploader.
|
|
111
|
+
*/
|
|
112
|
+
export function publishResearch(source, destination) {
|
|
113
|
+
if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
|
|
114
|
+
if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
|
|
115
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
116
|
+
const from = join(source, entry.name);
|
|
117
|
+
const to = join(destination, entry.name);
|
|
118
|
+
const stat = lstatSync(from);
|
|
119
|
+
if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
|
|
120
|
+
if (stat.isDirectory()) publishResearch(from, to);
|
|
121
|
+
else if (stat.isFile() && /\.md$/i.test(entry.name)) {
|
|
122
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
|
|
123
|
+
let there = null;
|
|
124
|
+
try {
|
|
125
|
+
there = lstatSync(to);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (e?.code !== 'ENOENT') throw e;
|
|
128
|
+
}
|
|
129
|
+
if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
|
|
130
|
+
const bytes = readFileSync(from);
|
|
131
|
+
if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
|
|
132
|
+
if (there) unlinkSync(to);
|
|
133
|
+
writeFileSync(to, bytes, { flag: 'wx' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runIsolatedResearch(opts, run, {
|
|
139
|
+
resolveHelper = codexResearchHelper,
|
|
140
|
+
readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
|
|
141
|
+
} = {}) {
|
|
142
|
+
if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
|
|
143
|
+
const original = realpathSync(opts.cwd);
|
|
144
|
+
const helper = resolveHelper();
|
|
145
|
+
const version = readVersion(helper).match(/codex-cli (\d+)\.(\d+)\.(\d+)/);
|
|
146
|
+
if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
|
|
147
|
+
throw new Error('Isolated research needs Codex CLI 0.159.2 or newer. Restart after updating Codex.');
|
|
148
|
+
// Both publication parents must be real directories in this checkout, or
|
|
149
|
+
// absent — never a link, dangling or not (ownedDirs.mjs).
|
|
150
|
+
for (const dir of [join(original, '.flowviant'), join(original, '.flowviant/artifacts')])
|
|
151
|
+
if (realDirState(dir) === 'other')
|
|
152
|
+
throw new Error('The research artifact destination is not a real directory.');
|
|
153
|
+
// Kept path across resumes, outside the source tree and separate from Codex's
|
|
154
|
+
// login/thread home. The caller's agent lock serializes turns in this place.
|
|
155
|
+
const gitDir = git(['rev-parse', '--absolute-git-dir'], original);
|
|
156
|
+
const isolation = join(gitDir, 'flowviant-research');
|
|
157
|
+
if (existsSync(isolation) && lstatSync(isolation).isSymbolicLink()) throw new Error('The research isolation directory is a symlink.');
|
|
158
|
+
rmSync(isolation, { recursive: true, force: true });
|
|
159
|
+
const snapshot = join(isolation, 'repo');
|
|
160
|
+
try {
|
|
161
|
+
copyResearchView(original, snapshot);
|
|
162
|
+
let knowledge = null;
|
|
163
|
+
if (opts.knowledgeDir) {
|
|
164
|
+
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
165
|
+
copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
|
|
166
|
+
}
|
|
167
|
+
for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
|
|
168
|
+
const rewrite = (text) => typeof text === 'string'
|
|
169
|
+
? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
|
|
170
|
+
const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
|
|
171
|
+
const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
|
|
172
|
+
prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
|
|
173
|
+
researchIsolation: { cwd: snapshot, helper } });
|
|
174
|
+
// Made, or found, real at publication too: every component checked again.
|
|
175
|
+
if (!ensureRealSubdir(original, '.flowviant/artifacts'))
|
|
176
|
+
throw new Error('The research artifact destination is not a real directory.');
|
|
177
|
+
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
|
|
178
|
+
return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
|
|
179
|
+
} finally { rmSync(isolation, { recursive: true, force: true }); }
|
|
180
|
+
}
|