flowviant 0.125.3 → 0.125.5

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.
@@ -46,9 +46,9 @@
46
46
  * swept on the next write.
47
47
  */
48
48
  import { randomBytes } from 'node:crypto';
49
- import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
49
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
50
50
  import { homedir } from 'node:os';
51
- import { basename, dirname, join } from 'node:path';
51
+ import { basename, dirname, join, resolve } from 'node:path';
52
52
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
53
53
  import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
54
54
 
@@ -141,6 +141,56 @@ export function personalClaudeSettings(env = process.env) {
141
141
  return out;
142
142
  }
143
143
 
144
+ /** Far past any real CLI config — `.claude.json` also holds every project's
145
+ * history; a bigger one is not read. */
146
+ const CLI_CONFIG_MAX_BYTES = 32 * 1024 * 1024;
147
+ const isRecord = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
148
+
149
+ /**
150
+ * THE MACHINE'S OWN MCP SERVERS THAT A CARD TURN'S SETTINGS SOURCES NEVER
151
+ * REACH (2026-10-17). The 2026-10-11 ruling mounts "the user and local
152
+ * servers" on every card turn, and neither arrived where the person keeps
153
+ * them. MEASURED on Claude Code 2.1.302, a scratch CLAUDE_CONFIG_DIR whose
154
+ * `.claude.json` held one user and one local server:
155
+ * · `--setting-sources ''` (every fenced posture: write-up, mockup, 3D model,
156
+ * deck) loaded NEITHER — a research card on the owner's box saw the
157
+ * claude.ai connectors and no Roblox_Studio, a user-scope server;
158
+ * · `--setting-sources user` (a code card) loaded the user server only;
159
+ * · a local server is keyed on the git root it was added from (`claude mcp
160
+ * add`'s default scope), which is the person's checkout — never the
161
+ * agent's worktree, so no card turn could see one even with every source.
162
+ * So the lane names what is missing and the adapter hands it over through a
163
+ * private `--mcp-config` file (runtimeClaude.mjs): `user` when the turn's
164
+ * sources drop the user scope, and the checkout's local servers always. A
165
+ * local name shadows a user one, the CLI's own precedence; an `--mcp-config`
166
+ * server beat a user server of the same name (measured), so a code card keeps
167
+ * that order too. `{}` when the file is absent, unreadable or not an object.
168
+ */
169
+ export function machineMcpServers({ user = false, checkout = null, env = process.env } = {}) {
170
+ const path = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, '.claude.json') : join(env.HOME || homedir(), '.claude.json');
171
+ let raw;
172
+ try {
173
+ if (statSync(path).size > CLI_CONFIG_MAX_BYTES) return {};
174
+ raw = JSON.parse(readFileSync(path, 'utf8'));
175
+ } catch {
176
+ return {};
177
+ }
178
+ if (!isRecord(raw)) return {};
179
+ const out = {};
180
+ const take = (servers) => {
181
+ if (!isRecord(servers)) return;
182
+ for (const [name, config] of Object.entries(servers)) if (name !== '__proto__' && isRecord(config)) out[name] = config;
183
+ };
184
+ if (user) take(raw.mcpServers);
185
+ if (typeof checkout === 'string' && checkout && isRecord(raw.projects)) {
186
+ const keys = [resolve(checkout)];
187
+ try { keys.push(realpathSync(checkout)); } catch { /* the resolved path alone */ }
188
+ const key = keys.find((k) => Object.hasOwn(raw.projects, k));
189
+ if (key) take(raw.projects[key]?.mcpServers);
190
+ }
191
+ return out;
192
+ }
193
+
144
194
  /** Where a fenced turn's settings file lives — the daemon's own state
145
195
  * directory, private to this user. */
146
196
  export const turnSettingsDir = () => join(homedir(), '.flowviant', 'run');
@@ -182,11 +232,12 @@ export function writeTurnSettings(settings, { dir = turnSettingsDir() } = {}) {
182
232
  }
183
233
  }
184
234
 
185
- /** Remove the settings file a turn's argv names, when it is one of ours. */
235
+ /** Remove the settings file a turn's argv names, when it is one of ours — and
236
+ * the machine-MCP file (`machineMcpServers`), written the same way. */
186
237
  export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
187
238
  if (!Array.isArray(args)) return;
188
239
  args.forEach((a, i) => {
189
- if (i === 0 || args[i - 1] !== '--settings' || typeof a !== 'string') return;
240
+ if (i === 0 || !['--settings', '--mcp-config'].includes(args[i - 1]) || typeof a !== 'string') return;
190
241
  if (dirname(a) !== dir || !TURN_SETTINGS_FILE.test(basename(a))) return;
191
242
  rmSync(a, { force: true });
192
243
  });
