flowviant 0.125.5 → 0.125.6

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 (86) hide show
  1. package/bin/lib/agentInternalWait.mjs +1 -5
  2. package/bin/lib/agentPlan.mjs +13 -163
  3. package/bin/lib/agentRepositories.mjs +3 -1
  4. package/bin/lib/claudeInput.mjs +28 -9
  5. package/bin/lib/claudeMachineMcp.mjs +85 -0
  6. package/bin/lib/claudePersonal.mjs +6 -57
  7. package/bin/lib/claudePosture.mjs +6 -15
  8. package/bin/lib/claudeSettingsArg.mjs +67 -0
  9. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  10. package/bin/lib/claudeSubagents.mjs +4 -2
  11. package/bin/lib/codexFencedMount.mjs +275 -0
  12. package/bin/lib/codexPersonal.mjs +1 -265
  13. package/bin/lib/codexProjectRead.mjs +5 -4
  14. package/bin/lib/codexResearch.mjs +9 -3
  15. package/bin/lib/codexSubagentEvents.mjs +11 -7
  16. package/bin/lib/codexSubagents.mjs +55 -52
  17. package/bin/lib/codexToml.mjs +328 -0
  18. package/bin/lib/conversationIds.mjs +10 -0
  19. package/bin/lib/fleet.mjs +2 -2
  20. package/bin/lib/git.mjs +32 -0
  21. package/bin/lib/heldTool.mjs +49 -0
  22. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  23. package/bin/lib/hooks/orchestrator.mjs +20 -10
  24. package/bin/lib/intakeDraft.mjs +1 -1
  25. package/bin/lib/jsonlTail.mjs +52 -0
  26. package/bin/lib/mcpInventory.mjs +9 -5
  27. package/bin/lib/mcpReasons.mjs +20 -0
  28. package/bin/lib/mcpReportBounds.mjs +10 -0
  29. package/bin/lib/mcpStdio.mjs +35 -0
  30. package/bin/lib/mergeBatch.mjs +6 -24
  31. package/bin/lib/mergeOrder.mjs +18 -0
  32. package/bin/lib/modelJson.mjs +173 -0
  33. package/bin/lib/nativeAgentTools.mjs +72 -0
  34. package/bin/lib/orchestrator.mjs +212 -133
  35. package/bin/lib/orchestratorClient.mjs +17 -21
  36. package/bin/lib/orchestratorHooks.mjs +21 -4
  37. package/bin/lib/orchestratorNative.mjs +58 -0
  38. package/bin/lib/orchestratorPrompt.mjs +7 -4
  39. package/bin/lib/orchestratorProtocol.mjs +25 -3
  40. package/bin/lib/postureTools.mjs +58 -13
  41. package/bin/lib/prWorkflow.mjs +13 -2
  42. package/bin/lib/projectRepositories.mjs +2 -2
  43. package/bin/lib/projectToolRuntimes.mjs +92 -245
  44. package/bin/lib/projectTools.mjs +59 -19
  45. package/bin/lib/repositoryDelivery.mjs +6 -4
  46. package/bin/lib/repositoryMerge.mjs +8 -12
  47. package/bin/lib/repositoryPlaces.mjs +2 -2
  48. package/bin/lib/repositoryPrompts.mjs +42 -0
  49. package/bin/lib/repositoryTool.mjs +9 -32
  50. package/bin/lib/researchUse.mjs +39 -0
  51. package/bin/lib/resumeMarker.mjs +43 -0
  52. package/bin/lib/runTurn.mjs +34 -52
  53. package/bin/lib/runtimeAntigravity.mjs +1 -1
  54. package/bin/lib/runtimeCapabilities.mjs +6 -16
  55. package/bin/lib/runtimeClaude.mjs +47 -20
  56. package/bin/lib/runtimeCodex.mjs +9 -6
  57. package/bin/lib/runtimeEvents.mjs +13 -2
  58. package/bin/lib/runtimeOfferings.mjs +9 -4
  59. package/bin/lib/runtimes.mjs +3 -1
  60. package/bin/lib/subagentBounds.mjs +20 -0
  61. package/bin/lib/trace.mjs +30 -5
  62. package/bin/lib/turnDeclaration.mjs +1 -1
  63. package/bin/lib/turnDiagnostics.mjs +9 -12
  64. package/bin/lib/turnReceipts.mjs +64 -0
  65. package/bin/lib/turnSubagents.mjs +91 -0
  66. package/bin/lib/turnUsage.mjs +9 -6
  67. package/bin/lib/versionOrder.mjs +11 -1
  68. package/bin/lib/work.mjs +6 -6
  69. package/bin/lib/workAgentMerges.mjs +5 -7
  70. package/bin/lib/workAgentPrecheck.mjs +5 -5
  71. package/bin/lib/workAgentRecovery.mjs +2 -1
  72. package/bin/lib/workAgentResume.mjs +121 -0
  73. package/bin/lib/workAgentTools.mjs +111 -0
  74. package/bin/lib/workAgentTurnExecution.mjs +62 -246
  75. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  76. package/bin/lib/workAgentTurnShape.mjs +90 -0
  77. package/bin/lib/workAgentTurns.mjs +24 -10
  78. package/bin/lib/workArtifactReconcile.mjs +3 -2
  79. package/bin/lib/workMergeBatch.mjs +7 -5
  80. package/bin/lib/workPlaceReports.mjs +3 -3
  81. package/bin/lib/workPlaces.mjs +14 -0
  82. package/bin/lib/workSessionRuntime.mjs +2 -5
  83. package/bin/lib/workSessionTurns.mjs +12 -63
  84. package/bin/lib/workShip.mjs +8 -18
  85. package/bin/lib/workTurnShape.mjs +6 -4
  86. package/package.json +1 -1
