flowviant 0.116.0 → 0.124.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 +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -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 +90 -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 +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- 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/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- 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/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -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 +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- 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/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- 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 +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -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 +113 -31
- 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 +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE READER OF `/proc/<pid>/stat` (audit 2026-10-10, F050/F089).
|
|
3
|
+
*
|
|
4
|
+
* Six modules used to parse this line by hand, each counting its own field
|
|
5
|
+
* index from "rest[0] is field 3" in a comment. The parse is the whole trap:
|
|
6
|
+
* field 2 is `comm`, which the kernel wraps in parens and which may contain
|
|
7
|
+
* BOTH spaces and parens — it is the first 15 bytes of the executable's name,
|
|
8
|
+
* and a name is not a token. A naive whitespace split taking $22 lands on
|
|
9
|
+
* `nice` for any such process, and `nice` is a small integer, so the answer
|
|
10
|
+
* is not a parse error but a plausible-looking "started at boot" that sails
|
|
11
|
+
* past any isFinite guard. Measured: a binary named `ev (i l) x` read 28308s
|
|
12
|
+
* too old that way (instance.mjs, which gates a SIGTERM and then a SIGKILL on
|
|
13
|
+
* this read).
|
|
14
|
+
*
|
|
15
|
+
* Every field after `comm` is a number or a single-character state, so no ')'
|
|
16
|
+
* can appear later: the LAST ')' in the line is always the kernel's own
|
|
17
|
+
* closing paren, even when comm itself ends in one. Fields are NAMED here so
|
|
18
|
+
* no caller counts again.
|
|
19
|
+
*
|
|
20
|
+
* Linux only, and a leaf: node builtins, nothing of ours. Null on any read or
|
|
21
|
+
* shape failure — which every caller already read as "cannot tell".
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/** Index of each named field in the list AFTER `comm` (index 0 is field 3). */
|
|
27
|
+
const FIELD = Object.freeze({ state: 0, ppid: 1, pgrp: 2, session: 3, tpgid: 5, starttime: 19 });
|
|
28
|
+
|
|
29
|
+
/** A kernel integer token: digits, with tpgid's `-1` for "no terminal". */
|
|
30
|
+
const int = (token) => (/^-?\d+$/.test(token ?? '') ? Number(token) : null);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The named fields of one `/proc/<pid>/stat` line, or null when the line has
|
|
34
|
+
* no `comm` close or is too short to carry `starttime` (field 22).
|
|
35
|
+
*/
|
|
36
|
+
export function parseProcStat(text) {
|
|
37
|
+
if (typeof text !== 'string') return null;
|
|
38
|
+
const close = text.lastIndexOf(')');
|
|
39
|
+
if (close < 0) return null;
|
|
40
|
+
const rest = text.slice(close + 1).trim().split(/\s+/);
|
|
41
|
+
if (rest.length <= FIELD.starttime) return null;
|
|
42
|
+
const out = { state: rest[FIELD.state] };
|
|
43
|
+
for (const name of ['ppid', 'pgrp', 'session', 'tpgid', 'starttime']) {
|
|
44
|
+
const value = int(rest[FIELD[name]]);
|
|
45
|
+
if (value === null) return null;
|
|
46
|
+
out[name] = value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A pid this reader will put in a path: a positive integer, its digit
|
|
52
|
+
* string (what a /proc listing hands back), or 'self'. */
|
|
53
|
+
function procName(pid) {
|
|
54
|
+
if (pid === 'self') return 'self';
|
|
55
|
+
if (Number.isSafeInteger(pid) && pid > 0) return String(pid);
|
|
56
|
+
if (typeof pid === 'string' && /^\d+$/.test(pid) && Number(pid) > 0) return pid;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `{ state, ppid, pgrp, session, tpgid, starttime }` for `pid` (or 'self'),
|
|
62
|
+
* or null: no such process, no /proc (macOS, hidepid=2, a pid namespace), or
|
|
63
|
+
* a line this parser does not recognise. `starttime` is in clock ticks since
|
|
64
|
+
* boot (USER_HZ); `tpgid` is -1 without a controlling terminal.
|
|
65
|
+
*/
|
|
66
|
+
export function procStat(pid, { read = readFileSync } = {}) {
|
|
67
|
+
const name = procName(pid);
|
|
68
|
+
if (!name) return null;
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = read(`/proc/${name}/stat`, 'utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return parseProcStat(String(text));
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIGNAL A DETACHED CHILD'S WHOLE PROCESS GROUP, FALLING BACK TO THE CHILD
|
|
3
|
+
* (2026-10-10, the audit's F086 / F145) — the one home of a block that was
|
|
4
|
+
* hand-written six times: the project check's timeout, the teardown of a
|
|
5
|
+
* check, the deploy command's timeout, the async network git call's timeout,
|
|
6
|
+
* a preview tunnel's stop and the reap of an orphaned tunnel.
|
|
7
|
+
*
|
|
8
|
+
* Why the GROUP: the daemon spawns these `detached`, so the child's pid is its
|
|
9
|
+
* process group, and the child is usually a shell (`shell: true`) or a tool
|
|
10
|
+
* that starts helpers (git's ssh, cloudflared). Signalling the child alone
|
|
11
|
+
* leaves what it started running — holding the worktree, its writer lock, or
|
|
12
|
+
* a pipe the caller is waiting on (workChildren.mjs tells the incident).
|
|
13
|
+
*
|
|
14
|
+
* Why the FALLBACK: a group that has already gone, a pid that is not a group
|
|
15
|
+
* leader, and a platform with no process groups (Windows throws on a negative
|
|
16
|
+
* pid) all throw on the group signal; the child itself is then signalled.
|
|
17
|
+
*
|
|
18
|
+
* NEVER A GROUP THE DAEMON DID NOT START: a pid that is not an integer above 1
|
|
19
|
+
* signals nothing by number — `kill(-1)` is every process the user owns, and
|
|
20
|
+
* `kill(0)` is the daemon's own group. Import-free, so git.mjs can use it.
|
|
21
|
+
*
|
|
22
|
+
* @param {import('node:child_process').ChildProcess | number} target
|
|
23
|
+
* @param {NodeJS.Signals} [signal]
|
|
24
|
+
* @returns {boolean} whether a signal was delivered
|
|
25
|
+
*/
|
|
26
|
+
export function killGroup(target, signal = 'SIGKILL') {
|
|
27
|
+
const child = target !== null && typeof target === 'object' ? target : null;
|
|
28
|
+
const pid = child ? child.pid : target;
|
|
29
|
+
const leader = Number.isInteger(pid) && pid > 1;
|
|
30
|
+
if (leader) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(-pid, signal);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
/* gone, not a leader, or no groups here — the process itself next */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (child) return child.kill(signal) === true;
|
|
40
|
+
if (leader) {
|
|
41
|
+
process.kill(pid, signal);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* already gone */
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/bin/lib/processes.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
47
47
|
import { readFileSync } from 'node:fs';
|
|
48
48
|
import { platform } from 'node:os';
|
|
49
49
|
import { procPids, scanProcs } from './procScan.mjs';
|
|
50
|
+
import { procStat } from './procStat.mjs';
|
|
50
51
|
|
|
51
52
|
// THE WALK'S BOUND is `procScan.mjs`'s: matched rows collected before the walk
|
|
52
53
|
// stops early — a bound on the WORK done for one pathological group, never on
|
|
@@ -69,21 +70,13 @@ export function processesSupported() {
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* `pid (comm) state ppid pgrp …` — comm is arbitrary text and CAN contain a
|
|
72
|
-
* `)`,
|
|
73
|
-
*
|
|
74
|
-
* anything that sets its own title can produce.
|
|
73
|
+
* `)`, which is why the line is read by procStat.mjs, the one parser that
|
|
74
|
+
* knows where comm ends. A process named `foo) bar` is not hypothetical; it is
|
|
75
|
+
* what anything that sets its own title can produce. Null when the pid is gone
|
|
76
|
+
* between readdir and read — ordinary.
|
|
75
77
|
*/
|
|
76
78
|
function pgrpOf(pid) {
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
80
|
-
} catch {
|
|
81
|
-
return null; // gone between readdir and read — ordinary
|
|
82
|
-
}
|
|
83
|
-
const close = stat.lastIndexOf(')');
|
|
84
|
-
if (close < 0) return null;
|
|
85
|
-
const rest = stat.slice(close + 1).trim().split(/\s+/);
|
|
86
|
-
const pgrp = Number(rest[2]);
|
|
79
|
+
const pgrp = procStat(pid)?.pgrp;
|
|
87
80
|
return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
|
|
88
81
|
}
|
|
89
82
|
|
|
@@ -172,18 +165,11 @@ function scanDarwin(pgids) {
|
|
|
172
165
|
}
|
|
173
166
|
|
|
174
167
|
/**
|
|
175
|
-
* Live processes in any of `pgids
|
|
168
|
+
* Live processes in any of `pgids`, and the TOTAL the cap was applied to.
|
|
176
169
|
*
|
|
177
|
-
* Returns null where the platform cannot answer — never
|
|
178
|
-
* "looked, found none" and is a different fact a surface renders
|
|
179
|
-
|
|
180
|
-
export function processesInGroups(pgids, { scrub } = {}) {
|
|
181
|
-
const m = measureProcesses(pgids, { scrub });
|
|
182
|
-
return m === null ? null : m.rows;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* …and the TOTAL the cap was applied to.
|
|
170
|
+
* Returns null where the platform cannot answer — never an empty measurement,
|
|
171
|
+
* which means "looked, found none" and is a different fact a surface renders
|
|
172
|
+
* differently.
|
|
187
173
|
*
|
|
188
174
|
* The rows alone are a lie about scale the moment a group has more than twelve
|
|
189
175
|
* members: a dashboard summing them states an incomplete figure as a complete
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* projectTools.mjs. projectTools.test.mjs pins every transport word to here.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { join, resolve } from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { codexHome } from './codexHome.mjs';
|
|
21
|
+
import { parseCodexToml, TomlRaw } from './codexPersonal.mjs';
|
|
21
22
|
|
|
22
23
|
export const toml = (value) => JSON.stringify(String(value));
|
|
23
24
|
|
|
@@ -43,6 +44,72 @@ function linkPersonalEntry(target, link) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** A table header's dotted keys, unquoted — null for one this reader cannot
|
|
48
|
+
* split, which is then nobody's to strip. */
|
|
49
|
+
function headerKeys(section) {
|
|
50
|
+
const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
|
|
51
|
+
const keys = [];
|
|
52
|
+
for (let i = 0; i < section.length; ) {
|
|
53
|
+
key.lastIndex = i;
|
|
54
|
+
const m = key.exec(section);
|
|
55
|
+
if (!m || key.lastIndex === i) return null;
|
|
56
|
+
try {
|
|
57
|
+
keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
i = key.lastIndex;
|
|
62
|
+
}
|
|
63
|
+
return keys;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The key a `key = …` / `key.sub = …` line defines, unquoted, or null. */
|
|
67
|
+
const lineKey = (line) => {
|
|
68
|
+
const m = /^\s*(?:"([^"\\]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*[.=]/.exec(line);
|
|
69
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* THE REPO'S MCP SERVERS, AS THE AGENT'S OWN CONFIG (2026-10-10, the audit).
|
|
74
|
+
* A Codex build turn was handed the base branch's servers as `-c` pairs —
|
|
75
|
+
* `mcp_servers.<n>.env.<K>=<value>`, `…http_headers={…}`, `…url=…` — with
|
|
76
|
+
* every `${VAR}` already expanded, so a token the repo's `.mcp.json` names
|
|
77
|
+
* sat in /proc/<pid>/cmdline, every local user's to read, against this
|
|
78
|
+
* daemon's own rule (codexPersonal.mjs: "A LITERAL SECRET NEVER RIDES
|
|
79
|
+
* ARGV"). They are written here instead, as tables of the agent's private
|
|
80
|
+
* CODEX_HOME config.toml (0600, in a 0700 home), which a build turn reads and
|
|
81
|
+
* every fenced posture ignores (`--ignore-user-config`) — the same turns the
|
|
82
|
+
* `-c` pairs reached. Written in the shape codexPersonal.mjs's bounded reader
|
|
83
|
+
* parses, since a fenced turn reads this file for its login.
|
|
84
|
+
*
|
|
85
|
+
* SO THE EXPANDED VALUES NOW SIT ON DISK, AND THAT IS STATED (2026-10-10). The
|
|
86
|
+
* `-c` pairs died with the turn; these tables live as long as the home does.
|
|
87
|
+
* An agent's home is kept between its turns in the worktree's private git dir
|
|
88
|
+
* (workAgentTurnExecution.mjs `codexAgentHome`) and goes only with the
|
|
89
|
+
* worktree, when the agent retires — until then this 0600 file holds every
|
|
90
|
+
* secret the repo's `.mcp.json` named. Each turn rewrites it whole, so a
|
|
91
|
+
* server the base branch dropped, or a variable since unset, leaves with the
|
|
92
|
+
* next turn; a home with no git dir to hold it dies with its turn. NOT DONE:
|
|
93
|
+
* a URL that would carry an expanded secret is written as any other — refusing
|
|
94
|
+
* it ('missing') is an open follow-up from the same audit finding (F660).
|
|
95
|
+
*/
|
|
96
|
+
function codexServerTables(servers) {
|
|
97
|
+
let out = '';
|
|
98
|
+
for (const [name, server] of Object.entries(servers ?? {})) {
|
|
99
|
+
out += `\n[mcp_servers.${name}]\n`;
|
|
100
|
+
if (typeof server.command === 'string') {
|
|
101
|
+
out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n`;
|
|
102
|
+
const env = Object.entries(server.env ?? {}).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
|
|
103
|
+
if (env.length) out += `[mcp_servers.${name}.env]\n${env.map(([key, value]) => `${key} = ${toml(value)}`).join('\n')}\n`;
|
|
104
|
+
} else {
|
|
105
|
+
out += `url = ${toml(server.url)}\n`;
|
|
106
|
+
const headers = Object.entries(server.headers ?? {});
|
|
107
|
+
if (headers.length) out += `[mcp_servers.${name}.http_headers]\n${headers.map(([key, value]) => `${toml(key)} = ${toml(value)}`).join('\n')}\n`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
46
113
|
/**
|
|
47
114
|
* Keep personal Codex files, but do not copy its project trust grants. An
|
|
48
115
|
* untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1.
|
|
@@ -58,23 +125,39 @@ function linkPersonalEntry(target, link) {
|
|
|
58
125
|
* worktree's skills can change between turns), and what Codex made here
|
|
59
126
|
* itself under a name the person has not got is kept.
|
|
60
127
|
*/
|
|
61
|
-
function isolatedCodexHome(dest, env, worktree) {
|
|
62
|
-
const source = resolve(env
|
|
128
|
+
function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
129
|
+
const source = resolve(codexHome(env));
|
|
63
130
|
const configPath = join(source, 'config.toml');
|
|
64
131
|
const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
65
132
|
const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
|
|
66
133
|
const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
|
|
67
|
-
//
|
|
68
|
-
//
|
|
134
|
+
// The repo's servers replace a personal server of the same name whole —
|
|
135
|
+
// its `[mcp_servers.<n>…]` tables are dropped below. A personal
|
|
136
|
+
// `mcp_servers` written inline cannot be told apart line by line, so it
|
|
137
|
+
// fails closed, as an inline `skills.config` does below.
|
|
138
|
+
const repoServers = new Set(Object.keys(mcpServers ?? {}));
|
|
139
|
+
const inline = () => new Error('Cannot isolate Codex project MCP servers beside an inline mcp_servers entry in config.toml');
|
|
140
|
+
if (repoServers.size && /^\s*["']?mcp_servers["']?\s*[.=]/m.test(topLevel)) throw inline();
|
|
141
|
+
// The `projects` sections alone grant a checkout permission to supply
|
|
142
|
+
// project settings. Keep every other personal setting, skill and session
|
|
143
|
+
// store.
|
|
69
144
|
const lines = sourceConfig.split('\n');
|
|
70
|
-
let
|
|
145
|
+
let dropping = false;
|
|
146
|
+
let inServers = false;
|
|
71
147
|
let config = lines.filter((line) => {
|
|
72
148
|
const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
|
|
73
149
|
if (header) {
|
|
74
150
|
const section = header[1] ?? header[2];
|
|
75
|
-
|
|
151
|
+
const keys = headerKeys(section);
|
|
152
|
+
inServers = keys?.length === 1 && keys[0] === 'mcp_servers';
|
|
153
|
+
dropping =
|
|
154
|
+
section === 'projects' ||
|
|
155
|
+
section.startsWith('projects.') ||
|
|
156
|
+
(keys?.[0] === 'mcp_servers' && keys.length > 1 && repoServers.has(keys[1]));
|
|
157
|
+
} else if (inServers && repoServers.has(lineKey(line))) {
|
|
158
|
+
throw inline();
|
|
76
159
|
}
|
|
77
|
-
return !
|
|
160
|
+
return !dropping;
|
|
78
161
|
}).join('\n');
|
|
79
162
|
if (worktree) {
|
|
80
163
|
// Codex discovers these even in an untrusted project. Disable exactly the
|
|
@@ -92,6 +175,7 @@ function isolatedCodexHome(dest, env, worktree) {
|
|
|
92
175
|
throw new Error('Cannot isolate Codex project skills with inline skills.config');
|
|
93
176
|
for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
|
|
94
177
|
}
|
|
178
|
+
config += codexServerTables(mcpServers);
|
|
95
179
|
|
|
96
180
|
// The home itself, only once the config is decided: a refused turn touches
|
|
97
181
|
// nothing here. A home that is not a real directory is not ours to write
|
|
@@ -99,9 +183,14 @@ function isolatedCodexHome(dest, env, worktree) {
|
|
|
99
183
|
try { if (!lstatSync(dest).isDirectory()) rmSync(dest, { force: true }); } catch { /* absent */ }
|
|
100
184
|
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
101
185
|
chmodSync(dest, 0o700);
|
|
102
|
-
const personal = existsSync(source) ? readdirSync(source).filter((name) => name !== 'config.toml') : [];
|
|
186
|
+
const personal = existsSync(source) ? readdirSync(source).filter((name) => name !== 'config.toml' && name !== 'generated_images') : [];
|
|
103
187
|
for (const name of personal) linkPersonalEntry(join(source, name), join(dest, name));
|
|
104
|
-
|
|
188
|
+
// Native supporting images belong to this agent, not the person's shared
|
|
189
|
+
// image directory. Repair homes created before this distinction.
|
|
190
|
+
const generated = join(dest, 'generated_images');
|
|
191
|
+
try { if (lstatSync(generated).isSymbolicLink()) rmSync(generated); } catch { /* absent */ }
|
|
192
|
+
mkdirSync(generated, { recursive: true, mode: 0o700 });
|
|
193
|
+
const linked = new Set([...personal, 'generated_images']);
|
|
105
194
|
for (const name of readdirSync(dest)) {
|
|
106
195
|
if (linked.has(name) || name === 'config.toml') continue;
|
|
107
196
|
// Only a LINK is ours to drop: a real entry under a name the person has
|
|
@@ -122,6 +211,212 @@ function isolatedCodexHome(dest, env, worktree) {
|
|
|
122
211
|
return { path: dest, personalDeveloperInstructions };
|
|
123
212
|
}
|
|
124
213
|
|
|
214
|
+
/** The environment names a fenced Codex turn's MCP header values ride under,
|
|
215
|
+
* one per header (`codexFencedMount`). */
|
|
216
|
+
export const MCP_HEADER_ENV_PREFIX = 'FLOWVIANT_MCP_HEADER_';
|
|
217
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
218
|
+
const SERVER_NAME = /^[A-Za-z0-9_-]{1,80}$/;
|
|
219
|
+
const expands = (value) => /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(String(value));
|
|
220
|
+
/**
|
|
221
|
+
* Names this machine's Codex, or the shell and tools under it, read for
|
|
222
|
+
* themselves. A server's env value rides in the CLI's own environment
|
|
223
|
+
* (`env_vars` forwards it), so a name the machine does not already set to that
|
|
224
|
+
* value would change how the CLI itself runs — a proxy, a CA bundle, a
|
|
225
|
+
* provider key. Such a server is left unmounted, in words.
|
|
226
|
+
*/
|
|
227
|
+
const CLI_ENV =
|
|
228
|
+
/^(PATH|HOME|SHELL|USER|LOGNAME|PWD|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|DISPLAY|WAYLAND_DISPLAY|BROWSER|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|RUST_LOG|RUST_BACKTRACE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy)$|^(LC|XDG|LD|DYLD|OPENAI|CODEX|AZURE_OPENAI|FLOWVIANT)_/;
|
|
229
|
+
|
|
230
|
+
/** This machine's own Codex servers: its config.toml's `[mcp_servers.*]`,
|
|
231
|
+
* read the way codexPersonal.mjs reads the login (bounded; a file that does
|
|
232
|
+
* not parse carries nothing). */
|
|
233
|
+
function machineCodexServers(machineEnv) {
|
|
234
|
+
try {
|
|
235
|
+
const path = join(resolve(codexHome(machineEnv)), 'config.toml');
|
|
236
|
+
const st = statSync(path);
|
|
237
|
+
if (!st.isFile() || st.size > 1024 * 1024) return [];
|
|
238
|
+
const servers = parseCodexToml(readFileSync(path, 'utf8'))?.mcp_servers;
|
|
239
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
|
|
240
|
+
return Object.entries(servers).filter(([, v]) => v && typeof v === 'object' && !Array.isArray(v));
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const tomlScalar = (v) => (v instanceof TomlRaw ? v.text : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
|
|
246
|
+
const words = (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null);
|
|
247
|
+
const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* THE MCP SERVERS ON A FENCED CODEX TURN — the project's and, since 2026-10-11,
|
|
251
|
+
* the machine's own (the owner: "the server that is running flowviant's claude
|
|
252
|
+
* HAS these MCPs yet the agents are not using them"). A Codex build reads both
|
|
253
|
+
* from the agent's config.toml (`isolatedCodexHome`: the person's own tables
|
|
254
|
+
* kept, the repo's beside them). Every fenced posture — write-up, mockup,
|
|
255
|
+
* 3D model, deck, image — passes `--ignore-user-config`, which drops that
|
|
256
|
+
* file, and a `-p` profile layered beside the flag was measured to load no
|
|
257
|
+
* server either (codex-cli 0.159.2, artifactProjectMcp.test.mjs). So these
|
|
258
|
+
* turns take the servers as `-c` pairs, the way codexPersonal.mjs carries a
|
|
259
|
+
* login back, under the same rule: A LITERAL SECRET NEVER RIDES ARGV.
|
|
260
|
+
*
|
|
261
|
+
* · A stdio server's command and arguments ride as written: they are the
|
|
262
|
+
* server process's own command line once Codex starts it, so `-c` adds no
|
|
263
|
+
* reader. A project server whose command or arguments expand a `${VAR}`
|
|
264
|
+
* is still left unmounted (the 2026-10-10 audit took expanded values off
|
|
265
|
+
* argv, and this keeps that line).
|
|
266
|
+
* · Every env value rides in the turn's ENVIRONMENT under its own name,
|
|
267
|
+
* forwarded by Codex's `env_vars` — except a project value with no `${VAR}`,
|
|
268
|
+
* which is text of the reviewed file and rides as written. Refused (the
|
|
269
|
+
* server is left unmounted, with the reason) when the name is the
|
|
270
|
+
* daemon's own (`FLOWVIANT_*`, `CODEX_HOME`), when this machine already
|
|
271
|
+
* sets it to something else, or when it is one the CLI reads itself
|
|
272
|
+
* (`CLI_ENV`) and the machine does not set it.
|
|
273
|
+
* · Every header value rides in the environment, named by
|
|
274
|
+
* `env_http_headers`. A machine server's own `env_http_headers`,
|
|
275
|
+
* `bearer_token_env_var` and `env_vars` are names, and ride as written.
|
|
276
|
+
* · A URL that expands a `${VAR}`, or carries a query, fragment or
|
|
277
|
+
* credentials, has no indirection in Codex's config: left unmounted.
|
|
278
|
+
* · A machine server keeps its own `cwd`, timeouts and tool lists; it is
|
|
279
|
+
* skipped where the person turned it off (`enabled = false`), and a
|
|
280
|
+
* project server of the same name replaces it, as on the build.
|
|
281
|
+
* · Each mounted server's tools are approved by name: an unannotated tool
|
|
282
|
+
* under a fenced posture failed "MCP tool call requires approval, but
|
|
283
|
+
* approval policy is never" (measured, 0.159.2) — never a blanket
|
|
284
|
+
* approval policy.
|
|
285
|
+
*
|
|
286
|
+
* `usable` is the base snapshot's ready entries; `expand` reads a value the
|
|
287
|
+
* way projectTools.mjs does; `machineEnv` is the caller's own (what the child
|
|
288
|
+
* would inherit). Returns `{ args, env, mounted, machine, unmounted }`:
|
|
289
|
+
* `mounted` the project servers, `machine` the machine's own, and
|
|
290
|
+
* `unmounted` `[{ name, reason, source: 'project' | 'machine' }]`.
|
|
291
|
+
*/
|
|
292
|
+
function codexFencedMount(usable, expand, machineEnv) {
|
|
293
|
+
const args = [];
|
|
294
|
+
const childEnv = {};
|
|
295
|
+
const mounted = [];
|
|
296
|
+
const machine = [];
|
|
297
|
+
const unmounted = [];
|
|
298
|
+
let header = 0;
|
|
299
|
+
/** A refusal sentence, or null with the value staged in `extra`. */
|
|
300
|
+
const forward = (key, value, extra) => {
|
|
301
|
+
if (key.startsWith('FLOWVIANT_') || key === 'CODEX_HOME') return `its env ${key} is a name this daemon keeps for itself`;
|
|
302
|
+
const has = machineEnv[key];
|
|
303
|
+
if (has !== undefined) return has === value ? null : `its env ${key} would replace this machine's own ${key}, which a fenced Codex turn cannot do`;
|
|
304
|
+
if (CLI_ENV.test(key)) return `its env ${key} is one this machine's Codex reads itself, which a fenced Codex turn cannot set`;
|
|
305
|
+
const staged = childEnv[key] ?? extra[key];
|
|
306
|
+
if (staged !== undefined && staged !== value) return `its env ${key} would replace another server's ${key}`;
|
|
307
|
+
extra[key] = value;
|
|
308
|
+
return null;
|
|
309
|
+
};
|
|
310
|
+
const headerEnv = (headers, extra) =>
|
|
311
|
+
Object.entries(headers).map(([key, value]) => {
|
|
312
|
+
const envName = `${MCP_HEADER_ENV_PREFIX}${header++}`;
|
|
313
|
+
extra[envName] = value;
|
|
314
|
+
return `${toml(key)}=${toml(envName)}`;
|
|
315
|
+
});
|
|
316
|
+
const accept = (name, pairs, extra, list) => {
|
|
317
|
+
args.push(...pairs, '-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`);
|
|
318
|
+
Object.assign(childEnv, extra);
|
|
319
|
+
list.push(name);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
for (const { name, config } of usable) {
|
|
323
|
+
const pairs = [];
|
|
324
|
+
const extra = {};
|
|
325
|
+
let refused = null;
|
|
326
|
+
if (typeof config.command === 'string') {
|
|
327
|
+
const rawArgs = Array.isArray(config.args) ? config.args : [];
|
|
328
|
+
if (expands(config.command) || rawArgs.some(expands)) {
|
|
329
|
+
refused = 'its command or arguments name a ${VAR}, which a fenced Codex turn would put on the command line';
|
|
330
|
+
} else {
|
|
331
|
+
pairs.push('-c', `mcp_servers.${name}.command=${toml(String(config.command))}`);
|
|
332
|
+
pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map((v) => toml(String(v))).join(',')}]`);
|
|
333
|
+
const forwarded = [];
|
|
334
|
+
for (const [key, value] of Object.entries(config.env ?? {})) {
|
|
335
|
+
if (!ENV_NAME.test(key)) continue;
|
|
336
|
+
if (!expands(value)) {
|
|
337
|
+
pairs.push('-c', `mcp_servers.${name}.env.${key}=${toml(String(value))}`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
refused = forward(key, expand(value), extra);
|
|
341
|
+
if (refused) break;
|
|
342
|
+
forwarded.push(key);
|
|
343
|
+
}
|
|
344
|
+
if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
|
|
345
|
+
}
|
|
346
|
+
} else if (typeof config.url === 'string') {
|
|
347
|
+
if (expands(config.url)) {
|
|
348
|
+
refused = 'its URL names a ${VAR}, which a fenced Codex turn would put on the command line';
|
|
349
|
+
} else {
|
|
350
|
+
pairs.push('-c', `mcp_servers.${name}.url=${toml(String(config.url))}`);
|
|
351
|
+
const named = headerEnv(Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)])), extra);
|
|
352
|
+
if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
|
|
353
|
+
}
|
|
354
|
+
} else continue;
|
|
355
|
+
if (refused) unmounted.push({ name, reason: refused, source: 'project' });
|
|
356
|
+
else accept(name, pairs, extra, mounted);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const projectNames = new Set(usable.map((t) => t.name));
|
|
360
|
+
for (const [name, server] of machineCodexServers(machineEnv)) {
|
|
361
|
+
if (projectNames.has(name) || name === 'flowviant' || server.enabled === false) continue;
|
|
362
|
+
if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
|
|
363
|
+
const pairs = [];
|
|
364
|
+
const extra = {};
|
|
365
|
+
let refused = SERVER_NAME.test(name) ? null : 'its name cannot be spelled as a command-line setting';
|
|
366
|
+
if (!refused && typeof server.command === 'string') {
|
|
367
|
+
const rawArgs = server.args === undefined ? [] : words(server.args);
|
|
368
|
+
if (!rawArgs) refused = 'its args are not a list of words';
|
|
369
|
+
else {
|
|
370
|
+
pairs.push('-c', `mcp_servers.${name}.command=${toml(server.command)}`);
|
|
371
|
+
pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map(toml).join(',')}]`);
|
|
372
|
+
const forwarded = [...(words(server.env_vars) ?? []).filter((k) => ENV_NAME.test(k))];
|
|
373
|
+
for (const [key, value] of Object.entries(table(server.env))) {
|
|
374
|
+
if (!ENV_NAME.test(key) || typeof value !== 'string') {
|
|
375
|
+
refused = `its env ${String(key).slice(0, 40)} is not a name and text`;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
refused = forward(key, value, extra);
|
|
379
|
+
if (refused) break;
|
|
380
|
+
if (!forwarded.includes(key)) forwarded.push(key);
|
|
381
|
+
}
|
|
382
|
+
if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
|
|
383
|
+
if (typeof server.cwd === 'string') pairs.push('-c', `mcp_servers.${name}.cwd=${toml(server.cwd)}`);
|
|
384
|
+
}
|
|
385
|
+
} else if (!refused) {
|
|
386
|
+
let url = null;
|
|
387
|
+
try { url = new URL(server.url); } catch { /* refused below */ }
|
|
388
|
+
if (!url || url.search || url.hash || url.username || url.password) {
|
|
389
|
+
refused = 'its URL carries a query, fragment or credentials, which a fenced Codex turn would put on the command line';
|
|
390
|
+
} else {
|
|
391
|
+
pairs.push('-c', `mcp_servers.${name}.url=${toml(server.url)}`);
|
|
392
|
+
if (typeof server.bearer_token_env_var === 'string' && ENV_NAME.test(server.bearer_token_env_var))
|
|
393
|
+
pairs.push('-c', `mcp_servers.${name}.bearer_token_env_var=${toml(server.bearer_token_env_var)}`);
|
|
394
|
+
const literal = table(server.http_headers);
|
|
395
|
+
const byName = table(server.env_http_headers);
|
|
396
|
+
if (Object.values(literal).some((v) => typeof v !== 'string') || Object.values(byName).some((v) => typeof v !== 'string' || !ENV_NAME.test(v))) {
|
|
397
|
+
refused = 'its headers are not text';
|
|
398
|
+
} else {
|
|
399
|
+
const named = [...headerEnv(literal, extra), ...Object.entries(byName).map(([key, envName]) => `${toml(key)}=${toml(envName)}`)];
|
|
400
|
+
if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!refused) {
|
|
405
|
+
for (const key of ['startup_timeout_sec', 'startup_timeout_ms', 'tool_timeout_sec']) {
|
|
406
|
+
const v = tomlScalar(server[key]);
|
|
407
|
+
if (v != null && /^[0-9_.]+$/.test(v)) pairs.push('-c', `mcp_servers.${name}.${key}=${v}`);
|
|
408
|
+
}
|
|
409
|
+
for (const key of ['enabled_tools', 'disabled_tools']) {
|
|
410
|
+
const list = words(server[key]);
|
|
411
|
+
if (list) pairs.push('-c', `mcp_servers.${name}.${key}=[${list.map(toml).join(',')}]`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (refused) unmounted.push({ name, reason: refused, source: 'machine' });
|
|
415
|
+
else accept(name, pairs, extra, machine);
|
|
416
|
+
}
|
|
417
|
+
return { args, env: childEnv, mounted, machine, unmounted };
|
|
418
|
+
}
|
|
419
|
+
|
|
125
420
|
/** Claude takes the base branch's skills as a plugin written for this turn. */
|
|
126
421
|
function reviewedClaudePlugin(dir, snapshot) {
|
|
127
422
|
if (!snapshot.skillFiles.length) return null;
|
|
@@ -139,8 +434,10 @@ function reviewedClaudePlugin(dir, snapshot) {
|
|
|
139
434
|
/**
|
|
140
435
|
* The table. `refuseMcp(config)` names why this runtime cannot take one MCP
|
|
141
436
|
* configuration it otherwise could; `prepare(dir, snapshot, env, worktree,
|
|
142
|
-
*
|
|
143
|
-
* `dir`, which dies with the turn, except what `
|
|
437
|
+
* turn)` returns the runner-specific part of prepareAgentTools' result — under
|
|
438
|
+
* `dir`, which dies with the turn, except what `turn.codexHome` names a
|
|
439
|
+
* lasting place for. `turn.mcpServers` is the base branch's servers this
|
|
440
|
+
* runtime takes, expanded.
|
|
144
441
|
*/
|
|
145
442
|
const TOOL_RUNTIMES = {
|
|
146
443
|
claude: {
|
|
@@ -156,10 +453,12 @@ const TOOL_RUNTIMES = {
|
|
|
156
453
|
// The agent's own lasting home when the caller has one (2026-09-29: its
|
|
157
454
|
// threads are indexed under it — see isolatedCodexHome), else one that
|
|
158
455
|
// dies with the turn, as every home did before.
|
|
159
|
-
prepare: (dir, _snapshot, env, worktree,
|
|
160
|
-
const isolated = isolatedCodexHome(
|
|
456
|
+
prepare: (dir, _snapshot, env, worktree, turn) => {
|
|
457
|
+
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
|
|
161
458
|
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
|
|
162
459
|
},
|
|
460
|
+
// A fenced posture ignores that home's config.toml (2026-10-10).
|
|
461
|
+
fencedMount: codexFencedMount,
|
|
163
462
|
},
|
|
164
463
|
antigravity: { mcp: false, skills: false },
|
|
165
464
|
};
|
|
@@ -168,6 +467,16 @@ const TOOL_RUNTIMES = {
|
|
|
168
467
|
const takesNothing = { mcp: false, skills: false };
|
|
169
468
|
const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES[runner] : takesNothing);
|
|
170
469
|
|
|
470
|
+
/** The servers as a fenced posture takes them where it ignores the runner's
|
|
471
|
+
* own file — Codex's `-c` pairs and environment (`codexFencedMount`) — or
|
|
472
|
+
* null for a runner whose fenced turns read the same file as its build
|
|
473
|
+
* (Claude's strict mcp.json) or that takes none. `usable` is the base
|
|
474
|
+
* snapshot's ready entries; `expand(value)` reads a `${VAR}`. */
|
|
475
|
+
export function fencedMountFor(runner, usable, expand, machineEnv = process.env) {
|
|
476
|
+
const mount = rowFor(runner).fencedMount;
|
|
477
|
+
return mount ? mount(usable, expand, machineEnv) : null;
|
|
478
|
+
}
|
|
479
|
+
|
|
171
480
|
/** Pure mapping used before a future tool-attach gesture is offered. */
|
|
172
481
|
export function runnerToolCapabilities(runner) {
|
|
173
482
|
const { mcp, skills } = rowFor(runner);
|
|
@@ -189,8 +498,8 @@ export function skillsRefusal(runner) {
|
|
|
189
498
|
return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
|
|
190
499
|
}
|
|
191
500
|
|
|
192
|
-
/** The runner-specific files a turn needs, written under `dir` — or, for
|
|
193
|
-
* `
|
|
194
|
-
export function prepareForRunner(runner, dir, snapshot, env, worktree,
|
|
195
|
-
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree,
|
|
501
|
+
/** The runner-specific files a turn needs, written under `dir` — or, for the
|
|
502
|
+
* `codexHome` it names, where the caller keeps it between turns. */
|
|
503
|
+
export function prepareForRunner(runner, dir, snapshot, env, worktree, turn = {}) {
|
|
504
|
+
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
|
|
196
505
|
}
|