flowviant 0.117.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/bin/cli.mjs +37 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -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
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
|
|
|
@@ -182,9 +183,14 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
|
182
183
|
try { if (!lstatSync(dest).isDirectory()) rmSync(dest, { force: true }); } catch { /* absent */ }
|
|
183
184
|
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
184
185
|
chmodSync(dest, 0o700);
|
|
185
|
-
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') : [];
|
|
186
187
|
for (const name of personal) linkPersonalEntry(join(source, name), join(dest, name));
|
|
187
|
-
|
|
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']);
|
|
188
194
|
for (const name of readdirSync(dest)) {
|
|
189
195
|
if (linked.has(name) || name === 'config.toml') continue;
|
|
190
196
|
// Only a LINK is ours to drop: a real entry under a name the person has
|
|
@@ -205,6 +211,212 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
|
205
211
|
return { path: dest, personalDeveloperInstructions };
|
|
206
212
|
}
|
|
207
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
|
+
|
|
208
420
|
/** Claude takes the base branch's skills as a plugin written for this turn. */
|
|
209
421
|
function reviewedClaudePlugin(dir, snapshot) {
|
|
210
422
|
if (!snapshot.skillFiles.length) return null;
|
|
@@ -245,6 +457,8 @@ const TOOL_RUNTIMES = {
|
|
|
245
457
|
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
|
|
246
458
|
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
|
|
247
459
|
},
|
|
460
|
+
// A fenced posture ignores that home's config.toml (2026-10-10).
|
|
461
|
+
fencedMount: codexFencedMount,
|
|
248
462
|
},
|
|
249
463
|
antigravity: { mcp: false, skills: false },
|
|
250
464
|
};
|
|
@@ -253,6 +467,16 @@ const TOOL_RUNTIMES = {
|
|
|
253
467
|
const takesNothing = { mcp: false, skills: false };
|
|
254
468
|
const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES[runner] : takesNothing);
|
|
255
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
|
+
|
|
256
480
|
/** Pure mapping used before a future tool-attach gesture is offered. */
|
|
257
481
|
export function runnerToolCapabilities(runner) {
|
|
258
482
|
const { mcp, skills } = rowFor(runner);
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/** Repo-owned tool configuration, read from the base ref for every agent turn. */
|
|
2
|
-
import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { delimiter, isAbsolute, join } from 'node:path';
|
|
2
|
+
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { git, gitRaw, splitNul } from './git.mjs';
|
|
6
6
|
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
7
|
-
import { mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
|
|
7
|
+
import { fencedMountFor, mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
|
|
8
8
|
|
|
9
9
|
const MAX_TOOLS = 40;
|
|
10
10
|
const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
|
|
@@ -124,6 +124,7 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
|
|
|
124
124
|
|
|
125
125
|
const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
|
|
126
126
|
|
|
127
|
+
|
|
127
128
|
/**
|
|
128
129
|
* Temporary per-turn files are removed after the last retry. No personal CLI config is written.
|
|
129
130
|
*
|
|
@@ -135,12 +136,19 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
|
|
|
135
136
|
*
|
|
136
137
|
* NO EXPANDED VALUE RIDES ARGV (2026-10-10, the audit): Claude's servers go
|
|
137
138
|
* to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
|
|
138
|
-
* (projectToolRuntimes.mjs) — never `-c` pairs on the command line.
|
|
139
|
+
* (projectToolRuntimes.mjs) — never `-c` pairs on the command line. A fenced
|
|
140
|
+
* Codex turn, which ignores that file, takes `fencedMount` instead: the
|
|
141
|
+
* repo's own words as `-c` pairs and every expanded value in its environment.
|
|
139
142
|
*/
|
|
140
143
|
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
|
|
141
144
|
const env = expansionEnv(callerEnv);
|
|
142
145
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
143
146
|
const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
|
|
147
|
+
// What the base lists and this turn cannot mount, and why — the turn is told
|
|
148
|
+
// (postureTools.mjs), so a missing server is a named fact, never a guess.
|
|
149
|
+
const mcpUnmounted = snapshot.tools
|
|
150
|
+
.filter((t) => !usable.includes(t))
|
|
151
|
+
.map((t) => ({ name: t.name, reason: String(mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
|
|
144
152
|
const mcpServers = {};
|
|
145
153
|
for (const { name, config } of usable) {
|
|
146
154
|
if (typeof config.command === 'string') {
|
|
@@ -177,6 +185,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
177
185
|
const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
|
|
178
186
|
return {
|
|
179
187
|
mcpPath,
|
|
188
|
+
/** The names of the servers mounted, as Claude's allow rules need them. */
|
|
189
|
+
mcpServers: Object.keys(mcpServers),
|
|
190
|
+
mcpUnmounted,
|
|
191
|
+
/** The mount a fenced turn takes where the runner's file is ignored
|
|
192
|
+
* (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
|
|
193
|
+
fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
|
|
180
194
|
instructions,
|
|
181
195
|
pluginDir,
|
|
182
196
|
codexHome,
|
|
@@ -197,3 +211,46 @@ export function toolReadout(snapshot, runner) {
|
|
|
197
211
|
}));
|
|
198
212
|
return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
|
|
199
213
|
}
|
|
214
|
+
|
|
215
|
+
const MCP_JSON_MAX_BYTES = 1024 * 1024;
|
|
216
|
+
const MCP_JSON_MAX_NAMES = 500;
|
|
217
|
+
/**
|
|
218
|
+
* EVERY SERVER NAMED IN A `.mcp.json` FROM `dir` UP TO `/` (2026-10-11). An
|
|
219
|
+
* agent turn that mounts the machine's MCP servers is not strict, and Claude
|
|
220
|
+
* Code then reads `.mcp.json` in its cwd and every parent wherever a settings
|
|
221
|
+
* scope approves one (measured, 2.1.290). The base branch's copy is the
|
|
222
|
+
* project's only `.mcp.json` input to an agent, so each of these names is
|
|
223
|
+
* disabled for the turn (claudePosture.mjs `withMachineMcp`): the agent's edit
|
|
224
|
+
* of its worktree's file mounts only after review lands it on base. A server
|
|
225
|
+
* the machine should have everywhere belongs in its CLI's user scope.
|
|
226
|
+
*
|
|
227
|
+
* NULL when the answer cannot be bounded — a `.mcp.json` over 1 MiB, or more
|
|
228
|
+
* than 500 names — and the lane then mounts strictly (the base copy alone)
|
|
229
|
+
* rather than leave one discovered. A file that does not parse names nothing:
|
|
230
|
+
* the CLI cannot read it either.
|
|
231
|
+
*/
|
|
232
|
+
export function mcpJsonServerNamesAbove(dir) {
|
|
233
|
+
const names = new Set();
|
|
234
|
+
let at = resolve(dir);
|
|
235
|
+
for (;;) {
|
|
236
|
+
const file = join(at, '.mcp.json');
|
|
237
|
+
let raw = null;
|
|
238
|
+
try {
|
|
239
|
+
const st = statSync(file);
|
|
240
|
+
if (st.isFile()) {
|
|
241
|
+
if (st.size > MCP_JSON_MAX_BYTES) return null;
|
|
242
|
+
raw = readFileSync(file, 'utf8');
|
|
243
|
+
}
|
|
244
|
+
} catch { /* none here */ }
|
|
245
|
+
if (raw != null) {
|
|
246
|
+
try {
|
|
247
|
+
const servers = JSON.parse(raw)?.mcpServers;
|
|
248
|
+
if (servers && typeof servers === 'object' && !Array.isArray(servers)) for (const name of Object.keys(servers)) names.add(name);
|
|
249
|
+
} catch { /* the CLI cannot parse it either */ }
|
|
250
|
+
}
|
|
251
|
+
if (names.size > MCP_JSON_MAX_NAMES) return null;
|
|
252
|
+
const up = dirname(at);
|
|
253
|
+
if (up === at) return [...names];
|
|
254
|
+
at = up;
|
|
255
|
+
}
|
|
256
|
+
}
|