fix(mcp): no file text in sync errors, follow relocated config dirs, docs and Settings polish (#521 review)

Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).

M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.

M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.

M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.

Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
  settings to turn MCP sync on first" instead of calling the routes; the 403 message also
  says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
  from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
  pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
  the route imported them, so no churn.

Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent 6d6e7da481
commit b451b3851e
16 changed files with 466 additions and 82 deletions
+2
View File
@@ -245,6 +245,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
+5 -2
View File
@@ -726,12 +726,15 @@ Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
The result carries server **names** only, never `env` values or `headers`. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
## Webhook notifications
+4 -2
View File
@@ -255,9 +255,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers. The schema restricts `path` to a home-relative path without `..`, since sync writes to it.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
## See also
+1
View File
@@ -122,6 +122,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
+12 -6
View File
@@ -28,6 +28,14 @@ const envName = z
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
const mcpRelativePath = z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
@@ -382,12 +390,7 @@ const capabilitiesSchema = z
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..'),
path: mcpRelativePath,
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
@@ -397,6 +400,9 @@ const capabilitiesSchema = z
'opencode-json',
'antigravity-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
})
.strict()
.optional(),
+24 -4
View File
@@ -307,7 +307,12 @@ const CLAUDE: CliEntry = {
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
mcpConfig: { path: '.claude.json', format: 'claude-json' },
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
format: 'claude-json',
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
},
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
// llama.cpp server. Claude reads these at process start only, so switching requires a
// respawn, never a live hot-swap.
@@ -488,7 +493,12 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
mcpConfig: { path: '.config/opencode/opencode.json', format: 'opencode-json' },
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
@@ -632,7 +642,11 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
mcpConfig: { path: '.codex/config.toml', format: 'codex-toml' },
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
},
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
@@ -734,7 +748,12 @@ const GEMINI: CliEntry = {
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
mcpConfig: { path: '.gemini/settings.json', format: 'gemini-json' },
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
mcpConfig: {
path: '.gemini/settings.json',
format: 'gemini-json',
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
},
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
// start). Confirm the exact model-override env var name against the installed
// gemini-cli version before shipping.
@@ -814,6 +833,7 @@ const ANTIGRAVITY: CliEntry = {
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
// custom-endpoint setting and explicitly say it "cannot currently" become the core
+8 -1
View File
@@ -530,8 +530,15 @@ export interface CliCapabilities {
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*
* `relocation` names the env var the CLI itself reads to move that file (codex's
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat };
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+91 -45
View File
@@ -25,9 +25,13 @@
* readable by its owner only.
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
* - Only one apply runs at a time.
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
* cannot be located safely, so that target is reported `skipped` and never written.
*
* The result types never carry env values or headers: those commonly hold secrets and the
* result is returned over HTTP.
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
*
* @module mcp-sync
*/
@@ -35,9 +39,10 @@
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
export type McpFormat = McpConfigFormat;
@@ -58,41 +63,15 @@ export type McpServerMap = Record<string, McpServer>;
export interface McpSyncTarget {
id: string;
label: string;
/** Home-relative default location of the config file. */
path: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
installed: boolean;
}
export interface McpSyncTargetResult {
id: string;
label: string;
file: string;
/**
* `absent`: not installed and no config file, so neither read nor created.
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
* `failed`: a read or write error (the file may be unchanged).
*/
status: 'ok' | 'absent' | 'unreadable' | 'failed';
error?: string;
servers: string[];
/** Servers added (apply) or that would be added (plan). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** Names defined differently by different CLIs; existing definitions are left untouched. */
conflicts: string[];
/** Names left out because the only definitions are switched off in their own CLI. */
disabled: string[];
/** Enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
unsupported: string[];
}
/** A second apply was requested while one was running. */
export class McpSyncBusyError extends Error {
constructor() {
@@ -101,6 +80,39 @@ export class McpSyncBusyError extends Error {
}
}
/**
* An error whose message this module wrote itself. It names keys Codeman chose and server names
* (which the result reports anyway), never a value from the file, so it may be shown as is.
*/
class McpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'McpConfigError';
}
}
/**
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
* "Unexpected token" errors quote about ten characters of source. These files hold env values
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
* else by a fixed category.
*/
function describeMcpSyncError(err: unknown): string {
if (err instanceof McpConfigError) return err.message;
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
if (err instanceof SyntaxError) {
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
const pos = /at position (\d+)/.exec(err.message);
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
}
const code = (err as NodeJS.ErrnoException | null)?.code;
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
return 'unexpected error';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -349,15 +361,15 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
const doc = parseToml(text);
const table = doc.mcp_servers;
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new Error('"mcp_servers" is not a table');
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
return table;
}
const dialect = JSON_DIALECTS[format];
const doc: unknown = JSON.parse(text);
if (!isRecord(doc)) throw new Error('top level is not a JSON object');
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new Error(`"${dialect.key}" is not an object`);
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
}
@@ -434,12 +446,12 @@ export function addServers(format: McpFormat, text: string | null, add: McpServe
// Re-read what we are about to write.
const after = parseConfig(format, out);
for (const n of before.names) {
if (!after.names.has(n)) throw new Error(`refusing to write: "${n}" would be lost`);
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
}
for (const n of names) {
const got = after.servers[n];
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
throw new Error(`refusing to write: "${n}" does not read back as written`);
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
}
}
return out;
@@ -481,7 +493,7 @@ async function writeAtomic(file: string, text: string, secret: boolean): Promise
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
try {
await fs.lstat(file);
throw new Error('config path is a dangling symlink');
throw new McpConfigError('config path is a dangling symlink');
} catch (inner) {
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
}
@@ -514,6 +526,36 @@ export interface McpSyncOptions {
/** false = report what would change without writing. */
apply: boolean;
home?: string;
/**
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
* env can never aim a write outside that home.
*/
env?: Record<string, string | undefined>;
}
/**
* The config file a target means, honouring its relocation env var. `skip` is set when the var
* holds something that cannot be located safely (a relative path resolves against the CLI's
* working directory, which differs per session), so the target is neither read nor written.
*/
function resolveFile(
t: McpSyncTarget,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/${rel.path}`,
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
};
}
return { file: join(dir, rel.path) };
}
let applying = false;
@@ -541,16 +583,19 @@ export async function syncMcpServers(
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
const home = opts.home ?? homedir();
const env = opts.env ?? (opts.home === undefined ? process.env : {});
const seen = new Set<string>();
const live = targets.filter((t) => (seen.has(t.path) ? false : (seen.add(t.path), true)));
const live = targets
.map((t) => ({ t, ...resolveFile(t, home, env) }))
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
const state = live.map((t) => {
const file = join(home, t.path);
const state = live.map(({ t, file, skip }) => {
const res: McpSyncTargetResult = {
id: t.id,
label: t.label,
file,
status: 'ok',
status: skip ? 'skipped' : 'ok',
...(skip ? { error: skip } : {}),
servers: [],
added: [],
skipped: [],
@@ -559,6 +604,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
});
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
@@ -570,7 +616,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
s.res.servers = [...parsed.names];
} catch (err) {
s.res.status = 'unreadable';
s.res.error = err instanceof Error ? err.message : String(err);
s.res.error = describeMcpSyncError(err);
}
}
@@ -618,7 +664,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
s.res.added = written;
} catch (err) {
s.res.status = 'failed';
s.res.error = err instanceof Error ? err.message : String(err);
s.res.error = describeMcpSyncError(err);
s.res.added = [];
}
}
+2
View File
@@ -27,6 +27,7 @@
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
*
* ## Cross-domain relationship map
*
@@ -72,3 +73,4 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
*
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
*/
/** One participating CLI in a sync result. */
export interface McpSyncTargetResult {
id: string;
label: string;
/** The config file read (and written). For a `skipped` target, the unresolved location. */
file: string;
/**
* `absent`: not installed and no config file, so neither read nor created.
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
* var is a relative path), so it is neither read nor written; `error` says why.
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
* `failed`: a read or write error (the file may be unchanged).
*/
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
/** Why the target is not `ok`. Position or category only, never file content. */
error?: string;
servers: string[];
/** Servers added (apply) or that would be added (plan). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
/** The `data` of `GET`/`POST /api/mcp-sync`. */
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** Names defined differently by different CLIs; existing definitions are left untouched. */
conflicts: string[];
/** Names left out because the only definitions are switched off in their own CLI. */
disabled: string[];
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
unsupported: string[];
}
+2 -2
View File
@@ -2505,7 +2505,7 @@
</div>
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">server</span></div>
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
@@ -2517,7 +2517,7 @@
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync).</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
+27 -2
View File
@@ -417,7 +417,10 @@ Object.assign(CodemanApp.prototype, {
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
document.getElementById('appSettingsMcpSync').checked = settings.mcpSyncEnabled === true;
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
@@ -1173,6 +1176,20 @@ Object.assign(CodemanApp.prototype, {
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
this._applyMcpSyncAdminGate();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate() {
const group = document.getElementById('mcpSyncGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
@@ -1181,6 +1198,11 @@ Object.assign(CodemanApp.prototype, {
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
@@ -1193,12 +1215,14 @@ Object.assign(CodemanApp.prototype, {
const data = body.data;
const rows = data.targets.map((t) => {
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
const verb = data.applied ? 'added' : 'would add';
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
return `<li><b>${escapeHtml(t.label)}</b> (${t.servers.length} servers): ${parts.join('; ')}</li>`;
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
@@ -4348,4 +4372,5 @@ Object.assign(CodemanApp.prototype, {
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
});
+26 -11
View File
@@ -7,20 +7,27 @@
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values or headers.
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created.
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncResult, type McpSyncTarget } from '../../mcp-sync.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
@@ -29,8 +36,7 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export async function mcpSyncTargets(): Promise<McpSyncTarget[]> {
const availability = await probeStockCliAvailability();
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
@@ -42,10 +48,13 @@ export async function mcpSyncTargets(): Promise<McpSyncTarget[]> {
}));
}
/** Enabled agent CLIs with no known MCP config file (sync cannot touch them). */
export function mcpUnsupportedLabels(): string[] {
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig)
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
@@ -54,7 +63,10 @@ async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'MCP sync is disabled. Enable it in Settings first.');
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
@@ -67,7 +79,10 @@ export function registerMcpSyncRoutes(app: FastifyInstance): void {
return denied;
}
try {
return { success: true, data: await syncMcpServers(await mcpSyncTargets(), { apply }, mcpUnsupportedLabels()) };
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
+32
View File
@@ -31,6 +31,26 @@ describe('capabilities.mcpConfig', () => {
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
});
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
const reloc = Object.fromEntries(
STOCK_CLIS.flatMap((e) =>
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
)
);
expect(reloc).toEqual({
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
antigravity: undefined,
});
});
it('accepts a relocation with an env var name and a relative path', () => {
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
expect(withMcp(value).success).toBe(true);
});
it.each([
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
@@ -38,6 +58,18 @@ describe('capabilities.mcpConfig', () => {
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
[
'relocation path traversal',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
],
[
'relocation absolute path',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
],
[
'relocation env var that is not a name',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
],
])('rejects %s', (_label, value) => {
expect(withMcp(value).success).toBe(false);
});
+127
View File
@@ -458,3 +458,130 @@ describe('syncMcpServers', () => {
});
});
});
describe('error messages never quote the config file (it holds env values and headers)', () => {
const claudeWithSecret = JSON.stringify({
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
});
it('reports an unparseable TOML file by line and column only', async () => {
put('.claude.json', claudeWithSecret);
put(
'.codex/config.toml',
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
});
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
put('.claude.json', claudeWithSecret);
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
expect(result(r, 'gemini').status).toBe('unreadable');
expect(result(r, 'gemini').error).toBe('not valid JSON');
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
const body = JSON.stringify(r);
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
});
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
put('.claude.json', claudeWithSecret);
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
put('.codex/config.toml', inline);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('failed');
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
expect(get('.codex/config.toml')).toBe(inline);
const body = JSON.stringify(r);
expect(body).not.toContain('sk-FILE-SECRET');
expect(body).not.toContain('sk-COPIED-SECRET');
});
});
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
put('.claude.json', claudeFile);
const codexHome = join(home, 'elsewhere/codex');
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: codexHome },
});
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
expect(result(r, 'codex').added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(existsSync(join(home, '.codex'))).toBe(false);
});
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
const claudeDir = join(home, 'accounts/work');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
const xdg = join(home, 'xdg');
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
apply: true,
home,
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
});
expect(result(r, 'claude').servers).toEqual(['fs']);
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
expect(existsSync(join(home, '.config'))).toBe(false);
});
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: 'relative/codex' },
});
const codex = result(r, 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
expect(codex.added).toEqual([]);
expect(existsSync(join(home, '.codex'))).toBe(false);
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
});
it('an empty value means unset, as it does for the CLI', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: '' },
});
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
});
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
put('.claude.json', claudeFile);
const saved = process.env.CODEX_HOME;
process.env.CODEX_HOME = join(home, 'from-process-env');
try {
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
} finally {
if (saved === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = saved;
}
});
});
+62 -7
View File
@@ -7,7 +7,7 @@
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
* creates. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -15,6 +15,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
// is installed, so only a CLI whose config file exists takes part.
@@ -36,11 +37,28 @@ vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
};
});
// Nothing is installed unless a test adds the id here.
const installed = vi.hoisted(() => new Set<string>());
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
probeStockCliAvailability: async () => ({}),
isCliEntryInstalled: () => false,
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
}));
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
return envVar ? [envVar] : [];
});
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
afterAll(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
const home = () => homedir();
const write = (rel: string, text: string) => {
const f = join(home(), rel);
@@ -64,9 +82,11 @@ const CODEX = '.codex/config.toml';
const GEMINI = '.gemini/settings.json';
beforeEach(() => {
for (const k of RELOCATION_VARS) delete process.env[k];
installed.clear();
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
for (const d of ['.claude.json', '.codex', '.gemini', '.config'])
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
rmSync(join(home(), d), { recursive: true, force: true });
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
// Codex and Gemini have been set up on this machine (their config files exist).
@@ -148,13 +168,48 @@ describe('/api/mcp-sync', () => {
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
});
it('lists enabled agent CLIs without MCP support, and omits disabled ones and the shell', async () => {
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
installed.add('grok').add('pi');
disable('pi');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
expect(unsupported).toContain('Grok');
expect(unsupported).not.toContain('Pi');
expect(unsupported.some((l: string) => /shell|terminal/i.test(l))).toBe(false);
expect(unsupported).toEqual(['Grok']);
});
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
const codexHome = join(home(), 'relocated/codex');
process.env.CODEX_HOME = codexHome;
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.file).toBe(join(codexHome, 'config.toml'));
expect(codex.added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
process.env.CODEX_HOME = 'relative/codex';
installed.add('codex');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME/);
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('never echoes the text of a config file it cannot parse', async () => {
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(res.body).not.toContain('lin_SECRET_abc');
});
it('never returns env values or headers', async () => {