mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user