diff --git a/CLAUDE.md b/CLAUDE.md index 34607abe..bce504cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.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-` 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:"` (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) diff --git a/docs/api-reference.md b/docs/api-reference.md index e9e532ab..7b534ddc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 `.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 `.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. ## Webhook notifications diff --git a/docs/cli-registry.md b/docs/cli-registry.md index f6d64094..82ec20a0 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -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 `/`, 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 diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 34b4d6d5..4892d3be 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -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 diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index b99d8d68..5391c5ac 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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(), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 18dc2300..45c40c7a 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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 diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index df64f008..147188c5 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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 `/` 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 diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index 4072ff9d..1e092ad0 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -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; 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(); - 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(); - 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; +} + +/** + * 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 +): { 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 { const home = opts.home ?? homedir(); + const env = opts.env ?? (opts.home === undefined ? process.env : {}); const seen = new Set(); - 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 = []; } } diff --git a/src/types/index.ts b/src/types/index.ts index 4ba3cca3..1af34926 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; diff --git a/src/types/mcp-sync.ts b/src/types/mcp-sync.ts new file mode 100644 index 00000000..3a8cdebb --- /dev/null +++ b/src/types/mcp-sync.ts @@ -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[]; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 5d212e44..cc8f0337 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2505,7 +2505,7 @@
-

MCP servers

server
+

MCP servers

synced
@@ -2517,7 +2517,7 @@