mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09: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:
@@ -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