@@ -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
- let file, offset = null, rest = Buffer.alloc(0), skip = false, timer;
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
- const { size } = await fh.stat();
22
- if (offset === null) { offset = Math.max(0, size - MAX_READ); skip = offset > 0; }
23
- if (size < offset) { offset = 0; rest = Buffer.alloc(0); }
24
- const end = Math.min(size, offset + MAX_READ);
25
- while (offset < end) {
26
- const buf = Buffer.alloc(Math.min(LINE_MAX, end - offset));
27
- const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
28
- if (!bytesRead) break;
29
- offset += bytesRead;
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 === 'AskUserQuestion' && agentId) {
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' || !['Agent', 'Task'].includes(b.name) || !b.id) continue;
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,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
+ }
@@ -56,7 +56,7 @@
56
56
  */
57
57
  import { readFileSync, statSync } from 'node:fs';
58
58
  import { join } from 'node:path';
59
- import { toml } from './projectToolRuntimes.mjs';
59
+ import { isTable, newTable, parseCodexToml, tomlValue } from './codexToml.mjs';
60
60
  import { codexHome } from './codexHome.mjs';
61
61
 
62
62
  /** Where the login lives and which endpoint it signs into — top-level keys,
@@ -104,259 +104,6 @@ export const CODEX_HEADER_ENV_PREFIX = 'FLOWVIANT_CODEX_SECRET_HEADER_';
104
104
 
105
105
  /** Far past any real config.toml; a bigger one is not read. */
106
106
  const CONFIG_MAX_BYTES = 1024 * 1024;
107
- /** Arrays and inline tables nest no deeper than this in a file we read. */
108
- const MAX_DEPTH = 32;
109
-
110
- /** A number, date or time, kept as written so it is carried as written. */
111
- export class TomlRaw {
112
- constructor(text) {
113
- this.text = text;
114
- }
115
- }
116
- const newTable = () => Object.create(null);
117
- const isTable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof TomlRaw);
118
-
119
- const BARE = /[A-Za-z0-9_-]+/y;
120
- // First match wins, so the forms that begin like an integer come first.
121
- const SCALAR =
122
- /[+-]?(?:inf|nan)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?|[+-]?\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/y;
123
-
124
- /**
125
- * A BOUNDED TOML READER — the repo carries no TOML library, and the one file
126
- * it reads is Codex's own config. Whole documents: tables, arrays of tables
127
- * (the daemon's own `[[skills.config]]` in an agent's home), dotted and quoted
128
- * keys, the four string forms, arrays, inline tables, booleans, and numbers,
129
- * dates and times kept as written. Anything else throws, and the caller
130
- * carries nothing — a partial read is never a license to guess.
131
- */
132
- export function parseCodexToml(text) {
133
- const src = String(text).replace(/^/, '');
134
- const n = src.length;
135
- let i = 0;
136
- const fail = (why) => {
137
- throw new Error(`config.toml: ${why} at offset ${i}`);
138
- };
139
- const ws = () => {
140
- while (i < n && (src[i] === ' ' || src[i] === '\t')) i++;
141
- };
142
- const comment = () => {
143
- if (src[i] === '#') while (i < n && src[i] !== '\n') i++;
144
- };
145
- const newline = () => {
146
- if (src[i] === '\n') return (i += 1), true;
147
- if (src[i] === '\r' && src[i + 1] === '\n') return (i += 2), true;
148
- return false;
149
- };
150
- /** Blank lines, comments and whitespace, as between array items. */
151
- const gap = () => {
152
- for (;;) {
153
- ws();
154
- comment();
155
- if (!newline()) return;
156
- }
157
- };
158
- const endOfLine = () => {
159
- ws();
160
- comment();
161
- if (i < n && !newline()) fail('expected the end of the line');
162
- };
163
- const hexChar = (len) => {
164
- const h = src.slice(i, i + len);
165
- if (h.length !== len || !/^[0-9A-Fa-f]+$/.test(h)) fail('bad escape');
166
- i += len;
167
- const cp = parseInt(h, 16);
168
- if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail('bad escape');
169
- return String.fromCodePoint(cp);
170
- };
171
- const escape = () => {
172
- const e = src[i + 1];
173
- i += 2;
174
- switch (e) {
175
- case 'b': return '\b';
176
- case 't': return '\t';
177
- case 'n': return '\n';
178
- case 'f': return '\f';
179
- case 'r': return '\r';
180
- case 'e': return '\x1b';
181
- case '"': return '"';
182
- case '\\': return '\\';
183
- case 'x': return hexChar(2);
184
- case 'u': return hexChar(4);
185
- case 'U': return hexChar(8);
186
- default: return fail('bad escape');
187
- }
188
- };
189
- const basicString = () => {
190
- i += 1;
191
- let s = '';
192
- for (;;) {
193
- if (i >= n) fail('unterminated string');
194
- const ch = src[i];
195
- if (ch === '"') return (i += 1), s;
196
- if (ch === '\n' || ch === '\r') fail('newline in a string');
197
- if (ch === '\\') s += escape();
198
- else (s += ch), (i += 1);
199
- }
200
- };
201
- const literalString = () => {
202
- const end = src.indexOf("'", i + 1);
203
- if (end < 0) fail('unterminated string');
204
- const s = src.slice(i + 1, end);
205
- if (/[\r\n]/.test(s)) fail('newline in a string');
206
- i = end + 1;
207
- return s;
208
- };
209
- const LINE_END_BACKSLASH = /\\[ \t]*\r?\n/y;
210
- const multiline = (quote, escapes) => {
211
- i += 3;
212
- newline(); // a newline straight after the opening quotes is not content
213
- let s = '';
214
- for (;;) {
215
- if (i >= n) fail('unterminated string');
216
- if (src.startsWith(quote.repeat(3), i)) {
217
- let q = 3;
218
- while (q < 5 && src[i + q] === quote) q++; // up to two belong to the content
219
- s += quote.repeat(q - 3);
220
- i += q;
221
- return s;
222
- }
223
- if (escapes && src[i] === '\\') {
224
- LINE_END_BACKSLASH.lastIndex = i;
225
- if (LINE_END_BACKSLASH.test(src)) {
226
- i = LINE_END_BACKSLASH.lastIndex;
227
- while (i < n && /[ \t\r\n]/.test(src[i])) i++;
228
- } else s += escape();
229
- } else (s += src[i]), (i += 1);
230
- }
231
- };
232
- const key = () => {
233
- const parts = [];
234
- for (;;) {
235
- ws();
236
- if (src[i] === '"') {
237
- if (src.startsWith('"""', i)) fail('a multi-line key');
238
- parts.push(basicString());
239
- } else if (src[i] === "'") {
240
- if (src.startsWith("'''", i)) fail('a multi-line key');
241
- parts.push(literalString());
242
- } else {
243
- BARE.lastIndex = i;
244
- const m = BARE.exec(src);
245
- if (!m) fail('expected a key');
246
- parts.push(m[0]);
247
- i = BARE.lastIndex;
248
- }
249
- ws();
250
- if (src[i] !== '.') return parts;
251
- i += 1;
252
- }
253
- };
254
- const assign = (target, path, v) => {
255
- let t = target;
256
- for (const k of path.slice(0, -1)) {
257
- if (!Object.hasOwn(t, k)) t[k] = newTable();
258
- else if (!isTable(t[k])) fail(`"${k}" is not a table`);
259
- t = t[k];
260
- }
261
- const last = path.at(-1);
262
- if (Object.hasOwn(t, last)) fail(`"${last}" is defined twice`);
263
- t[last] = v;
264
- };
265
- let depth = 0;
266
- const nested = (open) => {
267
- if (++depth > MAX_DEPTH) fail('nested too deep');
268
- try {
269
- return open();
270
- } finally {
271
- depth -= 1;
272
- }
273
- };
274
- const array = () => {
275
- i += 1;
276
- const out = [];
277
- for (;;) {
278
- gap();
279
- if (src[i] === ']') return (i += 1), out;
280
- out.push(value());
281
- gap();
282
- if (src[i] === ',') i += 1;
283
- else if (src[i] === ']') return (i += 1), out;
284
- else fail('expected , or ]');
285
- }
286
- };
287
- const inlineTable = () => {
288
- i += 1;
289
- const t = newTable();
290
- for (;;) {
291
- gap();
292
- if (src[i] === '}') return (i += 1), t;
293
- keyValue(t);
294
- gap();
295
- if (src[i] === ',') i += 1;
296
- else if (src[i] === '}') return (i += 1), t;
297
- else fail('expected , or }');
298
- }
299
- };
300
- const word = (w) => src.startsWith(w, i) && !/[A-Za-z0-9_-]/.test(src[i + w.length] ?? '');
301
- const value = () => {
302
- const ch = src[i];
303
- if (ch === '"') return src.startsWith('"""', i) ? multiline('"', true) : basicString();
304
- if (ch === "'") return src.startsWith("'''", i) ? multiline("'", false) : literalString();
305
- if (ch === '[') return nested(array);
306
- if (ch === '{') return nested(inlineTable);
307
- if (word('true')) return (i += 4), true;
308
- if (word('false')) return (i += 5), false;
309
- SCALAR.lastIndex = i;
310
- const m = SCALAR.exec(src);
311
- if (!m) fail('a value this reader does not know');
312
- i = SCALAR.lastIndex;
313
- if (/[A-Za-z0-9_.:+-]/.test(src[i] ?? '')) fail('a value this reader does not know');
314
- return new TomlRaw(m[0]);
315
- };
316
- const keyValue = (target) => {
317
- const path = key();
318
- if (src[i] !== '=') fail('expected =');
319
- i += 1;
320
- ws();
321
- assign(target, path, value());
322
- };
323
-
324
- const root = newTable();
325
- let current = root;
326
- for (;;) {
327
- gap();
328
- if (i >= n) return root;
329
- if (src[i] !== '[') {
330
- keyValue(current);
331
- endOfLine();
332
- continue;
333
- }
334
- const many = src[i + 1] === '[';
335
- i += many ? 2 : 1;
336
- const path = key();
337
- if (many ? !src.startsWith(']]', i) : src[i] !== ']') fail('an unclosed table header');
338
- i += many ? 2 : 1;
339
- endOfLine();
340
- let t = root;
341
- for (const k of path.slice(0, -1)) {
342
- if (!Object.hasOwn(t, k)) t[k] = newTable();
343
- const v = Array.isArray(t[k]) ? t[k].at(-1) : t[k];
344
- if (!isTable(v)) fail(`"${k}" is not a table`);
345
- t = v;
346
- }
347
- const last = path.at(-1);
348
- if (many) {
349
- if (!Object.hasOwn(t, last)) t[last] = [];
350
- if (!Array.isArray(t[last])) fail(`"${last}" is not an array of tables`);
351
- current = newTable();
352
- t[last].push(current);
353
- } else {
354
- if (!Object.hasOwn(t, last)) t[last] = newTable();
355
- else if (!isTable(t[last])) fail(`"${last}" is not a table`);
356
- current = t[last];
357
- }
358
- }
359
- }
360
107
 
361
108
  /**
362
109
  * The person's config.toml, where the turn's Codex would look —
@@ -374,17 +121,6 @@ export function readPersonalCodexConfig(env = process.env) {
374
121
  }
375
122
  }
376
123
 
377
- /** One value as TOML, as a `-c` override's value is parsed. */
378
- const bareKeyOr = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : toml(k));
379
- export function tomlValue(v) {
380
- if (typeof v === 'string') return toml(v);
381
- if (typeof v === 'boolean') return String(v);
382
- if (v instanceof TomlRaw) return v.text;
383
- if (Array.isArray(v)) return `[${v.map(tomlValue).join(',')}]`;
384
- if (isTable(v)) return `{${Object.keys(v).map((k) => `${bareKeyOr(k)}=${tomlValue(v[k])}`).join(',')}}`;
385
- throw new Error('not a TOML value');
386
- }
387
-
388
124
  const isString = (v) => typeof v === 'string';
389
125
  const isStringList = (v) => Array.isArray(v) && v.every(isString);
390
126
  const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
@@ -6,16 +6,17 @@ import { execFileSync } from 'node:child_process';
6
6
  import { lstatSync, readdirSync, realpathSync } from 'node:fs';
7
7
  import { isAbsolute, join, dirname } from 'node:path';
8
8
  import { git } from './git.mjs';
9
- import { codexResearchHelper } from './codexResearch.mjs';
9
+ import { CODEX_HELPER_MIN, codexResearchHelper } from './codexResearch.mjs';
10
+ import { versionAtLeast } from './versionOrder.mjs';
10
11
  import { unattendedEnv } from './noWindowEnv.mjs';
11
12
 
12
13
  export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
13
14
  if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
14
15
  if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
15
16
  const helper = codexResearchHelper(env);
16
- const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
17
- if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
18
- throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
17
+ const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+\.\d+\.\d+)/);
18
+ if (!version || !versionAtLeast(version[1], CODEX_HELPER_MIN))
19
+ throw new Error(`Project-scoped reads need Codex CLI ${CODEX_HELPER_MIN} or newer. Update Codex before retrying.`);
19
20
  const project = realpathSync(cwd);
20
21
  // Resolve both: linked worktrees have their own HEAD/index and shared
21
22
  // objects/refs in the common directory. Never grant the parent checkout.