@@ -45,6 +45,10 @@ const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not whi
45
45
  may be used. Use available tools needed for the person's request, including
46
46
  native image generation for requested textures or illustrations. A listed skill
47
47
  is not proof that its tool is installed, mounted or entitled on this account.
48
+ Configuration and mount names do not prove a live MCP connection. Verify access
49
+ with a non-mutating tool before claiming it works. If a call fails, relay its
50
+ actual startup, approval or connection error and a concrete corrective step;
51
+ an empty instance list means no connected instances, not missing tools.
48
52
  Do not re-file the kind or require a separate image card just to use a tool.
49
53
  If a needed tool is absent or fails, relay the measured limitation and ask for
50
54
  the missing configuration or a decision. A separately billed CLI/API fallback
@@ -1,6 +1,6 @@
1
1
  /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
2
  import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
- import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { delimiter, dirname, isAbsolute, join, relative, 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';
@@ -36,6 +36,19 @@ function onPath(command, env) {
36
36
  });
37
37
  }
38
38
 
39
+ const relativeCommand = (command) => !isAbsolute(command) && command.includes('/');
40
+
41
+ /** 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
+ function reviewedExecutable(root, commit, command) {
44
+ const path = relative(resolve(root), resolve(root, command));
45
+ if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return false;
46
+ try {
47
+ return splitNul(gitRaw(['ls-tree', '-z', commit, '--', path], root, SHOW))
48
+ .some((entry) => entry.startsWith('100755 blob ') && entry.slice(entry.indexOf('\t') + 1) === path);
49
+ } catch { return false; }
50
+ }
51
+
39
52
  function referenced(value) {
40
53
  return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
41
54
  }
@@ -101,9 +114,14 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
101
114
  for (const key of referenced(value)) vars.add(key);
102
115
  }
103
116
  const missing = [...vars].filter((key) => !env[key]).sort();
104
- const commandMissing = typeof config.command === 'string' && !onPath(expand(config.command, env), env);
117
+ const command = typeof config.command === 'string' ? expand(config.command, env) : null;
118
+ const repoCommand = command != null && relativeCommand(command);
119
+ const commandMissing = command != null && !(repoCommand ? reviewedExecutable(root, commit, command) : onPath(command, env));
105
120
  const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
106
- const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing ? `command ${config.command} not on PATH` : unsupported ? 'MCP configuration unsupported' : null;
121
+ const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing
122
+ ? repoCommand ? `command ${config.command} is not an executable file in the base branch; commit the launcher with executable permission on base`
123
+ : `command ${config.command} not on PATH`
124
+ : unsupported ? 'MCP configuration unsupported' : null;
107
125
  return { name, config, state: reason ? 'missing' : 'ready', reason };
108
126
  });
109
127
  // Nested instructions are normally discovered when a CLI enters that path.
@@ -119,7 +137,7 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
119
137
  if (instructionBytes > MAX_INSTRUCTION_BYTES) throw new Error('Base branch agent instructions exceed the turn limit');
120
138
  instructions.push({ path, body });
121
139
  }
122
- return { tools, skills, skillFiles, instructions };
140
+ return { tools, skills, skillFiles, instructions, root: resolve(root) };
123
141
  }
124
142
 
125
143
  const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
@@ -144,6 +162,11 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
144
162
  const env = expansionEnv(callerEnv);
145
163
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
146
164
  const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
165
+ // The CLI may read its config from a temporary directory, and Codex research
166
+ // changes cwd to an isolated copy. Keep repository launchers rooted at the
167
+ // agent worktree, not either of those directories or the daemon's checkout.
168
+ const launchRoot = worktree ?? snapshot.root;
169
+ const launchCommand = (command) => launchRoot && relativeCommand(command) ? resolve(launchRoot, command) : command;
147
170
  // What the base lists and this turn cannot mount, and why — the turn is told
148
171
  // (postureTools.mjs), so a missing server is a named fact, never a guess.
149
172
  const mcpUnmounted = snapshot.tools
@@ -152,7 +175,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
152
175
  const mcpServers = {};
153
176
  for (const { name, config } of usable) {
154
177
  if (typeof config.command === 'string') {
155
- const command = expand(config.command, env);
178
+ const command = launchCommand(expand(config.command, env));
156
179
  const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
157
180
  const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
158
181
  mcpServers[name] = { command, args, env: vars };
@@ -191,7 +214,10 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
191
214
  mcpUnmounted,
192
215
  /** The mount a fenced turn takes where the runner's file is ignored
193
216
  * (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
194
- fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
217
+ fencedMount: fencedMountFor(runner, usable.map((entry) => ({ ...entry, config: {
218
+ ...entry.config,
219
+ ...(typeof entry.config.command === 'string' ? { command: launchCommand(entry.config.command) } : {}),
220
+ } })), (value) => expand(value, env), callerEnv),
195
221
  instructions,
196
222
  pluginDir,
197
223
  codexHome,
@@ -234,6 +234,10 @@ function runPlainTurn(opts) {
234
234
  // server names an agent lane disables beside them, on a profile that
235
235
  // mounts project servers — never on one that does not.
236
236
  machineMcp: turnProfile.projectMcp ? opts.machineMcp : undefined,
237
+ // …beside them, the names the base copy mounts and the person's checkout,
238
+ // whose local servers no card turn's cwd reaches (2026-10-17).
239
+ baseMcp: turnProfile.projectMcp ? (opts.projectMcpServers ?? []) : [],
240
+ mcpCheckout: turnProfile.projectMcp ? opts.mcpCheckout : undefined,
237
241
  // Handed to the adapter rather than appended here, because WHERE these go
238
242
  // is a property of the CLI: Codex reads its prompt as a trailing
239
243
  // positional, so a flag after it is a flag in the wrong place.
@@ -14,7 +14,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
- import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings } from './claudePersonal.mjs';
17
+ import { NO_SETTING_SOURCES, machineMcpServers, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
18
  import { withMachineMcp } from './claudePosture.mjs';
19
19
  import { orchestratorHooks } from './orchestratorHooks.mjs';
20
20
 
@@ -102,7 +102,7 @@ export const CLAUDE_RUNTIME = {
102
102
  * strongest form of it available anywhere: `--append-system-prompt` sits
103
103
  * above the conversation rather than inside it.
104
104
  */
