flowviant 0.125.5 → 0.125.7
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 +4 -5
- package/bin/lib/agentInternalWait.mjs +1 -5
- package/bin/lib/agentPlan.mjs +13 -163
- package/bin/lib/agentRepositories.mjs +3 -1
- package/bin/lib/claudeInput.mjs +89 -10
- package/bin/lib/claudeMachineMcp.mjs +85 -0
- package/bin/lib/claudePersonal.mjs +6 -57
- package/bin/lib/claudePosture.mjs +6 -15
- package/bin/lib/claudeSettingsArg.mjs +67 -0
- package/bin/lib/claudeStream.mjs +30 -1
- package/bin/lib/claudeSubagentProgress.mjs +12 -28
- package/bin/lib/claudeSubagents.mjs +4 -2
- package/bin/lib/codexContract.mjs +52 -0
- package/bin/lib/codexFencedMount.mjs +275 -0
- package/bin/lib/codexPersonal.mjs +1 -265
- package/bin/lib/codexProjectRead.mjs +5 -4
- package/bin/lib/codexResearch.mjs +9 -3
- package/bin/lib/codexSubagentEvents.mjs +11 -7
- package/bin/lib/codexSubagents.mjs +55 -52
- package/bin/lib/codexToml.mjs +328 -0
- package/bin/lib/config.mjs +22 -1
- package/bin/lib/conversationIds.mjs +10 -0
- package/bin/lib/fleet.mjs +2 -2
- package/bin/lib/git.mjs +32 -0
- package/bin/lib/heldTool.mjs +49 -0
- package/bin/lib/hooks/mcpAllow.mjs +11 -5
- package/bin/lib/hooks/orchestrator.mjs +20 -10
- package/bin/lib/intakeDraft.mjs +1 -1
- package/bin/lib/jsonlTail.mjs +52 -0
- package/bin/lib/mcpInventory.mjs +11 -6
- package/bin/lib/mcpReasons.mjs +26 -0
- package/bin/lib/mcpReportBounds.mjs +10 -0
- package/bin/lib/mcpStdio.mjs +35 -0
- package/bin/lib/mergeBatch.mjs +6 -24
- package/bin/lib/mergeOrder.mjs +18 -0
- package/bin/lib/modelJson.mjs +173 -0
- package/bin/lib/nativeAgentTools.mjs +72 -0
- package/bin/lib/orchestrator.mjs +221 -133
- package/bin/lib/orchestratorClient.mjs +17 -21
- package/bin/lib/orchestratorHooks.mjs +21 -4
- package/bin/lib/orchestratorNative.mjs +58 -0
- package/bin/lib/orchestratorPrompt.mjs +7 -4
- package/bin/lib/orchestratorProtocol.mjs +25 -3
- package/bin/lib/postureTools.mjs +58 -13
- package/bin/lib/prWorkflow.mjs +13 -2
- package/bin/lib/processGroup.mjs +41 -0
- package/bin/lib/projectRepositories.mjs +2 -2
- package/bin/lib/projectToolRuntimes.mjs +92 -245
- package/bin/lib/projectTools.mjs +60 -19
- package/bin/lib/prompts.mjs +47 -18
- package/bin/lib/reportJournal.mjs +61 -0
- package/bin/lib/repositoryDelivery.mjs +6 -4
- package/bin/lib/repositoryMerge.mjs +8 -12
- package/bin/lib/repositoryPlaces.mjs +2 -2
- package/bin/lib/repositoryPrompts.mjs +42 -0
- package/bin/lib/repositoryTool.mjs +9 -32
- package/bin/lib/researchUse.mjs +39 -0
- package/bin/lib/resumeLost.mjs +21 -1
- package/bin/lib/resumeMarker.mjs +43 -0
- package/bin/lib/runTurn.mjs +55 -52
- package/bin/lib/runtimeAntigravity.mjs +1 -1
- package/bin/lib/runtimeCapabilities.mjs +13 -18
- package/bin/lib/runtimeClaude.mjs +68 -24
- package/bin/lib/runtimeCodex.mjs +12 -6
- package/bin/lib/runtimeEvents.mjs +13 -2
- package/bin/lib/runtimeOfferings.mjs +12 -5
- package/bin/lib/runtimes.mjs +3 -1
- package/bin/lib/startProject.mjs +10 -0
- package/bin/lib/subagentBounds.mjs +20 -0
- package/bin/lib/trace.mjs +30 -5
- package/bin/lib/turnDeclaration.mjs +1 -1
- package/bin/lib/turnDiagnostics.mjs +9 -12
- package/bin/lib/turnReceipts.mjs +64 -0
- package/bin/lib/turnSubagents.mjs +91 -0
- package/bin/lib/turnUsage.mjs +9 -6
- package/bin/lib/update.mjs +18 -4
- package/bin/lib/versionOrder.mjs +11 -1
- package/bin/lib/work.mjs +9 -9
- package/bin/lib/workAgentMerges.mjs +5 -7
- package/bin/lib/workAgentPrecheck.mjs +5 -5
- package/bin/lib/workAgentRecovery.mjs +2 -1
- package/bin/lib/workAgentResume.mjs +121 -0
- package/bin/lib/workAgentTools.mjs +111 -0
- package/bin/lib/workAgentTurnExecution.mjs +82 -246
- package/bin/lib/workAgentTurnOutcome.mjs +30 -3
- package/bin/lib/workAgentTurnReports.mjs +16 -0
- package/bin/lib/workAgentTurnShape.mjs +90 -0
- package/bin/lib/workAgentTurns.mjs +28 -12
- package/bin/lib/workArtifactReconcile.mjs +3 -2
- package/bin/lib/workAttachments.mjs +27 -5
- package/bin/lib/workMergeBatch.mjs +7 -5
- package/bin/lib/workNarration.mjs +32 -0
- package/bin/lib/workPlaceReports.mjs +3 -3
- package/bin/lib/workPlaces.mjs +14 -0
- package/bin/lib/workReportQueue.mjs +18 -1
- package/bin/lib/workSessionRuntime.mjs +2 -5
- package/bin/lib/workSessionTurns.mjs +68 -80
- package/bin/lib/workShip.mjs +8 -18
- package/bin/lib/workTurnShape.mjs +6 -4
- package/bin/lib/worktreeDiff.mjs +29 -5
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TURN'S ONE `--settings`, SPLICED AND READ IN ONE PLACE (2026-10-18, the
|
|
3
|
+
* SOLID pass). Three functions in two modules spliced the flag — the machine's
|
|
4
|
+
* MCP settings and the orchestrator's hooks (claudePosture.mjs), the person's
|
|
5
|
+
* kept keys (claudePersonal.mjs) — each repeating find, parse, merge, then
|
|
6
|
+
* splice or prepend, and the turn diagnostics read it a fourth way. The
|
|
7
|
+
* 2026-10-17 bug (the orchestrator's hooks on a second `--settings`, never
|
|
8
|
+
* loaded) is that class: the next thing a turn's settings carry merges here,
|
|
9
|
+
* into the one flag, and claudeSettingsArg.test.mjs keeps every other module
|
|
10
|
+
* from finding it for itself.
|
|
11
|
+
*
|
|
12
|
+
* THE PERSONAL STEP RUNS LAST. claudePersonal.mjs `withPersonalSettings` turns
|
|
13
|
+
* the value into the path of a private file (the person's keys never ride
|
|
14
|
+
* argv), so nothing merges after it: `replaceSettings` reads inline JSON only
|
|
15
|
+
* and refuses a path in words — merging into that file would put the person's
|
|
16
|
+
* secrets back on the command line. `settingsAt`, the diagnostics' reader,
|
|
17
|
+
* reads either.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
/** The turn's `--settings`: where it stands (-1 for none) and what it holds —
|
|
22
|
+
* null with no flag, else the inline JSON or the file it names, parsed. May
|
|
23
|
+
* throw (a file gone, a value that does not parse); the caller decides. */
|
|
24
|
+
export function settingsAt(args) {
|
|
25
|
+
const at = args.indexOf('--settings');
|
|
26
|
+
if (at < 0) return { at, settings: null };
|
|
27
|
+
const input = args[at + 1];
|
|
28
|
+
return { at, settings: JSON.parse(input.startsWith('{') ? input : readFileSync(input, 'utf8')) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An inline value, parsed — never a path (see the header). */
|
|
32
|
+
function inline(value) {
|
|
33
|
+
if (typeof value !== 'string' || !value.startsWith('{')) {
|
|
34
|
+
throw new Error("this turn's --settings already names a file; nothing merges after the personal settings");
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `perm` with its one `--settings` replaced by `fn(posture)` — `posture` the
|
|
41
|
+
* flag's own object, `{}` where there is none — standing where the flag stood,
|
|
42
|
+
* or before the lists when there was none (`--allowedTools` is variadic).
|
|
43
|
+
*/
|
|
44
|
+
export function replaceSettings(perm, fn) {
|
|
45
|
+
const at = perm.indexOf('--settings');
|
|
46
|
+
const value = at < 0 ? fn({}) : fn(inline(perm[at + 1]));
|
|
47
|
+
return at < 0 ? ['--settings', value, ...perm] : [...perm.slice(0, at), '--settings', value, ...perm.slice(at + 2)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ONE `--settings` PER TURN (2026-10-17). Claude Code applies only the LAST
|
|
52
|
+
* `--settings` it is handed (measured, 2.1.302: two flags each holding a
|
|
53
|
+
* PreToolUse hook ran the second hook alone). The card orchestrator's hooks
|
|
54
|
+
* rode a flag of their own ahead of the machine-MCP settings (claudePosture.mjs
|
|
55
|
+
* `withMachineMcp`), so on every code card that mounts the machine's servers
|
|
56
|
+
* the spawn gate, child registration and checkpoints never loaded
|
|
57
|
+
* (claudeOrchestratorHooks.test.mjs
|
|
58
|
+
* re-measures it on the real CLI). `extra` hooks join the posture's per event,
|
|
59
|
+
* after its own; its other keys win over the posture's.
|
|
60
|
+
*/
|
|
61
|
+
export function withSettings(perm, { hooks: extraHooks = {}, ...extra }) {
|
|
62
|
+
return replaceSettings(perm, (posture) => {
|
|
63
|
+
const hooks = { ...(posture.hooks ?? {}) };
|
|
64
|
+
for (const [event, rows] of Object.entries(extraHooks)) hooks[event] = [...(hooks[event] ?? []), ...rows];
|
|
65
|
+
return JSON.stringify({ ...posture, ...extra, hooks });
|
|
66
|
+
});
|
|
67
|
+
}
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -79,8 +79,28 @@ export function usageFromResult(ev) {
|
|
|
79
79
|
// container that reports no spend, which is indistinguishable from an older
|
|
80
80
|
// daemon by design. A callback that is silent when it breaks has to be called
|
|
81
81
|
// directly by something.
|
|
82
|
+
/**
|
|
83
|
+
* One TaskCreate/TaskUpdate result, applied to a turn's task list (`tasks`, a
|
|
84
|
+
* Map of id → `{ content, status }` in creation order). True when the list
|
|
85
|
+
* changed. A task an earlier turn created is not on this turn's list, and an
|
|
86
|
+
* update to it changes nothing here: its subject was never said.
|
|
87
|
+
*/
|
|
88
|
+
export function taskPlanChanged(tasks, result) {
|
|
89
|
+
if (!result || typeof result !== 'object') return false;
|
|
90
|
+
const made = result.task;
|
|
91
|
+
if (made && typeof made.id === 'string' && typeof made.subject === 'string' && made.subject.trim()) {
|
|
92
|
+
tasks.set(made.id, { content: made.subject, status: 'pending' });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const to = result.statusChange?.to;
|
|
96
|
+
if (typeof result.taskId !== 'string' || typeof to !== 'string' || !tasks.has(result.taskId)) return false;
|
|
97
|
+
if (to === 'deleted') tasks.delete(result.taskId);
|
|
98
|
+
else tasks.set(result.taskId, { ...tasks.get(result.taskId), status: to });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
export function handleStreamLine(line, options) {
|
|
83
|
-
const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents } = options;
|
|
103
|
+
const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks } = options;
|
|
84
104
|
let ev;
|
|
85
105
|
try {
|
|
86
106
|
ev = JSON.parse(line);
|
|
@@ -164,6 +184,15 @@ export function handleStreamLine(line, options) {
|
|
|
164
184
|
}
|
|
165
185
|
}
|
|
166
186
|
} else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
|
|
187
|
+
// THE TASK LIST IS THE PLAN NOW (2026-10-19). Claude Code 2.1.303 offers
|
|
188
|
+
// TaskCreate/TaskUpdate and no TodoWrite (measured: the tools a `-p` turn
|
|
189
|
+
// is handed), so the plan card — built from TodoWrite — never drew. Each
|
|
190
|
+
// result says what it did (`tool_use_result`: the created task's id and
|
|
191
|
+
// subject, or a task's status change), so the turn's own list is kept here
|
|
192
|
+
// and handed on in TodoWrite's shape, which the card already reads.
|
|
193
|
+
if (!agentId && tasks && taskPlanChanged(tasks, ev.tool_use_result)) {
|
|
194
|
+
onToolEvent?.('TodoWrite', { todos: [...tasks.values()] }, { id: 'claude-task-list' });
|
|
195
|
+
}
|
|
167
196
|
for (const b of ev.message.content) {
|
|
168
197
|
if (b.type === 'tool_result') onToolEvent?.(null, {}, {
|
|
169
198
|
id: b.tool_use_id, result: true, isError: b.is_error === true,
|
|
@@ -6,11 +6,11 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { claudeProjectDir } from './claudeSessions.mjs';
|
|
8
8
|
import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
|
|
9
|
+
import { followJsonl, newTail } from './jsonlTail.mjs';
|
|
9
10
|
|
|
10
|
-
const MAX_READ = 4 * 1024 * 1024;
|
|
11
|
-
const LINE_MAX = 256 * 1024;
|
|
12
11
|
export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = process.env, onLine }) {
|
|
13
|
-
|
|
12
|
+
const tail = newTail();
|
|
13
|
+
let file, timer;
|
|
14
14
|
let chain = Promise.resolve();
|
|
15
15
|
const flush = () => {
|
|
16
16
|
chain = chain.then(async () => {
|
|
@@ -18,31 +18,15 @@ export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = pr
|
|
|
18
18
|
let fh;
|
|
19
19
|
try {
|
|
20
20
|
fh = await open(file, 'r');
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const data = Buffer.concat([rest, buf.subarray(0, bytesRead)]);
|
|
31
|
-
let start = 0, nl;
|
|
32
|
-
while ((nl = data.indexOf(10, start)) >= 0) {
|
|
33
|
-
const line = data.subarray(start, nl).toString('utf8'); start = nl + 1;
|
|
34
|
-
if (skip) { skip = false; continue; }
|
|
35
|
-
try {
|
|
36
|
-
const ev = JSON.parse(line);
|
|
37
|
-
if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) continue;
|
|
38
|
-
const at = Date.parse(ev.timestamp ?? '');
|
|
39
|
-
if (!Number.isFinite(at) || at < since) continue;
|
|
40
|
-
onLine(line);
|
|
41
|
-
} catch { /* torn or unknown native row */ }
|
|
42
|
-
}
|
|
43
|
-
rest = data.subarray(start);
|
|
44
|
-
if (rest.length > LINE_MAX) { rest = Buffer.alloc(0); skip = true; }
|
|
45
|
-
}
|
|
21
|
+
await followJsonl(fh, tail, line => {
|
|
22
|
+
try {
|
|
23
|
+
const ev = JSON.parse(line);
|
|
24
|
+
if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) return;
|
|
25
|
+
const at = Date.parse(ev.timestamp ?? '');
|
|
26
|
+
if (!Number.isFinite(at) || at < since) return;
|
|
27
|
+
onLine(line);
|
|
28
|
+
} catch { /* torn or unknown native row */ }
|
|
29
|
+
});
|
|
46
30
|
} catch { /* the transcript can appear after init */ }
|
|
47
31
|
finally { await fh?.close().catch(() => {}); }
|
|
48
32
|
}).catch(() => {});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
|
|
2
|
+
|
|
1
3
|
/** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
|
|
2
4
|
export function createClaudeSubagents(report = () => {}) {
|
|
3
5
|
const calls = new Map();
|
|
@@ -29,12 +31,12 @@ export function createClaudeSubagents(report = () => {}) {
|
|
|
29
31
|
const agentId = identify(ev);
|
|
30
32
|
if (ev.type === 'assistant') {
|
|
31
33
|
for (const b of ev.message?.content ?? []) {
|
|
32
|
-
if (b.type === 'tool_use' && b.name
|
|
34
|
+
if (b.type === 'tool_use' && NATIVE_AGENT_TOOLS.claude.ask.includes(b.name) && agentId) {
|
|
33
35
|
questions.set(b.id, agentId);
|
|
34
36
|
if (questions.size > 128) questions.delete(questions.keys().next().value);
|
|
35
37
|
report({ id: agentId, status: 'asked' });
|
|
36
38
|
}
|
|
37
|
-
if (b.type !== 'tool_use' || !
|
|
39
|
+
if (b.type !== 'tool_use' || !NATIVE_AGENT_TOOLS.claude.spawn.includes(b.name) || !b.id) continue;
|
|
38
40
|
const input = b.input ?? {};
|
|
39
41
|
const id = aliases.get(input.resume) ?? b.id;
|
|
40
42
|
calls.set(b.id, { id, input });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CODEX TAB'S CONTRACT, SENT WHEN IT IS NEWS (2026-10-19).
|
|
3
|
+
*
|
|
4
|
+
* Codex has no system prompt for a resumed `exec`: the tab's contract rode at
|
|
5
|
+
* the head of EVERY turn's message, so each turn added its ~6 KB to the thread
|
|
6
|
+
* again — measured offline on 0.163, the third turn's request carried it three
|
|
7
|
+
* times — and a long tab compacted early, on copies of the same instructions.
|
|
8
|
+
* `developer_instructions` is no way out: a resumed thread keeps the ones it
|
|
9
|
+
* started with and ignores a changed value (measured the same way), and the
|
|
10
|
+
* contract does change (the knowledge and artifact paragraphs).
|
|
11
|
+
*
|
|
12
|
+
* So a resumed thread is sent the contract only when it is news: a thread this
|
|
13
|
+
* marker has not seen, a contract whose text changed, or every
|
|
14
|
+
* `CONTRACT_RESEND_EVERY`th turn — a bound on what a compaction may have
|
|
15
|
+
* summarised away. A fresh thread is always sent it. The marker sits beside
|
|
16
|
+
* the tab's thread marker, scoped by session, owner-only, best-effort: an
|
|
17
|
+
* unreadable one sends the contract, as before.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
export const CODEX_CONTRACT_MARKER = 'flowviant-codex-contract';
|
|
23
|
+
export const CONTRACT_RESEND_EVERY = 8;
|
|
24
|
+
|
|
25
|
+
const digest = (text) => createHash('sha256').update(String(text ?? '')).digest('hex');
|
|
26
|
+
const read = (path) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Does the resumed `thread` already hold this `system` text, recently? */
|
|
35
|
+
export function codexContractKnown(path, thread, system) {
|
|
36
|
+
if (!path || !thread) return false;
|
|
37
|
+
const m = read(path);
|
|
38
|
+
return m?.thread === thread && m.hash === digest(system) && Number.isInteger(m.since) && m.since < CONTRACT_RESEND_EVERY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** After a turn: what `thread` now holds, and how many turns since it was
|
|
42
|
+
* last sent the contract (`sent`: whether this turn carried it). */
|
|
43
|
+
export function recordCodexContract(path, thread, system, sent) {
|
|
44
|
+
if (!path || !thread) return;
|
|
45
|
+
const m = sent ? null : read(path);
|
|
46
|
+
const since = !sent && m?.thread === thread && Number.isInteger(m.since) ? m.since + 1 : 0;
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(path, JSON.stringify({ thread, hash: digest(system), since }), { mode: 0o600 });
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort: the next turn sends the contract */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MCP SERVERS ON A FENCED CODEX TURN, AS `-c` PAIRS — the project's and
|
|
3
|
+
* the machine's own (`codexFencedMount`, below). Split out of
|
|
4
|
+
* projectToolRuntimes.mjs (2026-10-17), whose table row hands it out as
|
|
5
|
+
* `fencedMount`; the build's config.toml stays there.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { codexHome } from './codexHome.mjs';
|
|
10
|
+
import { parseCodexToml, TomlRaw, toml, tomlValue } from './codexToml.mjs';
|
|
11
|
+
|
|
12
|
+
/** The environment names a fenced Codex turn's MCP header values ride under,
|
|
13
|
+
* one per header (`codexFencedMount`). */
|
|
14
|
+
export const MCP_HEADER_ENV_PREFIX = 'FLOWVIANT_MCP_HEADER_';
|
|
15
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
export const SERVER_NAME = /^[A-Za-z0-9_-]{1,80}$/;
|
|
17
|
+
const expands = (value) => /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(String(value));
|
|
18
|
+
/**
|
|
19
|
+
* Names this machine's Codex, or the shell and tools under it, read for
|
|
20
|
+
* themselves. A server's env value rides in the CLI's own environment
|
|
21
|
+
* (`env_vars` forwards it), so a name the machine does not already set to that
|
|
22
|
+
* value would change how the CLI itself runs — a proxy, a CA bundle, a
|
|
23
|
+
* provider key. Such a server is left unmounted, in words.
|
|
24
|
+
*/
|
|
25
|
+
const CLI_ENV =
|
|
26
|
+
/^(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)_/;
|
|
27
|
+
|
|
28
|
+
/** This machine's own Codex servers: its config.toml's `[mcp_servers.*]`,
|
|
29
|
+
* read the way codexPersonal.mjs reads the login (bounded; a file that does
|
|
30
|
+
* not parse carries nothing). `[]` when there is no file, NULL when there is
|
|
31
|
+
* one this cannot read (2026-10-17: unknown, never "none"). */
|
|
32
|
+
function machineCodexServers(machineEnv) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
const path = join(resolve(codexHome(machineEnv)), 'config.toml');
|
|
36
|
+
const st = statSync(path);
|
|
37
|
+
if (!st.isFile() || st.size > 1024 * 1024) return null;
|
|
38
|
+
text = readFileSync(path, 'utf8');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return error?.code === 'ENOENT' ? [] : null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const servers = parseCodexToml(text)?.mcp_servers;
|
|
44
|
+
if (servers === undefined) return [];
|
|
45
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
|
|
46
|
+
return Object.entries(servers).filter(([, v]) => v && typeof v === 'object' && !Array.isArray(v));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const APPROVAL_MODES = new Set(['auto', 'prompt', 'writes', 'approve']);
|
|
52
|
+
const tomlScalar = (v) => (v instanceof TomlRaw ? v.text : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
|
|
53
|
+
const words = (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null);
|
|
54
|
+
const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* THE MCP SERVERS ON A FENCED CODEX TURN — the project's and, since 2026-10-11,
|
|
58
|
+
* the machine's own (the owner: "the server that is running flowviant's claude
|
|
59
|
+
* HAS these MCPs yet the agents are not using them"). A Codex build reads both
|
|
60
|
+
* from the agent's config.toml (`isolatedCodexHome`: the person's own tables
|
|
61
|
+
* kept, the repo's beside them). Every fenced posture — write-up, mockup,
|
|
62
|
+
* 3D model, deck, image — passes `--ignore-user-config`, which drops that
|
|
63
|
+
* file, and a `-p` profile layered beside the flag was measured to load no
|
|
64
|
+
* server either (codex-cli 0.159.2, artifactProjectMcp.test.mjs). So these
|
|
65
|
+
* turns take the servers as `-c` pairs, the way codexPersonal.mjs carries a
|
|
66
|
+
* login back, under the same rule: A LITERAL SECRET NEVER RIDES ARGV.
|
|
67
|
+
*
|
|
68
|
+
* · A stdio server's command and arguments ride as written: they are the
|
|
69
|
+
* server process's own command line once Codex starts it, so `-c` adds no
|
|
70
|
+
* reader. A project server whose command or arguments expand a `${VAR}`
|
|
71
|
+
* is still left unmounted (the 2026-10-10 audit took expanded values off
|
|
72
|
+
* argv, and this keeps that line).
|
|
73
|
+
* · Every env value rides in the turn's ENVIRONMENT under its own name,
|
|
74
|
+
* forwarded by Codex's `env_vars` — except a project value with no `${VAR}`,
|
|
75
|
+
* which is text of the reviewed file and rides as written. Refused (the
|
|
76
|
+
* server is left unmounted, with the reason) when the name is the
|
|
77
|
+
* daemon's own (`FLOWVIANT_*`, `CODEX_HOME`), when this machine already
|
|
78
|
+
* sets it to something else, or when it is one the CLI reads itself
|
|
79
|
+
* (`CLI_ENV`) and the machine does not set it.
|
|
80
|
+
* · Every header value rides in the environment, named by
|
|
81
|
+
* `env_http_headers`. A machine server's own `env_http_headers`,
|
|
82
|
+
* `bearer_token_env_var` and `env_vars` are names, and ride as written.
|
|
83
|
+
* · A URL that expands a `${VAR}`, or carries a query, fragment or
|
|
84
|
+
* credentials, has no indirection in Codex's config: left unmounted.
|
|
85
|
+
* · A machine server keeps its own `cwd`, timeouts, tool lists, header
|
|
86
|
+
* helper (a command, riding as written, as a stdio command does), OAuth
|
|
87
|
+
* scopes, resource and settings — all but `oauth.client_secret`, which
|
|
88
|
+
* stays off argv and is said; it is skipped where the person turned it
|
|
89
|
+
* off (`enabled = false`), and a project server of the same name replaces
|
|
90
|
+
* it, as on the build. The person's own `flowviant` rides too: a card
|
|
91
|
+
* mints no Flowviant server of its own (hooks/mcpAllow.mjs, 2026-10-17).
|
|
92
|
+
* · Each mounted server's tools are approved by name: an unannotated tool
|
|
93
|
+
* under a fenced posture failed "MCP tool call requires approval, but
|
|
94
|
+
* approval policy is never" (measured, 0.159.2) — never a blanket
|
|
95
|
+
* approval policy. A machine server keeps the person's own modes, its
|
|
96
|
+
* default and each tool's (2026-10-17: an unconditional approval
|
|
97
|
+
* overrode a person's "prompt"), and is approved only where they set
|
|
98
|
+
* none; one that asks before every call is left unmounted, and one with
|
|
99
|
+
* tools that ask is mounted and said (`partial`), since nobody can
|
|
100
|
+
* approve on this turn (measured, 0.163.0: a tool's mode beats its
|
|
101
|
+
* server's, and "auto", "writes" and "prompt" each refuse an unannotated
|
|
102
|
+
* tool there).
|
|
103
|
+
*
|
|
104
|
+
* `usable` is the base snapshot's ready entries, `turnServers` the card's
|
|
105
|
+
* own (prepareAgentTools); `expand` reads a value the
|
|
106
|
+
* way projectTools.mjs does; `machineEnv` is the caller's own (what the child
|
|
107
|
+
* would inherit). Returns `{ args, env, mounted, machine, unmounted }`:
|
|
108
|
+
* `mounted` the project servers, `machine` the machine's own (null when its
|
|
109
|
+
* config.toml could not be read), and
|
|
110
|
+
* `unmounted` `[{ name, reason, source: 'project' | 'machine', partial? }]`.
|
|
111
|
+
*/
|
|
112
|
+
export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
|
|
113
|
+
const args = [];
|
|
114
|
+
const childEnv = {};
|
|
115
|
+
const mounted = [];
|
|
116
|
+
const machine = [];
|
|
117
|
+
const unmounted = [];
|
|
118
|
+
let header = 0;
|
|
119
|
+
/** A refusal sentence, or null with the value staged in `extra`. */
|
|
120
|
+
const forward = (key, value, extra) => {
|
|
121
|
+
if (key.startsWith('FLOWVIANT_') || key === 'CODEX_HOME') return `its env ${key} is a name this daemon keeps for itself`;
|
|
122
|
+
const has = machineEnv[key];
|
|
123
|
+
if (has !== undefined) return has === value ? null : `its env ${key} would replace this machine's own ${key}, which a fenced Codex turn cannot do`;
|
|
124
|
+
if (CLI_ENV.test(key)) return `its env ${key} is one this machine's Codex reads itself, which a fenced Codex turn cannot set`;
|
|
125
|
+
const staged = childEnv[key] ?? extra[key];
|
|
126
|
+
if (staged !== undefined && staged !== value) return `its env ${key} would replace another server's ${key}`;
|
|
127
|
+
extra[key] = value;
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
const headerEnv = (headers, extra) =>
|
|
131
|
+
Object.entries(headers).map(([key, value]) => {
|
|
132
|
+
const envName = `${MCP_HEADER_ENV_PREFIX}${header++}`;
|
|
133
|
+
extra[envName] = value;
|
|
134
|
+
return `${toml(key)}=${toml(envName)}`;
|
|
135
|
+
});
|
|
136
|
+
const approve = (name) => ['-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`];
|
|
137
|
+
const accept = (name, pairs, extra, list, approval = approve(name)) => {
|
|
138
|
+
args.push(...pairs, ...approval);
|
|
139
|
+
Object.assign(childEnv, extra);
|
|
140
|
+
list.push(name);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// THE CARD'S OWN SERVERS (2026-10-17) — `card_repositories` on a
|
|
144
|
+
// multi-repository card — which rode only the home's config.toml, ignored
|
|
145
|
+
// here. The daemon's words; their env by name (the values are the turn's
|
|
146
|
+
// environment already), and no project or machine server of their name.
|
|
147
|
+
const owned = Object.entries(turnServers ?? {}).filter(([name, c]) => SERVER_NAME.test(name) && typeof c?.command === 'string');
|
|
148
|
+
for (const [name, config] of owned) {
|
|
149
|
+
const keys = Object.keys(config.env ?? {}).filter((k) => ENV_NAME.test(k));
|
|
150
|
+
const pairs = ['-c', `mcp_servers.${name}.command=${toml(config.command)}`, '-c', `mcp_servers.${name}.args=[${(Array.isArray(config.args) ? config.args : []).map((v) => toml(String(v))).join(',')}]`];
|
|
151
|
+
if (keys.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${keys.map(toml).join(',')}]`);
|
|
152
|
+
accept(name, pairs, Object.fromEntries(keys.map((k) => [k, String(config.env[k])])), []);
|
|
153
|
+
}
|
|
154
|
+
const ownNames = new Set(owned.map(([name]) => name));
|
|
155
|
+
|
|
156
|
+
for (const { name, config } of usable) {
|
|
157
|
+
if (ownNames.has(name)) continue;
|
|
158
|
+
const pairs = [];
|
|
159
|
+
const extra = {};
|
|
160
|
+
let refused = null;
|
|
161
|
+
if (typeof config.command === 'string') {
|
|
162
|
+
const rawArgs = Array.isArray(config.args) ? config.args : [];
|
|
163
|
+
if (expands(config.command) || rawArgs.some(expands)) {
|
|
164
|
+
refused = 'its command or arguments name a ${VAR}, which a fenced Codex turn would put on the command line';
|
|
165
|
+
} else {
|
|
166
|
+
pairs.push('-c', `mcp_servers.${name}.command=${toml(String(config.command))}`);
|
|
167
|
+
pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map((v) => toml(String(v))).join(',')}]`);
|
|
168
|
+
const forwarded = [];
|
|
169
|
+
for (const [key, value] of Object.entries(config.env ?? {})) {
|
|
170
|
+
if (!ENV_NAME.test(key)) continue;
|
|
171
|
+
if (!expands(value)) {
|
|
172
|
+
pairs.push('-c', `mcp_servers.${name}.env.${key}=${toml(String(value))}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
refused = forward(key, expand(value), extra);
|
|
176
|
+
if (refused) break;
|
|
177
|
+
forwarded.push(key);
|
|
178
|
+
}
|
|
179
|
+
if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof config.url === 'string') {
|
|
182
|
+
if (expands(config.url)) {
|
|
183
|
+
refused = 'its URL names a ${VAR}, which a fenced Codex turn would put on the command line';
|
|
184
|
+
} else {
|
|
185
|
+
pairs.push('-c', `mcp_servers.${name}.url=${toml(String(config.url))}`);
|
|
186
|
+
const named = headerEnv(Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)])), extra);
|
|
187
|
+
if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
|
|
188
|
+
}
|
|
189
|
+
} else continue;
|
|
190
|
+
if (refused) unmounted.push({ name, reason: refused, source: 'project' });
|
|
191
|
+
else accept(name, pairs, extra, mounted);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const projectNames = new Set([...usable.map((t) => t.name), ...ownNames]);
|
|
195
|
+
const personal = machineCodexServers(machineEnv);
|
|
196
|
+
for (const [name, server] of personal ?? []) {
|
|
197
|
+
if (projectNames.has(name) || server.enabled === false) continue;
|
|
198
|
+
if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
|
|
199
|
+
const pairs = [];
|
|
200
|
+
const extra = {};
|
|
201
|
+
const partial = [];
|
|
202
|
+
let refused = SERVER_NAME.test(name) ? null : 'its name cannot be spelled as a command-line setting';
|
|
203
|
+
if (!refused && typeof server.command === 'string') {
|
|
204
|
+
const rawArgs = server.args === undefined ? [] : words(server.args);
|
|
205
|
+
if (!rawArgs) refused = 'its args are not a list of words';
|
|
206
|
+
else {
|
|
207
|
+
pairs.push('-c', `mcp_servers.${name}.command=${toml(server.command)}`);
|
|
208
|
+
pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map(toml).join(',')}]`);
|
|
209
|
+
const forwarded = [...(words(server.env_vars) ?? []).filter((k) => ENV_NAME.test(k))];
|
|
210
|
+
for (const [key, value] of Object.entries(table(server.env))) {
|
|
211
|
+
if (!ENV_NAME.test(key) || typeof value !== 'string') {
|
|
212
|
+
refused = `its env ${String(key).slice(0, 40)} is not a name and text`;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
refused = forward(key, value, extra);
|
|
216
|
+
if (refused) break;
|
|
217
|
+
if (!forwarded.includes(key)) forwarded.push(key);
|
|
218
|
+
}
|
|
219
|
+
if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
|
|
220
|
+
if (typeof server.cwd === 'string') pairs.push('-c', `mcp_servers.${name}.cwd=${toml(server.cwd)}`);
|
|
221
|
+
}
|
|
222
|
+
} else if (!refused) {
|
|
223
|
+
let url = null;
|
|
224
|
+
try { url = new URL(server.url); } catch { /* refused below */ }
|
|
225
|
+
if (!url || url.search || url.hash || url.username || url.password) {
|
|
226
|
+
refused = 'its URL carries a query, fragment or credentials, which a fenced Codex turn would put on the command line';
|
|
227
|
+
} else {
|
|
228
|
+
pairs.push('-c', `mcp_servers.${name}.url=${toml(server.url)}`);
|
|
229
|
+
if (typeof server.bearer_token_env_var === 'string' && ENV_NAME.test(server.bearer_token_env_var))
|
|
230
|
+
pairs.push('-c', `mcp_servers.${name}.bearer_token_env_var=${toml(server.bearer_token_env_var)}`);
|
|
231
|
+
const literal = table(server.http_headers);
|
|
232
|
+
const byName = table(server.env_http_headers);
|
|
233
|
+
if (Object.values(literal).some((v) => typeof v !== 'string') || Object.values(byName).some((v) => typeof v !== 'string' || !ENV_NAME.test(v))) {
|
|
234
|
+
refused = 'its headers are not text';
|
|
235
|
+
} else {
|
|
236
|
+
const named = [...headerEnv(literal, extra), ...Object.entries(byName).map(([key, envName]) => `${toml(key)}=${toml(envName)}`)];
|
|
237
|
+
if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
|
|
238
|
+
if (typeof server.http_headers_helper === 'string') pairs.push('-c', `mcp_servers.${name}.http_headers_helper=${toml(server.http_headers_helper)}`);
|
|
239
|
+
if (words(server.scopes)) pairs.push('-c', `mcp_servers.${name}.scopes=[${server.scopes.map(toml).join(',')}]`);
|
|
240
|
+
if (typeof server.oauth_resource === 'string') pairs.push('-c', `mcp_servers.${name}.oauth_resource=${toml(server.oauth_resource)}`);
|
|
241
|
+
const oauth = Object.entries(table(server.oauth)).filter(([key, v]) => key !== 'client_secret' && (typeof v === 'string' || typeof v === 'boolean' || tomlScalar(v) != null));
|
|
242
|
+
if (oauth.length) pairs.push('-c', `mcp_servers.${name}.oauth=${tomlValue(Object.fromEntries(oauth))}`);
|
|
243
|
+
if (Object.hasOwn(table(server.oauth), 'client_secret')) partial.push('its OAuth client secret stays off this turn’s command line, so a sign-in or refresh that needs it fails');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// The person's approval modes, as written; approved where they set none.
|
|
248
|
+
const mode = APPROVAL_MODES.has(server.default_tools_approval_mode) ? server.default_tools_approval_mode : null;
|
|
249
|
+
const tools = Object.entries(table(server.tools)).filter(([, v]) => APPROVAL_MODES.has(table(v).approval_mode));
|
|
250
|
+
const approval = ['-c', `mcp_servers.${name}.default_tools_approval_mode=${toml(mode ?? 'approve')}`];
|
|
251
|
+
if (tools.length) approval.push('-c', `mcp_servers.${name}.tools=${tomlValue(Object.fromEntries(tools.map(([tool, v]) => [tool, { approval_mode: v.approval_mode }])))}`);
|
|
252
|
+
if (!refused && mode === 'prompt' && !tools.some(([, v]) => v.approval_mode === 'approve')) {
|
|
253
|
+
refused = 'it asks for approval before every tool call (default_tools_approval_mode = "prompt"), and nobody can approve one on this turn';
|
|
254
|
+
}
|
|
255
|
+
const asking = tools.filter(([, v]) => v.approval_mode !== 'approve').map(([tool]) => tool);
|
|
256
|
+
if (mode && mode !== 'approve') partial.unshift(`its tools ask for approval (default_tools_approval_mode = "${mode}"), and a call that asks fails here, where nobody can approve one`);
|
|
257
|
+
else if (asking.length) partial.unshift(`its tools ${asking.join(', ')} ask for approval, and a call to one fails here, where nobody can approve one`);
|
|
258
|
+
if (!refused) {
|
|
259
|
+
for (const key of ['startup_timeout_sec', 'startup_timeout_ms', 'tool_timeout_sec']) {
|
|
260
|
+
const v = tomlScalar(server[key]);
|
|
261
|
+
if (v != null && /^[0-9_.]+$/.test(v)) pairs.push('-c', `mcp_servers.${name}.${key}=${v}`);
|
|
262
|
+
}
|
|
263
|
+
for (const key of ['enabled_tools', 'disabled_tools']) {
|
|
264
|
+
const list = words(server[key]);
|
|
265
|
+
if (list) pairs.push('-c', `mcp_servers.${name}.${key}=[${list.map(toml).join(',')}]`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (refused) unmounted.push({ name, reason: refused, source: 'machine' });
|
|
269
|
+
else {
|
|
270
|
+
accept(name, pairs, extra, machine, approval);
|
|
271
|
+
if (partial.length) unmounted.push({ name, reason: partial.join('; '), source: 'machine', partial: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { args, env: childEnv, mounted, machine: personal == null ? null : machine, unmounted };
|
|
275
|
+
}
|