flowviant 0.117.0 → 0.124.1
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 +41 -0
- package/bin/cli.mjs +44 -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 +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -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/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- 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/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- 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/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- 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/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -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/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- package/npm-shrinkwrap.json +0 -59
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The planner declares prerequisites; the daemon enforces them without reading
|
|
2
|
+
* human prose. Declared collisions use the same compatible groups; evidence stays on the wire. */
|
|
3
|
+
export function groupDependencies(agents, dependencies, collisions) {
|
|
4
|
+
if (dependencies === undefined && collisions === undefined) return agents;
|
|
5
|
+
if (dependencies === undefined) dependencies = [];
|
|
6
|
+
if (collisions === undefined) collisions = [];
|
|
7
|
+
if (!Array.isArray(collisions) || collisions.length > 1200) return null;
|
|
8
|
+
if (!Array.isArray(dependencies) || dependencies.length > 1200) return null;
|
|
9
|
+
if (!dependencies.length && !collisions.length) return agents;
|
|
10
|
+
const owner = new Map();
|
|
11
|
+
for (const [i, group] of agents.entries()) {
|
|
12
|
+
for (const id of group.taskIds) {
|
|
13
|
+
if (owner.has(id)) return null;
|
|
14
|
+
owner.set(id, i);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const parents = agents.map((_, i) => i);
|
|
18
|
+
const root = (i) => {
|
|
19
|
+
while (parents[i] !== i) i = parents[i];
|
|
20
|
+
return i;
|
|
21
|
+
};
|
|
22
|
+
const needs = new Map();
|
|
23
|
+
for (const edge of dependencies) {
|
|
24
|
+
if (!edge || !owner.has(edge.taskId) || !Array.isArray(edge.requires) || edge.requires.length > 60) return null;
|
|
25
|
+
for (const id of edge.requires) {
|
|
26
|
+
if (!owner.has(id) || id === edge.taskId) return null;
|
|
27
|
+
const a = root(owner.get(edge.taskId));
|
|
28
|
+
const b = root(owner.get(id));
|
|
29
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
30
|
+
if (!needs.has(edge.taskId)) needs.set(edge.taskId, new Set());
|
|
31
|
+
needs.get(edge.taskId).add(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const collision of collisions) {
|
|
35
|
+
if (!collision || !Array.isArray(collision.taskIds) || new Set(collision.taskIds).size < 2 || collision.taskIds.length > 60) return null;
|
|
36
|
+
for (const id of collision.taskIds) {
|
|
37
|
+
if (!owner.has(id)) return null;
|
|
38
|
+
const a = root(owner.get(collision.taskIds[0]));
|
|
39
|
+
const b = root(owner.get(id));
|
|
40
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
for (const [i, group] of agents.entries()) {
|
|
45
|
+
const key = root(i);
|
|
46
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
47
|
+
groups.get(key).push(group);
|
|
48
|
+
}
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const members of groups.values()) {
|
|
51
|
+
const attached = [...new Set(members.map((g) => g.intoAgentId).filter(Boolean))];
|
|
52
|
+
if (attached.length > 1) return null;
|
|
53
|
+
const pending = members.flatMap((g) => g.taskIds);
|
|
54
|
+
if (pending.length > 60) return null;
|
|
55
|
+
const taskIds = [];
|
|
56
|
+
// Stable topological order: unrelated cards keep their proposed order.
|
|
57
|
+
while (pending.length) {
|
|
58
|
+
const at = pending.findIndex((id) => [...(needs.get(id) ?? [])].every((need) => taskIds.includes(need)));
|
|
59
|
+
if (at < 0) return null; // a cycle cannot become a runnable proposal
|
|
60
|
+
taskIds.push(...pending.splice(at, 1));
|
|
61
|
+
}
|
|
62
|
+
const budget = members.reduce((sum, g) => sum + (g.pointsBudget ?? 0), 0);
|
|
63
|
+
result.push({ ...members[0], taskIds,
|
|
64
|
+
...(members.some(g => g.rationale) ? { rationale: members.map(g => g.rationale).filter(Boolean).join('\n').slice(0, 1000) } : {}),
|
|
65
|
+
...(budget ? { pointsBudget: Math.min(budget, 100_000) } : {}),
|
|
66
|
+
...(attached.length ? { intoAgentId: attached[0] } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Per-turn readout of mounted tools and remaining permission boundaries.
|
|
2
|
+
* Kind selects the delivery contract; CLI/configuration and the filesystem
|
|
3
|
+
* fence determine supporting capabilities. Keep these claims beside the
|
|
4
|
+
* actual builders and the spawned-lane tests in artifactProjectMcp.test.mjs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** What the machine's own MCP servers are, per CLI. */
|
|
8
|
+
const MACHINE = {
|
|
9
|
+
claude: 'the MCP servers and claude.ai connectors this machine’s Claude Code has (its user and local servers)',
|
|
10
|
+
codex: 'the MCP servers in this machine’s Codex config',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Per posture and CLI: what the card has and what it lacks. */
|
|
14
|
+
const POSTURES = {
|
|
15
|
+
research: {
|
|
16
|
+
claude: {
|
|
17
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
|
+
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
22
|
+
lacks: 'no host files outside that copy except native generated images; native image generation depends on this CLI and account',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
design: {
|
|
26
|
+
claude: {
|
|
27
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, guarded read commands, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
28
|
+
lacks: 'no measured native image-generation tool, no delegation, no helper scripts or browsers, and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
32
|
+
lacks: 'no native web on this broad-read fence (host credentials are readable); use a configured project web MCP server when available',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
image: {
|
|
36
|
+
codex: {
|
|
37
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
38
|
+
lacks: 'no web',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Configuration supplies tools; a skill supplies instructions, not a tool. */
|
|
44
|
+
const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not which supporting capabilities
|
|
45
|
+
may be used. Use available tools needed for the person's request, including
|
|
46
|
+
native image generation for requested textures or illustrations. A listed skill
|
|
47
|
+
is not proof that its tool is installed, mounted or entitled on this account.
|
|
48
|
+
Do not re-file the kind or require a separate image card just to use a tool.
|
|
49
|
+
If a needed tool is absent or fails, relay the measured limitation and ask for
|
|
50
|
+
the missing configuration or a decision. A separately billed CLI/API fallback
|
|
51
|
+
requires the person's explicit choice first; never default to OPENAI_API_KEY.
|
|
52
|
+
Use personal connectors only for the requested work; sending messages or other
|
|
53
|
+
external writes requires explicit authorization. Never open the person's browser
|
|
54
|
+
or desktop; headless inspection must stay within the granted filesystem fence.
|
|
55
|
+
Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.`;
|
|
56
|
+
|
|
57
|
+
const list = (names) => names.join(', ');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The note for a turn, or '' when its posture withholds nothing to say.
|
|
61
|
+
* `mounted` is the names of the base `.mcp.json` servers this turn mounted;
|
|
62
|
+
* `machine` whether the machine's own servers were mounted beside them (a
|
|
63
|
+
* Claude turn whose `.mcp.json` walk could not be bounded mounts the base copy
|
|
64
|
+
* alone); `unmounted` is `[{ name, reason, source }]` for the servers it
|
|
65
|
+
* could not mount — `source` 'project' (the base `.mcp.json`) or 'machine'.
|
|
66
|
+
*/
|
|
67
|
+
export function postureToolsNote({ posture, runtime, mounted = [], machine = true, unmounted = [] }) {
|
|
68
|
+
const row = POSTURES[posture]?.[runtime];
|
|
69
|
+
if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration. ${SUPPORTING_CAPABILITIES.replace('Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.', 'Delegate only when the person explicitly requests it.')} ` : '';
|
|
70
|
+
const fromProject = unmounted.filter((u) => u.source !== 'machine');
|
|
71
|
+
const fromMachine = unmounted.filter((u) => u.source === 'machine');
|
|
72
|
+
const project = mounted.length
|
|
73
|
+
? `The project’s MCP servers from the base branch’s .mcp.json are mounted: ${list(mounted)}.`
|
|
74
|
+
: fromProject.length
|
|
75
|
+
? 'None of the project’s MCP servers is mounted on this machine.'
|
|
76
|
+
: 'The base branch’s .mcp.json lists no MCP server.';
|
|
77
|
+
const own = machine
|
|
78
|
+
? ` So are ${MACHINE[runtime]}.`
|
|
79
|
+
: ` ${MACHINE[runtime][0].toUpperCase()}${MACHINE[runtime].slice(1)} are not mounted on this turn.`;
|
|
80
|
+
const missing = (rows, where) => (rows.length ? ` ${where}, but not mounted here: ${list(rows.map(({ name, reason }) => `${name} (${reason})`))}.` : '');
|
|
81
|
+
return `TOOLS ON THIS CARD. You have ${row.has}. You have ${row.lacks}. ${project}${own}${missing(fromProject, 'Listed in the base branch’s .mcp.json')}${missing(fromMachine, `In ${MACHINE[runtime]}`)} A server added to .mcp.json only in this worktree is not mounted; the base branch’s file is the one that counts.
|
|
82
|
+
There is no Flowviant setting that adds a tool, so never invent one. A missing
|
|
83
|
+
server can be configured in the base branch's .mcp.json or the machine's CLI.
|
|
84
|
+
${SUPPORTING_CAPABILITIES}`;
|
|
85
|
+
}
|
package/bin/lib/prWorkflow.mjs
CHANGED
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
* without GitHub; nothing in the daemon passes them except the fetch.
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
55
|
+
import { tmpdir } from 'node:os';
|
|
56
|
+
import { join } from 'node:path';
|
|
54
57
|
import { execFileSync } from 'node:child_process';
|
|
55
58
|
import { git, stripUrlUserinfo } from './git.mjs';
|
|
56
59
|
import { scrub } from './uplinkScrub.mjs';
|
|
@@ -232,3 +235,15 @@ export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fet
|
|
|
232
235
|
const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
|
|
233
236
|
return landed ? { ok: true } : { notOnBase: true };
|
|
234
237
|
}
|
|
238
|
+
|
|
239
|
+
/** Cross-links are written only to verified PR URLs, before any PR lands. */
|
|
240
|
+
export function linkRepositoryPrs(url, { cwd, cards, order, repositories, states, gh = runGh }) {
|
|
241
|
+
if (!PR_URL_RE.test(url)) throw new Error('GitHub did not return a verified pull request URL.');
|
|
242
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-pr-'));
|
|
243
|
+
try {
|
|
244
|
+
const file = join(dir, 'body.md');
|
|
245
|
+
const body = `Flowviant cards: ${cards.map((c) => c.url ? `[${c.id}](${c.url})` : c.id).join(', ')}\n\nMerge order:\n${order.map((id, i) => `${i + 1}. ${repositories.find((r) => r.id === id)?.name ?? id}${states[id]?.prUrl ? ` — ${states[id].prUrl}` : ''}`).join('\n')}\n\n${cards.map((c) => c.mergeOrderReason).filter(Boolean).join('\n')}\n`;
|
|
246
|
+
writeFileSync(file, body, { mode: 0o600 });
|
|
247
|
+
gh(['pr', 'edit', url, '--body-file', file], { cwd, timeout: 60_000 });
|
|
248
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
249
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Each repository observes its own base, including PRs merged outside this daemon. */
|
|
2
|
+
import { createLandedObserver } from './landed.mjs';
|
|
3
|
+
import { gitNetAsync } from './git.mjs';
|
|
4
|
+
export function createProjectLandedObserver({ repoRoot, baseRef, repositories }) {
|
|
5
|
+
const legacy = createLandedObserver({ repoRoot, baseRef });
|
|
6
|
+
const observers = new Map();
|
|
7
|
+
return { observe: async () => {
|
|
8
|
+
if (!repositories?.multiple()) return legacy.observe();
|
|
9
|
+
for (const repo of repositories.list().filter((r) => r.repoRoot && r.baseRef)) {
|
|
10
|
+
const key = `${repo.id}:${repo.repoRoot}`;
|
|
11
|
+
if (!observers.has(key)) observers.set(key, createLandedObserver({ repoRoot: repo.repoRoot, baseRef: () => repositories.list().find((r) => r.id === repo.id)?.baseRef, repo: repo.id }));
|
|
12
|
+
try { await gitNetAsync(['fetch', 'origin', '--quiet'], repo.repoRoot); } catch { /* local-only or offline */ }
|
|
13
|
+
await observers.get(key).observe();
|
|
14
|
+
}
|
|
15
|
+
} };
|
|
16
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The project's catalog joined to this machine's credential bindings. */
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { printableLine } from './printable.mjs';
|
|
4
|
+
import { listStoredProjects } from './credentials.mjs';
|
|
5
|
+
import { checkoutIdentity } from './checkoutIdentity.mjs';
|
|
6
|
+
import { detectBaseRef, gitOrNull, usableBaseRef, baseBranchName } from './git.mjs';
|
|
7
|
+
|
|
8
|
+
export function storedProject(token) {
|
|
9
|
+
return listStoredProjects().find((entry) => entry.fleetToken === token) ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function address(value) {
|
|
13
|
+
let remote = String(value ?? '');
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(remote);
|
|
16
|
+
if (url.hostname.toLowerCase() === 'github.com') remote = url.pathname.replace(/^\//, '');
|
|
17
|
+
} catch { /* full name or scp remote */ }
|
|
18
|
+
remote = remote.replace(/^(?:git@)?github\.com:/i, '').replace(/\.git\/?$/i, '');
|
|
19
|
+
return /^[\w.-]+\/[\w.-]+$/.test(remote) ? remote.toLowerCase() : remote;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createProjectRepositories({ entry, repoRoot, getEntry = () => entry }) {
|
|
23
|
+
let catalog = null;
|
|
24
|
+
const learn = (rows) => {
|
|
25
|
+
if (!Array.isArray(rows)) return;
|
|
26
|
+
if (!rows.length || rows.length > 64 || rows.some((r) => !r || typeof r.id !== 'string' || typeof r.name !== 'string') ||
|
|
27
|
+
rows.filter((r) => r.isPrimary).length !== 1)
|
|
28
|
+
throw new Error('The project repository catalog is invalid.');
|
|
29
|
+
catalog = rows;
|
|
30
|
+
};
|
|
31
|
+
const list = () => {
|
|
32
|
+
const bindings = Object.entries(getEntry()?.repositories ?? {});
|
|
33
|
+
return (catalog ?? []).map((repo) => {
|
|
34
|
+
// Match remotes as well as names: a rename must not strand a binding,
|
|
35
|
+
// and reusing a short name must never select a different git repository.
|
|
36
|
+
const matches = bindings.filter(([name, root]) => {
|
|
37
|
+
if (!existsSync(root)) return false;
|
|
38
|
+
return repo.remote ? address(checkoutIdentity(root).remote) === address(repo.remote) : name === repo.name;
|
|
39
|
+
});
|
|
40
|
+
const root = matches.find(([name]) => name === repo.name)?.[1] ?? matches[0]?.[1] ??
|
|
41
|
+
(catalog.length === 1 && bindings.length === 0 ? repoRoot : null);
|
|
42
|
+
const checkout = root && gitOrNull(['rev-parse', '--show-toplevel'], root);
|
|
43
|
+
const available = checkout && realpathSync(checkout) === realpathSync(root);
|
|
44
|
+
return { ...repo, repoRoot: available ? root : null,
|
|
45
|
+
baseRef: available ? (repo.baseBranch ? usableBaseRef(root, `origin/${baseBranchName(repo.baseBranch)}`) : detectBaseRef(root)) : null };
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const targets = (job) => {
|
|
49
|
+
const rows = list();
|
|
50
|
+
const ids = job.repos ?? job.task?.repos;
|
|
51
|
+
return Array.isArray(ids) && ids.length ? [...new Set(ids)] : rows.filter((r) => r.isPrimary).map((r) => r.id);
|
|
52
|
+
};
|
|
53
|
+
const missing = (job) => {
|
|
54
|
+
if (!catalog) return null; // an older server, not a measurement of absence
|
|
55
|
+
const rows = list();
|
|
56
|
+
const absent = targets(job).filter((id) => !rows.find((r) => r.id === id)?.repoRoot);
|
|
57
|
+
return absent.length ? printableLine(`Repositories not checked out on this machine: ${absent.map((id) => rows.find((r) => r.id === id)?.name ?? id).join(', ')}. Run flowviant login in each checkout to connect it.`, 4000) : null;
|
|
58
|
+
};
|
|
59
|
+
return { learn, list, targets, missing, multiple: () => (catalog?.length ?? 0) > 1 };
|
|
60
|
+
}
|
|
@@ -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
|
-
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
|
|
143
|
+
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {} } = {}) {
|
|
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') {
|
|
@@ -154,6 +162,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
154
162
|
mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
155
163
|
}
|
|
156
164
|
}
|
|
165
|
+
Object.assign(mcpServers, turnServers); // daemon-owned names cannot be replaced by project config
|
|
157
166
|
const mcpPath = join(dir, 'mcp.json');
|
|
158
167
|
writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
159
168
|
const pointers = [];
|
|
@@ -177,6 +186,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
177
186
|
const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
|
|
178
187
|
return {
|
|
179
188
|
mcpPath,
|
|
189
|
+
/** The names of the servers mounted, as Claude's allow rules need them. */
|
|
190
|
+
mcpServers: Object.keys(mcpServers),
|
|
191
|
+
mcpUnmounted,
|
|
192
|
+
/** The mount a fenced turn takes where the runner's file is ignored
|
|
193
|
+
* (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
|
|
194
|
+
fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
|
|
180
195
|
instructions,
|
|
181
196
|
pluginDir,
|
|
182
197
|
codexHome,
|
|
@@ -197,3 +212,46 @@ export function toolReadout(snapshot, runner) {
|
|
|
197
212
|
}));
|
|
198
213
|
return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
|
|
199
214
|
}
|
|
215
|
+
|
|
216
|
+
const MCP_JSON_MAX_BYTES = 1024 * 1024;
|
|
217
|
+
const MCP_JSON_MAX_NAMES = 500;
|
|
218
|
+
/**
|
|
219
|
+
* EVERY SERVER NAMED IN A `.mcp.json` FROM `dir` UP TO `/` (2026-10-11). An
|
|
220
|
+
* agent turn that mounts the machine's MCP servers is not strict, and Claude
|
|
221
|
+
* Code then reads `.mcp.json` in its cwd and every parent wherever a settings
|
|
222
|
+
* scope approves one (measured, 2.1.290). The base branch's copy is the
|
|
223
|
+
* project's only `.mcp.json` input to an agent, so each of these names is
|
|
224
|
+
* disabled for the turn (claudePosture.mjs `withMachineMcp`): the agent's edit
|
|
225
|
+
* of its worktree's file mounts only after review lands it on base. A server
|
|
226
|
+
* the machine should have everywhere belongs in its CLI's user scope.
|
|
227
|
+
*
|
|
228
|
+
* NULL when the answer cannot be bounded — a `.mcp.json` over 1 MiB, or more
|
|
229
|
+
* than 500 names — and the lane then mounts strictly (the base copy alone)
|
|
230
|
+
* rather than leave one discovered. A file that does not parse names nothing:
|
|
231
|
+
* the CLI cannot read it either.
|
|
232
|
+
*/
|
|
233
|
+
export function mcpJsonServerNamesAbove(dir) {
|
|
234
|
+
const names = new Set();
|
|
235
|
+
let at = resolve(dir);
|
|
236
|
+
for (;;) {
|
|
237
|
+
const file = join(at, '.mcp.json');
|
|
238
|
+
let raw = null;
|
|
239
|
+
try {
|
|
240
|
+
const st = statSync(file);
|
|
241
|
+
if (st.isFile()) {
|
|
242
|
+
if (st.size > MCP_JSON_MAX_BYTES) return null;
|
|
243
|
+
raw = readFileSync(file, 'utf8');
|
|
244
|
+
}
|
|
245
|
+
} catch { /* none here */ }
|
|
246
|
+
if (raw != null) {
|
|
247
|
+
try {
|
|
248
|
+
const servers = JSON.parse(raw)?.mcpServers;
|
|
249
|
+
if (servers && typeof servers === 'object' && !Array.isArray(servers)) for (const name of Object.keys(servers)) names.add(name);
|
|
250
|
+
} catch { /* the CLI cannot parse it either */ }
|
|
251
|
+
}
|
|
252
|
+
if (names.size > MCP_JSON_MAX_NAMES) return null;
|
|
253
|
+
const up = dirname(at);
|
|
254
|
+
if (up === at) return [...names];
|
|
255
|
+
at = up;
|
|
256
|
+
}
|
|
257
|
+
}
|