mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(mcp): make sync opt-in and address review
Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes: - codex TOML read/validated with smol-toml: CRLF, inline tables and command-less tables no longer yield a duplicate [mcp_servers.x]; the new text is re-parsed before writing - null-prototype tables and own-key checks; unsafe names ignored at every level - servers switched off in their own CLI (codex/opencode/antigravity) are not copied - only CLIs that are installed or already have a config file take part - files receiving env/headers are left 0600; symlinked configs are written through - one apply at a time (409), unique tmp files cleaned on failure, failed status - routes set real HTTP status codes; api-reference section; format type single-sourced Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7616de13de
commit
4398dbfad0
+252
-27
@@ -1,16 +1,41 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { parseServers, addServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
||||
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
||||
|
||||
const TARGETS: McpSyncTarget[] = [
|
||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json' },
|
||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json' },
|
||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml' },
|
||||
{ id: 'antigravity', label: 'Antigravity', path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
|
||||
{ id: 'opencode', label: 'OpenCode', path: '.config/opencode/opencode.json', format: 'opencode-json' },
|
||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
|
||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
|
||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
path: '.gemini/config/mcp_config.json',
|
||||
format: 'antigravity-json',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
|
||||
let home: string;
|
||||
@@ -20,6 +45,12 @@ const put = (rel: string, text: string) => {
|
||||
writeFileSync(file, text);
|
||||
};
|
||||
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
|
||||
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
|
||||
...TARGETS.find((t) => t.id === id)!,
|
||||
...patch,
|
||||
});
|
||||
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
|
||||
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
|
||||
@@ -62,16 +93,27 @@ describe('dialect parsing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads gemini url (sse) vs httpUrl (http) and opencode local/remote', () => {
|
||||
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
|
||||
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
|
||||
});
|
||||
|
||||
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
|
||||
const g = parseServers(
|
||||
'gemini-json',
|
||||
JSON.stringify({
|
||||
mcpServers: { a: { url: 'https://a' }, b: { httpUrl: 'https://b' }, c: { command: 'c', args: ['1'] } },
|
||||
mcpServers: {
|
||||
a: { url: 'https://a' },
|
||||
b: { httpUrl: 'https://b' },
|
||||
c: { command: 'c', args: ['1'] },
|
||||
d: { url: 'https://d', type: 'http' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(g.a.transport).toBe('sse');
|
||||
expect(g.b.transport).toBe('http');
|
||||
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
|
||||
expect(g.d.transport).toBe('http');
|
||||
const o = parseServers(
|
||||
'opencode-json',
|
||||
JSON.stringify({
|
||||
@@ -85,8 +127,9 @@ describe('dialect parsing', () => {
|
||||
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
|
||||
});
|
||||
|
||||
it('throws on unparseable JSON so the file is never written', () => {
|
||||
it('throws on unparseable files so they are never written', () => {
|
||||
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
|
||||
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,19 +178,46 @@ describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () =>
|
||||
});
|
||||
|
||||
describe('hostile config files', () => {
|
||||
it('never lets a server name reach Object.prototype (toml and json)', () => {
|
||||
it('never lets a server name or a sub-table key reach Object.prototype', () => {
|
||||
const toml = parseServers(
|
||||
'codex-toml',
|
||||
'[mcp_servers.__proto__]\ncommand = "x"\npolluted = "yes"\n[mcp_servers.ok]\ncommand = "y"\n'
|
||||
[
|
||||
'[mcp_servers.__proto__]',
|
||||
'command = "x"',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs.__proto__]',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "y"',
|
||||
'[mcp_servers.toString]',
|
||||
'command = "t"',
|
||||
'call = "x"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(Object.keys(toml)).toEqual(['ok']);
|
||||
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
|
||||
const json = parseServers(
|
||||
'claude-json',
|
||||
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"ok":{"command":"y"}}}'
|
||||
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
|
||||
);
|
||||
expect(Object.keys(json)).toEqual(['ok']);
|
||||
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(({} as Record<string, unknown>).command).toBeUndefined();
|
||||
expect(typeof Object.prototype.toString.call).toBe('function');
|
||||
});
|
||||
|
||||
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(r.conflicts).toEqual([]);
|
||||
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
|
||||
'hasOwnProperty',
|
||||
'toString',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-object server table instead of overwriting it', () => {
|
||||
@@ -157,7 +227,7 @@ describe('hostile config files', () => {
|
||||
});
|
||||
|
||||
describe('addServers', () => {
|
||||
it('preserves other keys and existing servers, appends codex tables without touching the rest', () => {
|
||||
it('preserves other keys and existing servers', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
|
||||
keep: { transport: 'stdio', command: 'OVERWRITE' },
|
||||
@@ -167,7 +237,9 @@ describe('addServers', () => {
|
||||
expect(out.theme).toBe('dark');
|
||||
expect(out.mcpServers.keep).toEqual({ command: 'k' });
|
||||
expect(out.mcpServers.n.command).toBe('n');
|
||||
});
|
||||
|
||||
it('appends codex tables without touching the rest, and quotes odd names', () => {
|
||||
const toml = addServers('codex-toml', 'model = "x"\n', {
|
||||
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
|
||||
});
|
||||
@@ -179,6 +251,65 @@ describe('addServers', () => {
|
||||
env: { K: 'v' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
|
||||
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
|
||||
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
|
||||
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
|
||||
const out = addServers('codex-toml', existing, {
|
||||
fs: { transport: 'stdio', command: 'other' },
|
||||
extra: { transport: 'stdio', command: 'e' },
|
||||
});
|
||||
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
|
||||
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
|
||||
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
|
||||
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
|
||||
expect(out.startsWith(existing)).toBe(true);
|
||||
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('refuses to write when the result would not read back as intended', () => {
|
||||
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
|
||||
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled servers are not propagated', () => {
|
||||
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
|
||||
put(
|
||||
'.config/opencode/opencode.json',
|
||||
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
|
||||
);
|
||||
put(
|
||||
'.gemini/config/mcp_config.json',
|
||||
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
|
||||
);
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
|
||||
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
|
||||
// ...and each CLI still gets the live one.
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
|
||||
expect(get('.codex/config.toml')).not.toContain('off_oc');
|
||||
// The switched-off entry itself is left as it was (still disabled).
|
||||
expect(get('.codex/config.toml')).toContain('enabled = false');
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
|
||||
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
|
||||
expect(r.disabled).toEqual([]);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMcpServers', () => {
|
||||
@@ -186,27 +317,32 @@ describe('syncMcpServers', () => {
|
||||
numStartups: 3,
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
||||
});
|
||||
|
||||
it('passes the unsupported list through to the result', async () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
||||
expect(r.unsupported).toEqual(['Pi']);
|
||||
});
|
||||
const setUpAll = () => {
|
||||
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
put('.codex/config.toml', '');
|
||||
put('.config/opencode/opencode.json', '{}');
|
||||
put('.gemini/config/mcp_config.json', '{}');
|
||||
};
|
||||
|
||||
it('previews without writing and never leaks env values', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
const before = get('.gemini/settings.json');
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
||||
expect(r.applied).toBe(false);
|
||||
expect(r.targets.find((t) => t.id === 'gemini')!.added).toEqual(['fs']);
|
||||
expect(existsSync(join(home, '.gemini/settings.json'))).toBe(false);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
expect(get('.gemini/settings.json')).toBe(before);
|
||||
expect(JSON.stringify(r)).not.toContain('s3cret');
|
||||
});
|
||||
|
||||
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.targets.find((t) => t.id === 'claude')!.added).toEqual(['web']);
|
||||
expect(r.targets.find((t) => t.id === 'codex')!.added).toEqual(['fs']);
|
||||
expect(result(r, 'claude').added).toEqual(['web']);
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
|
||||
@@ -218,6 +354,7 @@ describe('syncMcpServers', () => {
|
||||
});
|
||||
|
||||
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
|
||||
setUpAll();
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
|
||||
@@ -228,8 +365,96 @@ describe('syncMcpServers', () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.conflicts).toEqual(['x']);
|
||||
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
|
||||
expect(r.targets.find((t) => t.id === 'codex')!.skipped).toEqual(['sse']);
|
||||
expect(r.targets.find((t) => t.id === 'opencode')!.status).toBe('unreadable');
|
||||
expect(result(r, 'codex').skipped).toEqual(['sse']);
|
||||
expect(result(r, 'opencode').status).toBe('unreadable');
|
||||
expect(get('.config/opencode/opencode.json')).toBe(broken);
|
||||
});
|
||||
|
||||
it('passes the unsupported list through to the result', async () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
||||
expect(r.unsupported).toEqual(['Pi']);
|
||||
});
|
||||
|
||||
describe('only CLIs that are installed or already have a config file take part', () => {
|
||||
it('never creates config for a CLI that is neither installed nor configured', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
|
||||
const r = await syncMcpServers(notInstalled, { apply: true, home });
|
||||
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(home, '.gemini'))).toBe(false);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
|
||||
it('an installed CLI with no config yet gets one created', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file safety', () => {
|
||||
it('leaves a file that receives env values or headers readable by its owner only', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps the existing mode when nothing secret is copied', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
|
||||
});
|
||||
|
||||
it('writes through a symlinked config instead of replacing the link', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, 'dotfiles'), { recursive: true });
|
||||
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
|
||||
'npx'
|
||||
);
|
||||
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dangling symlink as failed and writes nothing', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(result(r, 'gemini').status).toBe('failed');
|
||||
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
|
||||
McpSyncBusyError
|
||||
);
|
||||
await first;
|
||||
// A preview is read-only and is never refused.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
|
||||
// ...and the lock is released afterwards.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
|
||||
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* @fileoverview Route tests for /api/mcp-sync. Only CLIs that are ENABLED in the registry take
|
||||
* part; enabled agent CLIs with no known MCP config are reported as unsupported.
|
||||
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
|
||||
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
|
||||
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
|
||||
* config are reported as unsupported.
|
||||
*
|
||||
* ⚠️ 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 } from 'vitest';
|
||||
import { 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';
|
||||
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';
|
||||
|
||||
// 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.
|
||||
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
|
||||
// (after taking the lock) until released, so a second POST deterministically overlaps it.
|
||||
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
|
||||
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
|
||||
return {
|
||||
...actual,
|
||||
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
|
||||
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
|
||||
const parked = new Promise<void>((resolve) => (hold.release = resolve));
|
||||
hold.entered?.();
|
||||
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
|
||||
await parked;
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
|
||||
probeStockCliAvailability: async () => ({}),
|
||||
isCliEntryInstalled: () => false,
|
||||
}));
|
||||
|
||||
const home = () => homedir();
|
||||
const write = (rel: string, text: string) => {
|
||||
const f = join(home(), rel);
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const setEnabled = (on: boolean | undefined) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
|
||||
};
|
||||
const disable = (...ids: string[]) => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
@@ -37,26 +69,57 @@ beforeEach(() => {
|
||||
for (const d of ['.claude.json', '.codex', '.gemini', '.config'])
|
||||
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).
|
||||
write(CODEX, 'model = "gpt-5"\n');
|
||||
write(GEMINI, '{}');
|
||||
setEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync — opt-in', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
expect(res.json().error).toMatch(/disabled/i);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the toggle on the next request, with no restart', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
setEnabled(false);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync', () => {
|
||||
it('GET previews without writing', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.applied).toBe(false);
|
||||
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
|
||||
expect(existsSync(join(home(), CODEX))).toBe(false);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('POST adds the server to every enabled CLI', async () => {
|
||||
it('POST adds the server to every enabled, set-up CLI', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.json().data.applied).toBe(true);
|
||||
@@ -64,15 +127,25 @@ describe('/api/mcp-sync', () => {
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
|
||||
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
|
||||
expect(opencode.status).toBe('absent');
|
||||
expect(existsSync(join(home(), '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches a CLI that is disabled in the registry', async () => {
|
||||
disable('codex');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
|
||||
expect(ids).not.toContain('codex');
|
||||
expect(ids).toContain('gemini');
|
||||
expect(existsSync(join(home(), '.codex'))).toBe(false);
|
||||
expect(existsSync(join(home(), GEMINI))).toBe(true);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
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 () => {
|
||||
@@ -94,16 +167,33 @@ describe('/api/mcp-sync', () => {
|
||||
expect(res.body).not.toContain('sekrit-value');
|
||||
});
|
||||
|
||||
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
hold.release = () => undefined;
|
||||
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
|
||||
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
await entered; // the first apply now holds the lock
|
||||
const release = hold.release;
|
||||
hold.release = null; // the overlapping request goes straight to the real function
|
||||
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
release?.();
|
||||
expect((await first).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.json().success, method).toBe(false);
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
}
|
||||
expect(existsSync(join(home(), CODEX))).toBe(false);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('multi-user: an admin is allowed', async () => {
|
||||
|
||||
Reference in New Issue
Block a user