Files
Codeman/test/mcp-sync-registry.test.ts
T
Codeman maintainer b451b3851e 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>
2026-10-04 23:52:41 +02:00

77 lines
3.4 KiB
TypeScript

// @vitest-environment node
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
function withMcp(mcpConfig: unknown) {
const e = claude();
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
return CliEntrySchema.safeParse(e);
}
describe('capabilities.mcpConfig', () => {
it('is declared by exactly the CLIs whose format is verified', () => {
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
});
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
expect(new Set(paths).size).toBe(paths.length);
});
it('accepts a home-relative path with a known format', () => {
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' }],
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
['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);
});
});