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.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. 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
+ }
@@ -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
- * `)`, so the fields are read after the LAST one rather than by splitting the
73
- * whole line. A process named `foo) bar` is not hypothetical; it is what
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
- let stat;
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 `[]`, which means
178
- * "looked, found none" and is a different fact a surface renders differently.
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 { homedir } from 'node:os';
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.CODEX_HOME || join(homedir(), '.codex'));
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
- // These sections alone grant a checkout permission to supply project
68
- // settings. Keep every other personal setting, skill and session store.
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 inProject = false;
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
- inProject = section === 'projects' || section.startsWith('projects.');
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 !inProject;
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
- const linked = new Set(personal);
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
- * kept)` returns the runner-specific part of prepareAgentTools' result — under
143
- * `dir`, which dies with the turn, except what `kept` names a lasting place for.
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, kept) => {
160
- const isolated = isolatedCodexHome(kept?.codexHome ?? join(dir, 'codex-home'), env, worktree);
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 what
193
- * `kept` names, where the caller keeps it between turns. */
194
- export function prepareForRunner(runner, dir, snapshot, env, worktree, kept = {}) {
195
- return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, kept) ?? {}) };
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
  }