105
- args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false, orchestration = false }) {
105
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false }) {
106
106
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
107
107
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
108
108
  // with no vault, or one no rule can name, has no list — refused here in
@@ -221,8 +221,24 @@ export const CLAUDE_RUNTIME = {
221
221
  // kept is written to a private file, never onto argv (claudePersonal.mjs)
222
222
  // — so this is the LAST thing built: nothing after it may throw and
223
223
  // leave the file with no turn to release it.
224
+ //
225
+ // …AND THE MACHINE'S SERVERS NO SOURCE OF THIS TURN READS (2026-10-17,
226
+ // claudePersonal.mjs `machineMcpServers`): the user scope a fenced turn's
227
+ // empty source list drops, and the checkout's local scope every card turn
228
+ // missed. In a private file too — a server's env and headers are secrets —
229
+ // minus every name the base copy mounts, which speaks for the project.
230
+ const own = machine ? machineMcpServers({ user: fenced, checkout: mcpCheckout }) : {};
231
+ for (const name of baseMcp) delete own[name];
232
+ const ownArgs = Object.keys(own).length ? ['--mcp-config', writeTurnSettings({ mcpServers: own })] : [];
224
233
  const postured = machine ? withMachineMcp(perm, machineMcp) : perm;
225
- a.push(...(fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured));
234
+ let tail;
235
+ try {
236
+ tail = fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured;
237
+ } catch (error) {
238
+ releaseTurnSettings(ownArgs);
239
+ throw error;
240
+ }
241
+ a.push(...ownArgs, ...tail);
226
242
  return a;
227
243
  },
228
244
  /** What `args` wrote for a turn — the fenced posture's settings file —
@@ -592,7 +592,7 @@ export function createAgentTurnExecution({
592
592
  ? {
593
593
  mcpArgs: [...(claudeDisabled ? [] : ['--strict-mcp-config']), '--mcp-config', projectTools.mcpPath],
594
594
  projectMcpServers: projectTools.mcpServers,
595
- ...(claudeDisabled ? { machineMcp: claudeDisabled } : {}),
595
+ ...(claudeDisabled ? { machineMcp: claudeDisabled, mcpCheckout: source?.repoRoot ?? repoRoot } : {}),
596
596
  }
597
597
  : fencedMount ? { mcpArgs: fencedMount.args } : {}),
598
598
  knowledgeDir: knowledgeDirFor(repoRoot),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.3",
3
+ "version": "0.125.5",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {