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.
- 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 +28 -9
- 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/claudeSubagentProgress.mjs +12 -28
- package/bin/lib/claudeSubagents.mjs +4 -2
- 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/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 +9 -5
- package/bin/lib/mcpReasons.mjs +20 -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 +212 -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/projectRepositories.mjs +2 -2
- package/bin/lib/projectToolRuntimes.mjs +92 -245
- package/bin/lib/projectTools.mjs +59 -19
- 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/resumeMarker.mjs +43 -0
- package/bin/lib/runTurn.mjs +34 -52
- package/bin/lib/runtimeAntigravity.mjs +1 -1
- package/bin/lib/runtimeCapabilities.mjs +6 -16
- package/bin/lib/runtimeClaude.mjs +47 -20
- package/bin/lib/runtimeCodex.mjs +9 -6
- package/bin/lib/runtimeEvents.mjs +13 -2
- package/bin/lib/runtimeOfferings.mjs +9 -4
- package/bin/lib/runtimes.mjs +3 -1
- 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/versionOrder.mjs +11 -1
- package/bin/lib/work.mjs +6 -6
- 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 +62 -246
- package/bin/lib/workAgentTurnOutcome.mjs +30 -3
- package/bin/lib/workAgentTurnShape.mjs +90 -0
- package/bin/lib/workAgentTurns.mjs +24 -10
- package/bin/lib/workArtifactReconcile.mjs +3 -2
- package/bin/lib/workMergeBatch.mjs +7 -5
- package/bin/lib/workPlaceReports.mjs +3 -3
- package/bin/lib/workPlaces.mjs +14 -0
- package/bin/lib/workSessionRuntime.mjs +2 -5
- package/bin/lib/workSessionTurns.mjs +12 -63
- package/bin/lib/workShip.mjs +8 -18
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +1 -1
|
@@ -15,12 +15,13 @@
|
|
|
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,
|
|
18
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { join, resolve } from 'node:path';
|
|
20
20
|
import { codexHome } from './codexHome.mjs';
|
|
21
|
-
import { parseCodexToml,
|
|
21
|
+
import { bareKey, headerKeys, lineKeys, parseCodexToml, toml, tomlValue } from './codexToml.mjs';
|
|
22
|
+
import { SERVER_NAME, codexFencedMount } from './codexFencedMount.mjs';
|
|
22
23
|
|
|
23
|
-
export
|
|
24
|
+
export { MCP_HEADER_ENV_PREFIX } from './codexFencedMount.mjs';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* ONE LINK PER PERSONAL ENTRY, MADE OR MENDED IN PLACE (2026-09-29). A home
|
|
@@ -44,31 +45,6 @@ function linkPersonalEntry(target, link) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
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
48
|
/**
|
|
73
49
|
* THE REPO'S MCP SERVERS, AS THE AGENT'S OWN CONFIG (2026-10-10, the audit).
|
|
74
50
|
* A Codex build turn was handed the base branch's servers as `-c` pairs —
|
|
@@ -79,7 +55,7 @@ const lineKey = (line) => {
|
|
|
79
55
|
* ARGV"). They are written here instead, as tables of the agent's private
|
|
80
56
|
* CODEX_HOME config.toml (0600, in a 0700 home), which a build turn reads and
|
|
81
57
|
* every fenced posture ignores (`--ignore-user-config`) — the same turns the
|
|
82
|
-
* `-c` pairs reached. Written in the shape
|
|
58
|
+
* `-c` pairs reached. Written in the shape codexToml.mjs's bounded reader
|
|
83
59
|
* parses, since a fenced turn reads this file for its login.
|
|
84
60
|
*
|
|
85
61
|
* SO THE EXPANDED VALUES NOW SIT ON DISK, AND THAT IS STATED (2026-10-10). The
|
|
@@ -92,17 +68,25 @@ const lineKey = (line) => {
|
|
|
92
68
|
* next turn; a home with no git dir to hold it dies with its turn. NOT DONE:
|
|
93
69
|
* a URL that would carry an expanded secret is written as any other — refusing
|
|
94
70
|
* it ('missing') is an open follow-up from the same audit finding (F660).
|
|
71
|
+
*
|
|
72
|
+
* EACH IS APPROVED BY NAME (2026-10-17). These are the daemon's own servers
|
|
73
|
+
* and the reviewed base copy, and a card turn runs `approval_policy="never"`:
|
|
74
|
+
* under SAFE's workspace-write every unannotated tool failed "MCP tool call
|
|
75
|
+
* requires approval, but approval policy is never" (measured, codex-cli
|
|
76
|
+
* 0.163.0 — route_message among them), as the fenced mount had already found.
|
|
77
|
+
* The person's own tables keep their own modes.
|
|
95
78
|
*/
|
|
96
79
|
function codexServerTables(servers) {
|
|
97
80
|
let out = '';
|
|
98
81
|
for (const [name, server] of Object.entries(servers ?? {})) {
|
|
99
82
|
out += `\n[mcp_servers.${name}]\n`;
|
|
83
|
+
const approve = 'default_tools_approval_mode = "approve"\n';
|
|
100
84
|
if (typeof server.command === 'string') {
|
|
101
|
-
out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n`;
|
|
85
|
+
out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n${approve}`;
|
|
102
86
|
const env = Object.entries(server.env ?? {}).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
|
|
103
87
|
if (env.length) out += `[mcp_servers.${name}.env]\n${env.map(([key, value]) => `${key} = ${toml(value)}`).join('\n')}\n`;
|
|
104
88
|
} else {
|
|
105
|
-
out += `url = ${toml(server.url)}\n`;
|
|
89
|
+
out += `url = ${toml(server.url)}\n${approve}`;
|
|
106
90
|
const headers = Object.entries(server.headers ?? {});
|
|
107
91
|
if (headers.length) out += `[mcp_servers.${name}.http_headers]\n${headers.map(([key, value]) => `${toml(key)} = ${toml(value)}`).join('\n')}\n`;
|
|
108
92
|
}
|
|
@@ -132,32 +116,53 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
|
132
116
|
const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
|
|
133
117
|
const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
|
|
134
118
|
// The repo's servers replace a personal server of the same name whole —
|
|
135
|
-
// its `[mcp_servers.<n>…]` tables are dropped below.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
119
|
+
// its `[mcp_servers.<n>…]` tables are dropped below.
|
|
120
|
+
//
|
|
121
|
+
// A PERSONAL SERVER WRITTEN OUTSIDE A TABLE (2026-10-17). Dotted keys
|
|
122
|
+
// (`mcp_servers.roblox.command = …`, at the top or under `[mcp_servers]`)
|
|
123
|
+
// stand beside an appended table (measured, codex-cli 0.163.0), so they are
|
|
124
|
+
// kept, and refused only when they name one of these servers: a dotted
|
|
125
|
+
// definition cannot be dropped whole line by line. The one-line inline
|
|
126
|
+
// table (`mcp_servers = { … }`) does NOT stand beside one ("failed to load
|
|
127
|
+
// bootstrap configuration", measured), so it is read and rewritten as one
|
|
128
|
+
// dotted line per server, without the names these servers replace; one this
|
|
129
|
+
// reader cannot take alone fails closed, as an inline `skills.config` does
|
|
130
|
+
// below. Names compare unquoted, however the person quoted them.
|
|
138
131
|
const repoServers = new Set(Object.keys(mcpServers ?? {}));
|
|
139
132
|
const inline = () => new Error('Cannot isolate Codex project MCP servers beside an inline mcp_servers entry in config.toml');
|
|
140
|
-
|
|
133
|
+
const dotted = (name) => new Error(`Cannot isolate Codex MCP server ${name}: config.toml defines one of that name outside its own table, which cannot be replaced line by line — write it as a [mcp_servers.${name}] table`);
|
|
141
134
|
// The `projects` sections alone grant a checkout permission to supply
|
|
142
135
|
// project settings. Keep every other personal setting, skill and session
|
|
143
136
|
// store.
|
|
144
137
|
const lines = sourceConfig.split('\n');
|
|
145
138
|
let dropping = false;
|
|
146
139
|
let inServers = false;
|
|
147
|
-
let
|
|
140
|
+
let top = true;
|
|
141
|
+
let config = lines.flatMap((line) => {
|
|
148
142
|
const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
|
|
149
143
|
if (header) {
|
|
150
144
|
const section = header[1] ?? header[2];
|
|
151
145
|
const keys = headerKeys(section);
|
|
146
|
+
top = false;
|
|
152
147
|
inServers = keys?.length === 1 && keys[0] === 'mcp_servers';
|
|
153
148
|
dropping =
|
|
154
149
|
section === 'projects' ||
|
|
155
150
|
section.startsWith('projects.') ||
|
|
156
151
|
(keys?.[0] === 'mcp_servers' && keys.length > 1 && repoServers.has(keys[1]));
|
|
157
|
-
} else if (
|
|
158
|
-
|
|
152
|
+
} else if (repoServers.size && (top || inServers)) {
|
|
153
|
+
const keys = lineKeys(line);
|
|
154
|
+
const name = top ? (keys?.[0] === 'mcp_servers' ? keys[1] : undefined) : keys?.[0];
|
|
155
|
+
if (top && !keys && /^\s*["']?mcp_servers["']?\s*[.=]/.test(line)) throw inline();
|
|
156
|
+
if (name !== undefined && repoServers.has(name)) throw dotted(name);
|
|
157
|
+
if (top && keys?.length === 1 && keys[0] === 'mcp_servers') {
|
|
158
|
+
let servers;
|
|
159
|
+
try { servers = parseCodexToml(line).mcp_servers; } catch { throw inline(); }
|
|
160
|
+
return Object.entries(servers)
|
|
161
|
+
.filter(([key]) => !repoServers.has(key))
|
|
162
|
+
.map(([key, value]) => `mcp_servers.${bareKey(key)} = ${tomlValue(value)}`);
|
|
163
|
+
}
|
|
159
164
|
}
|
|
160
|
-
return
|
|
165
|
+
return dropping ? [] : [line];
|
|
161
166
|
}).join('\n');
|
|
162
167
|
if (worktree) {
|
|
163
168
|
// Codex discovers these even in an untrusted project. Disable exactly the
|
|
@@ -176,6 +181,8 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
|
176
181
|
for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
|
|
177
182
|
}
|
|
178
183
|
config += codexServerTables(mcpServers);
|
|
184
|
+
const approvals = personalApprovals(sourceConfig, repoServers);
|
|
185
|
+
const machineServers = personalServerNames(sourceConfig, repoServers);
|
|
179
186
|
|
|
180
187
|
// The home itself, only once the config is decided: a refused turn touches
|
|
181
188
|
// nothing here. A home that is not a real directory is not ours to write
|
|
@@ -208,213 +215,51 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
|
208
215
|
} finally {
|
|
209
216
|
rmSync(staged, { force: true });
|
|
210
217
|
}
|
|
211
|
-
return { path: dest, personalDeveloperInstructions };
|
|
218
|
+
return { path: dest, personalDeveloperInstructions, approvals, machineServers };
|
|
212
219
|
}
|
|
213
220
|
|
|
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
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
222
|
+
* THE PERSON'S OWN SERVERS, APPROVED WHERE THEY SET NO MODE (2026-10-17, the
|
|
223
|
+
* review). A card turn runs `approval_policy="never"`, and under SAFE's
|
|
224
|
+
* workspace-write a machine server's unannotated tool failed "MCP tool call
|
|
225
|
+
* requires approval, but approval policy is never" (measured, codex-cli
|
|
226
|
+
* 0.163.0) while the same card on Claude reaches it through the allow hook
|
|
227
|
+
* (hooks/mcpAllow.mjs) and a fenced Codex card through its own mount — the
|
|
228
|
+
* owner: board agents use the machine's MCP servers, fenced cards included.
|
|
229
|
+
* As `-c` pairs, not lines of the kept file: a server written as an inline
|
|
230
|
+
* table takes no key after it, and a `-c` pair reaches it however it is
|
|
231
|
+
* written (measured). A mode the person set is theirs and stays; a file this
|
|
232
|
+
* reader cannot parse approves nothing.
|
|
226
233
|
*/
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
}
|
|
234
|
+
function personalApprovals(sourceConfig, repoServers) {
|
|
235
|
+
let servers;
|
|
236
|
+
try { servers = parseCodexToml(sourceConfig)?.mcp_servers; } catch { return []; }
|
|
237
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
|
|
238
|
+
return Object.entries(servers)
|
|
239
|
+
.filter(([name, v]) => !repoServers.has(name) && SERVER_NAME.test(name) && v && typeof v === 'object' && !Array.isArray(v)
|
|
240
|
+
&& (typeof v.command === 'string' || typeof v.url === 'string') && v.default_tools_approval_mode === undefined)
|
|
241
|
+
.flatMap(([name]) => ['-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`]);
|
|
244
242
|
}
|
|
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
243
|
|
|
249
244
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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' }]`.
|
|
245
|
+
* …AND THEIR NAMES, AS A BUILD CARD IS TOLD THEM (2026-10-18). The kept
|
|
246
|
+
* config.toml mounts every server the person configured beside the repo's —
|
|
247
|
+
* a URL with a query, a mode set to ask and every other one the fenced
|
|
248
|
+
* mount refuses — so a code card is told these, in file order, and not the
|
|
249
|
+
* fenced mount's list (2026-10-17: a card is told the machine's servers by
|
|
250
|
+
* name). `[]` with no config, NULL for one this reader cannot parse: unknown,
|
|
251
|
+
* never "none".
|
|
291
252
|
*/
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 };
|
|
253
|
+
function personalServerNames(sourceConfig, repoServers) {
|
|
254
|
+
if (sourceConfig === '') return [];
|
|
255
|
+
let servers;
|
|
256
|
+
try { servers = parseCodexToml(sourceConfig)?.mcp_servers; } catch { return null; }
|
|
257
|
+
if (servers === undefined) return [];
|
|
258
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
|
|
259
|
+
return Object.entries(servers)
|
|
260
|
+
.filter(([name, v]) => !repoServers.has(name) && v && typeof v === 'object' && !Array.isArray(v)
|
|
261
|
+
&& (typeof v.command === 'string' || typeof v.url === 'string') && v.enabled !== false)
|
|
262
|
+
.map(([name]) => name);
|
|
418
263
|
}
|
|
419
264
|
|
|
420
265
|
/** Claude takes the base branch's skills as a plugin written for this turn. */
|
|
@@ -437,7 +282,8 @@ function reviewedClaudePlugin(dir, snapshot) {
|
|
|
437
282
|
* turn)` returns the runner-specific part of prepareAgentTools' result — under
|
|
438
283
|
* `dir`, which dies with the turn, except what `turn.codexHome` names a
|
|
439
284
|
* lasting place for. `turn.mcpServers` is the base branch's servers this
|
|
440
|
-
* runtime takes, expanded.
|
|
285
|
+
* runtime takes, expanded; `turn.fenced` a posture that reads no config file
|
|
286
|
+
* of the runner's own.
|
|
441
287
|
*/
|
|
442
288
|
const TOOL_RUNTIMES = {
|
|
443
289
|
claude: {
|
|
@@ -452,10 +298,11 @@ const TOOL_RUNTIMES = {
|
|
|
452
298
|
refuseMcp: (config) => (config.type === 'sse' ? 'Codex cannot take SSE MCP' : null),
|
|
453
299
|
// The agent's own lasting home when the caller has one (2026-09-29: its
|
|
454
300
|
// threads are indexed under it — see isolatedCodexHome), else one that
|
|
455
|
-
// dies with the turn, as every home did before.
|
|
301
|
+
// dies with the turn, as every home did before. A fenced turn ignores the
|
|
302
|
+
// home's config.toml, so no server is written into it (2026-10-17).
|
|
456
303
|
prepare: (dir, _snapshot, env, worktree, turn) => {
|
|
457
|
-
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
|
|
458
|
-
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
|
|
304
|
+
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : turn?.mcpServers);
|
|
305
|
+
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions, codexApprovals: turn?.fenced ? [] : isolated.approvals, codexMachineServers: turn?.fenced ? null : isolated.machineServers };
|
|
459
306
|
},
|
|
460
307
|
// A fenced posture ignores that home's config.toml (2026-10-10).
|
|
461
308
|
fencedMount: codexFencedMount,
|
|
@@ -472,9 +319,9 @@ const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES
|
|
|
472
319
|
* null for a runner whose fenced turns read the same file as its build
|
|
473
320
|
* (Claude's strict mcp.json) or that takes none. `usable` is the base
|
|
474
321
|
* snapshot's ready entries; `expand(value)` reads a `${VAR}`. */
|
|
475
|
-
export function fencedMountFor(runner, usable, expand, machineEnv = process.env) {
|
|
322
|
+
export function fencedMountFor(runner, usable, expand, machineEnv = process.env, turnServers = {}) {
|
|
476
323
|
const mount = rowFor(runner).fencedMount;
|
|
477
|
-
return mount ? mount(usable, expand, machineEnv) : null;
|
|
324
|
+
return mount ? mount(usable, expand, machineEnv, turnServers) : null;
|
|
478
325
|
}
|
|
479
326
|
|
|
480
327
|
/** Pure mapping used before a future tool-attach gesture is offered. */
|
|
@@ -501,5 +348,5 @@ export function skillsRefusal(runner) {
|
|
|
501
348
|
/** The runner-specific files a turn needs, written under `dir` — or, for the
|
|
502
349
|
* `codexHome` it names, where the caller keeps it between turns. */
|
|
503
350
|
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) ?? {}) };
|
|
351
|
+
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, codexApprovals: [], codexMachineServers: null, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
|
|
505
352
|
}
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { git, gitRaw, splitNul } from './git.mjs';
|
|
6
6
|
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
7
7
|
import { fencedMountFor, mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
|
|
8
|
+
import { mcpCommandNotOnPathReason, mcpEnvUnsetReason, mcpLauncherNotExecutableReason } from './mcpReasons.mjs';
|
|
8
9
|
|
|
9
10
|
const MAX_TOOLS = 40;
|
|
10
11
|
const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
|
|
@@ -39,14 +40,41 @@ function onPath(command, env) {
|
|
|
39
40
|
const relativeCommand = (command) => !isAbsolute(command) && command.includes('/');
|
|
40
41
|
|
|
41
42
|
/** Repo launchers are not PATH commands. Measure the reviewed tree's mode,
|
|
42
|
-
* never an uncommitted executable in the checkout used to read that tree
|
|
43
|
-
|
|
43
|
+
* never an uncommitted executable in the checkout used to read that tree —
|
|
44
|
+
* `{ path, blob }` for an executable file there, else null. */
|
|
45
|
+
function reviewedLauncher(root, commit, command) {
|
|
44
46
|
const path = relative(resolve(root), resolve(root, command));
|
|
45
|
-
if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return
|
|
47
|
+
if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return null;
|
|
46
48
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
for (const entry of splitNul(gitRaw(['ls-tree', '-z', commit, '--', path], root, SHOW))) {
|
|
50
|
+
const m = /^100755 blob ([0-9a-f]+)\t/.exec(entry);
|
|
51
|
+
if (m && entry.slice(entry.indexOf('\t') + 1) === path) return { path, blob: m[1] };
|
|
52
|
+
}
|
|
53
|
+
} catch { /* not in the reviewed tree */ }
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* …AND THE FILE A TURN RUNS IS THAT FILE (2026-10-17). The launcher is run
|
|
59
|
+
* from the agent's worktree, where a branch cut before it landed has none
|
|
60
|
+
* and the agent may have edited it — and an MCP server runs outside every
|
|
61
|
+
* fence of the turn (a Codex stdio server is not sandboxed). So the
|
|
62
|
+
* worktree's copy must be the reviewed blob, byte for byte (`hash-object
|
|
63
|
+
* --no-filters`, which runs no filter the worktree configures), or the server
|
|
64
|
+
* is not mounted, in words. Checked here rather than copied out of the tree,
|
|
65
|
+
* so a launcher still finds what it names beside itself. What the launcher
|
|
66
|
+
* then runs is its own business, as a PATH command's is.
|
|
67
|
+
*/
|
|
68
|
+
function launcherRefusal(snapshot, launchRoot, { config, launcher }) {
|
|
69
|
+
const file = resolve(launchRoot, launcher.path);
|
|
70
|
+
const named = `the launcher ${config.command}`;
|
|
71
|
+
try {
|
|
72
|
+
if (!statSync(file).isFile()) return `${named} is not in this worktree`;
|
|
73
|
+
} catch { return `${named} is not in this worktree`; }
|
|
74
|
+
try { accessSync(file, constants.X_OK); } catch { return `${named} is not executable in this worktree`; }
|
|
75
|
+
let blob = null;
|
|
76
|
+
try { blob = git(['hash-object', '--no-filters', '--', file], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* unread */ }
|
|
77
|
+
return blob === launcher.blob ? null : `${named} in this worktree differs from the reviewed base copy`;
|
|
50
78
|
}
|
|
51
79
|
|
|
52
80
|
function referenced(value) {
|
|
@@ -116,13 +144,14 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
|
|
|
116
144
|
const missing = [...vars].filter((key) => !env[key]).sort();
|
|
117
145
|
const command = typeof config.command === 'string' ? expand(config.command, env) : null;
|
|
118
146
|
const repoCommand = command != null && relativeCommand(command);
|
|
119
|
-
const
|
|
147
|
+
const launcher = repoCommand ? reviewedLauncher(root, commit, command) : null;
|
|
148
|
+
const commandMissing = command != null && !(repoCommand ? launcher : onPath(command, env));
|
|
120
149
|
const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
|
|
121
|
-
const reason = missing.length ?
|
|
122
|
-
? repoCommand ?
|
|
123
|
-
:
|
|
150
|
+
const reason = missing.length ? mcpEnvUnsetReason(missing) : commandMissing
|
|
151
|
+
? repoCommand ? mcpLauncherNotExecutableReason(config.command)
|
|
152
|
+
: mcpCommandNotOnPathReason(config.command)
|
|
124
153
|
: unsupported ? 'MCP configuration unsupported' : null;
|
|
125
|
-
return { name, config, state: reason ? 'missing' : 'ready', reason };
|
|
154
|
+
return { name, config, state: reason ? 'missing' : 'ready', reason, ...(launcher ? { launcher } : {}) };
|
|
126
155
|
});
|
|
127
156
|
// Nested instructions are normally discovered when a CLI enters that path.
|
|
128
157
|
// Their worktree copies must not regain authority through that discovery.
|
|
@@ -156,22 +185,26 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
|
|
|
156
185
|
* to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
|
|
157
186
|
* (projectToolRuntimes.mjs) — never `-c` pairs on the command line. A fenced
|
|
158
187
|
* Codex turn, which ignores that file, takes `fencedMount` instead: the
|
|
159
|
-
* repo's own words as `-c` pairs and every expanded value in its environment
|
|
188
|
+
* repo's own words as `-c` pairs and every expanded value in its environment
|
|
189
|
+
* — and `fenced` (the card's posture is not the build) keeps the servers out
|
|
190
|
+
* of the file it ignores (2026-10-17).
|
|
160
191
|
*/
|
|
161
|
-
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {} } = {}) {
|
|
192
|
+
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {}, fenced = false } = {}) {
|
|
162
193
|
const env = expansionEnv(callerEnv);
|
|
163
|
-
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
164
|
-
const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
|
|
165
194
|
// The CLI may read its config from a temporary directory, and Codex research
|
|
166
195
|
// changes cwd to an isolated copy. Keep repository launchers rooted at the
|
|
167
196
|
// agent worktree, not either of those directories or the daemon's checkout.
|
|
168
197
|
const launchRoot = worktree ?? snapshot.root;
|
|
169
198
|
const launchCommand = (command) => launchRoot && relativeCommand(command) ? resolve(launchRoot, command) : command;
|
|
199
|
+
const refusals = new Map(snapshot.tools.map((t) => [t, t.state !== 'ready' ? null
|
|
200
|
+
: mcpRefusal(runner, t.config) ?? (t.launcher && launchRoot ? launcherRefusal(snapshot, launchRoot, t) : null)]));
|
|
201
|
+
const usable = snapshot.tools.filter((t) => t.state === 'ready' && refusals.get(t) == null);
|
|
170
202
|
// What the base lists and this turn cannot mount, and why — the turn is told
|
|
171
203
|
// (postureTools.mjs), so a missing server is a named fact, never a guess.
|
|
172
204
|
const mcpUnmounted = snapshot.tools
|
|
173
205
|
.filter((t) => !usable.includes(t))
|
|
174
|
-
.map((t) => ({ name: t.name, reason: String(mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
|
|
206
|
+
.map((t) => ({ name: t.name, reason: String(refusals.get(t) ?? mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
|
|
207
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
175
208
|
const mcpServers = {};
|
|
176
209
|
for (const { name, config } of usable) {
|
|
177
210
|
if (typeof config.command === 'string') {
|
|
@@ -201,12 +234,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
201
234
|
].filter(Boolean).join('\n\n');
|
|
202
235
|
let prepared;
|
|
203
236
|
try {
|
|
204
|
-
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers });
|
|
237
|
+
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, fenced });
|
|
205
238
|
} catch (error) {
|
|
206
239
|
rmSync(dir, { recursive: true, force: true });
|
|
207
240
|
throw error;
|
|
208
241
|
}
|
|
209
|
-
const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
|
|
242
|
+
const { pluginDir, codexHome, personalDeveloperInstructions, codexApprovals, codexMachineServers } = prepared;
|
|
210
243
|
return {
|
|
211
244
|
mcpPath,
|
|
212
245
|
/** The names of the servers mounted, as Claude's allow rules need them. */
|
|
@@ -217,11 +250,18 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
217
250
|
fencedMount: fencedMountFor(runner, usable.map((entry) => ({ ...entry, config: {
|
|
218
251
|
...entry.config,
|
|
219
252
|
...(typeof entry.config.command === 'string' ? { command: launchCommand(entry.config.command) } : {}),
|
|
220
|
-
} })), (value) => expand(value, env), callerEnv),
|
|
253
|
+
} })), (value) => expand(value, env), callerEnv, turnServers),
|
|
221
254
|
instructions,
|
|
222
255
|
pluginDir,
|
|
223
256
|
codexHome,
|
|
224
257
|
personalDeveloperInstructions,
|
|
258
|
+
/** `-c` pairs approving the person's own Codex servers that set no mode,
|
|
259
|
+
* for a build card (projectToolRuntimes.mjs `personalApprovals`). */
|
|
260
|
+
codexApprovals,
|
|
261
|
+
/** The person's own Codex servers a build card's kept config.toml mounts,
|
|
262
|
+
* by name (projectToolRuntimes.mjs `personalServerNames`); null when
|
|
263
|
+
* unread, and on every other turn. */
|
|
264
|
+
codexMachineServers,
|
|
225
265
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
226
266
|
};
|
|
227
267
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** Delivery order is the agent's decision, checked against Git's evidence. */
|
|
2
2
|
import { parseTurnResult } from './agentPlan.mjs';
|
|
3
|
-
import { git, gitOrNull } from './git.mjs';
|
|
3
|
+
import { git, gitOrNull, isAncestor } from './git.mjs';
|
|
4
4
|
import { taskIdsFromMessage, commitRecord } from './worktreeDiff.mjs';
|
|
5
5
|
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
6
6
|
import { fleetRequest } from './fleetPost.mjs';
|
|
7
7
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
8
|
+
import { mergeOrderCorrection } from './repositoryPrompts.mjs';
|
|
9
|
+
import { completeMergeOrder } from './mergeOrder.mjs';
|
|
8
10
|
|
|
9
11
|
export function measuredCardRepos(rows, taskId, receipts = []) {
|
|
10
12
|
return rows.filter((r) => {
|
|
@@ -17,7 +19,7 @@ export function deliveryOrder(value, ids, catalog) {
|
|
|
17
19
|
const names = new Map(catalog.flatMap((r) => [[r.id, r.id], [r.name, r.id]]));
|
|
18
20
|
const order = Array.isArray(value?.mergeOrder) ? value.mergeOrder.map((id) => names.get(id) ?? id) : ids.length <= 1 ? ids : [];
|
|
19
21
|
const reason = typeof value?.mergeOrderReason === 'string' ? value.mergeOrderReason.trim().replace(/\s+/g, ' ').slice(0, 1000) : '';
|
|
20
|
-
if (
|
|
22
|
+
if (!completeMergeOrder(order, ids, reason)) return null;
|
|
21
23
|
return { changedRepos: ids, mergeOrder: order, ...(reason ? { mergeOrderReason: reason } : {}) };
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -52,13 +54,13 @@ export async function finishRepositoryDelivery({ job, run, out, answer, resume,
|
|
|
52
54
|
return out;
|
|
53
55
|
}
|
|
54
56
|
if (attempt === 3) throw new Error('The delivery still has no valid mergeOrder and mergeOrderReason for every repository with commits. The card has not entered Review.');
|
|
55
|
-
out = await resume(
|
|
57
|
+
out = await resume(mergeOrderCorrection(rows.filter((r) => ids.includes(r.id)).map((r) => ({ id: r.id, name: r.name }))));
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export function allResolutionsCommitted(rows) {
|
|
60
62
|
return rows.length > 0 && rows.every((r) => !gitOrNull(['rev-parse', '--verify', 'MERGE_HEAD'], r.wt)
|
|
61
|
-
&& (
|
|
63
|
+
&& isAncestor(r.baseRef, 'HEAD', r.wt)
|
|
62
64
|
&& !git(['diff', '--name-only', '--diff-filter=U'], r.wt).trim()
|
|
63
65
|
&& !git(['status', '--porcelain', '--untracked-files=no'], r.wt).trim());
|
|
64
66
|
}
|