From 07d50c26a687eda160cf6081969b234fbcd4bfe8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:51:54 -0700 Subject: [PATCH 1/5] feat(cli): commonly mcp config prints an MCP config for an attached agent (TASK-666) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/mcp-config.test.mjs | 54 ++++++++++++++++++++++ cli/src/commands/mcp.js | 63 ++++++++++++++++++++++++++ cli/src/index.js | 2 + commonly-mcp/__tests__/client.test.mjs | 7 +++ commonly-mcp/src/client.js | 2 +- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 cli/__tests__/mcp-config.test.mjs create mode 100644 cli/src/commands/mcp.js diff --git a/cli/__tests__/mcp-config.test.mjs b/cli/__tests__/mcp-config.test.mjs new file mode 100644 index 0000000000..107bda0a94 --- /dev/null +++ b/cli/__tests__/mcp-config.test.mjs @@ -0,0 +1,54 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-config-')); +jest.unstable_mockModule('os', () => ({ ...os, default: { ...os, homedir: () => tmp }, homedir: () => tmp })); + +const { buildMcpConfig } = await import('../src/commands/mcp.js'); + +const TOKEN = 'cm_agent_secretvalue123'; +const tokensDir = path.join(tmp, '.commonly', 'tokens'); + +const seed = (name, extra = {}) => { + fs.mkdirSync(tokensDir, { recursive: true }); + fs.writeFileSync(path.join(tokensDir, `${name}.json`), + JSON.stringify({ runtimeToken: TOKEN, instanceUrl: 'https://api.example.test', podId: 'p1', ...extra })); +}; + +beforeEach(() => fs.rmSync(path.join(tmp, '.commonly'), { recursive: true, force: true })); + +describe('commonly mcp config', () => { + it('points at a 0600 token file and never prints the token', () => { + seed('lead'); + const config = buildMcpConfig({}); + const env = config.mcpServers.commonly.env; + expect(JSON.stringify(config)).not.toContain(TOKEN); + expect(env.COMMONLY_API_URL).toBe('https://api.example.test'); + expect(fs.readFileSync(env.COMMONLY_TOKEN_FILE, 'utf8').trim()).toBe(TOKEN); + expect(fs.statSync(env.COMMONLY_TOKEN_FILE).mode & 0o777).toBe(0o600); + }); + + it('keeps 0600 when the file already exists with a looser mode', () => { + seed('lead'); + const first = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE; + fs.chmodSync(first, 0o644); + buildMcpConfig({ agent: 'lead' }); + expect(fs.statSync(first).mode & 0o777).toBe(0o600); + }); + + it('names the attach command when no agent exists', () => { + expect(() => buildMcpConfig({})).toThrow(/commonly agent attach/); + }); + + it('asks for --agent when several are attached', () => { + seed('a'); seed('b'); + expect(() => buildMcpConfig({})).toThrow(/--agent/); + expect(buildMcpConfig({ agent: 'b' }).mcpServers.commonly.env.COMMONLY_TOKEN_FILE).toMatch(/b\.token$/); + }); + + it('rejects a path-like agent name', () => { + expect(() => buildMcpConfig({ agent: '../x' })).toThrow(/Invalid agent name/); + }); +}); diff --git a/cli/src/commands/mcp.js b/cli/src/commands/mcp.js new file mode 100644 index 0000000000..c75325b3b9 --- /dev/null +++ b/cli/src/commands/mcp.js @@ -0,0 +1,63 @@ +/** + * commonly mcp config — print an MCP server config for a locally attached agent (TASK-666). + * The MCP server only accepts a cm_agent_* runtime token, so the config points + * COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token. + */ +import { chmodSync, mkdirSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { listLocalAgents, loadAgentToken } from './agent.js'; + +const mcpTokenDir = () => join(homedir(), '.commonly', 'mcp'); + +const NO_AGENT_HINT = 'Attach one first: commonly agent attach --pod --name '; + +export const buildMcpConfig = ({ agent } = {}) => { + let name = agent; + if (!name) { + const local = listLocalAgents(); + if (local.length === 0) throw new Error(`No local agent has a runtime token on this computer. ${NO_AGENT_HINT}`); + if (local.length > 1) { + throw new Error(`More than one local agent (${local.map((a) => a.name).join(', ')}). Pick one with --agent .`); + } + name = local[0].name; + } + if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith('.')) throw new Error(`Invalid agent name '${name}'`); + const record = loadAgentToken(name); + const token = String(record?.runtimeToken || '').trim(); + if (!token) throw new Error(`No runtime token saved for agent '${name}'. ${NO_AGENT_HINT}`); + if (!record.instanceUrl) throw new Error(`Agent '${name}' has no saved instance URL. Re-attach it.`); + + const dir = mcpTokenDir(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tokenPath = join(dir, `${name}.token`); + // mode applies on create only; chmod keeps a pre-existing file at 0600. + writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 }); + chmodSync(tokenPath, 0o600); + + return { + mcpServers: { + commonly: { + command: 'npx', + args: ['-y', '@commonlyai/mcp@latest'], + env: { COMMONLY_API_URL: record.instanceUrl, COMMONLY_TOKEN_FILE: tokenPath }, + }, + }, + }; +}; + +export const registerMcp = (program) => { + const mcp = program.command('mcp').description('Connect an MCP client (Claude Code, Codex, Cursor) to Commonly'); + mcp + .command('config') + .description('Print an MCP server config for a locally attached agent; the token stays in a 0600 file') + .option('--agent ', 'Local agent to use (default: the only one)') + .action((opts) => { + try { + console.log(JSON.stringify(buildMcpConfig({ agent: opts.agent }), null, 2)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } + }); +}; diff --git a/cli/src/index.js b/cli/src/index.js index 59b2762b81..8b933fd250 100755 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -22,6 +22,7 @@ import { registerPod } from './commands/pod.js'; import { registerDev } from './commands/dev.js'; import { registerVerify } from './commands/verify.js'; import { registerDoctor } from './commands/doctor.js'; +import { registerMcp } from './commands/mcp.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')); @@ -57,6 +58,7 @@ registerVerify(program); // Install health: one line per check (TASK-660) registerDoctor(program); +registerMcp(program); // Each subcommand owns its own `--instance ` flag; a program-level // duplicate shadowed the subcommand value on commander v12, causing diff --git a/commonly-mcp/__tests__/client.test.mjs b/commonly-mcp/__tests__/client.test.mjs index 7e55ce0e81..2cf7a13043 100644 --- a/commonly-mcp/__tests__/client.test.mjs +++ b/commonly-mcp/__tests__/client.test.mjs @@ -33,6 +33,13 @@ describe('loadConfig', () => { COMMONLY_AGENT_TOKEN: 'sk-not-an-agent-token', })).toThrow(/cm_agent_/); }); + + it('names the command that fixes a user-token refusal', () => { + expect(() => loadConfig({ + COMMONLY_API_URL: 'https://x.example', + COMMONLY_AGENT_TOKEN: 'eyJuser.jwt', + })).toThrow(/commonly mcp config/); + }); }); describe('request', () => { diff --git a/commonly-mcp/src/client.js b/commonly-mcp/src/client.js index 9479e568d4..c49920a62e 100644 --- a/commonly-mcp/src/client.js +++ b/commonly-mcp/src/client.js @@ -117,7 +117,7 @@ export const loadConfig = (env = process.env, opts = {}) => { throw new Error('No runtime token: COMMONLY_TOKEN_FD must name a pipe, COMMONLY_TOKEN_FILE must name a file, or COMMONLY_AGENT_TOKEN must be set (cm_agent_* runtime token)'); } if (!token.startsWith('cm_agent_')) { - throw new Error('The runtime token must be a cm_agent_* token, whichever channel delivered it'); + throw new Error('The runtime token must be a cm_agent_* token, whichever channel delivered it (a user login token is refused). Run `commonly mcp config` to get a working config for an attached agent, or `commonly agent attach` first'); } return Object.freeze({ baseUrl: baseUrl.replace(/\/$/, ''), From df60048ea7bb238397842819e318b819fd8ce9ce Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:52:34 -0700 Subject: [PATCH 2/5] fix(cli): mcp config removes then exclusively creates the token file, tightens dir, checks prefix (TASK-666) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/mcp-config.test.mjs | 21 +++++++++++++++++++++ cli/src/commands/mcp.js | 10 ++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/mcp-config.test.mjs b/cli/__tests__/mcp-config.test.mjs index 107bda0a94..f01eb7039a 100644 --- a/cli/__tests__/mcp-config.test.mjs +++ b/cli/__tests__/mcp-config.test.mjs @@ -38,6 +38,27 @@ describe('commonly mcp config', () => { expect(fs.statSync(first).mode & 0o777).toBe(0o600); }); + it('does not write through a symlink at the token path', () => { + seed('lead'); + const dir = path.join(tmp, '.commonly', 'mcp'); + fs.mkdirSync(dir, { recursive: true }); + const victim = path.join(tmp, 'victim'); + fs.writeFileSync(victim, 'keep'); + fs.symlinkSync(victim, path.join(dir, 'lead.token')); + buildMcpConfig({ agent: 'lead' }); + expect(fs.readFileSync(victim, 'utf8')).toBe('keep'); + }); + + it('tightens a pre-existing loose mcp dir and refuses a non-agent token', () => { + seed('lead'); + const dir = path.join(tmp, '.commonly', 'mcp'); + fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); + buildMcpConfig({ agent: 'lead' }); + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + seed('user', { runtimeToken: 'eyJuser.jwt' }); + expect(() => buildMcpConfig({ agent: 'user' })).toThrow(/cm_agent_/); + }); + it('names the attach command when no agent exists', () => { expect(() => buildMcpConfig({})).toThrow(/commonly agent attach/); }); diff --git a/cli/src/commands/mcp.js b/cli/src/commands/mcp.js index c75325b3b9..00554e5fee 100644 --- a/cli/src/commands/mcp.js +++ b/cli/src/commands/mcp.js @@ -3,7 +3,7 @@ * The MCP server only accepts a cm_agent_* runtime token, so the config points * COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token. */ -import { chmodSync, mkdirSync, writeFileSync } from 'fs'; +import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { listLocalAgents, loadAgentToken } from './agent.js'; @@ -26,14 +26,16 @@ export const buildMcpConfig = ({ agent } = {}) => { const record = loadAgentToken(name); const token = String(record?.runtimeToken || '').trim(); if (!token) throw new Error(`No runtime token saved for agent '${name}'. ${NO_AGENT_HINT}`); + if (!token.startsWith('cm_agent_')) throw new Error(`The saved token for '${name}' is not a cm_agent_* runtime token. Re-attach it: commonly agent attach`); if (!record.instanceUrl) throw new Error(`Agent '${name}' has no saved instance URL. Re-attach it.`); const dir = mcpTokenDir(); mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); const tokenPath = join(dir, `${name}.token`); - // mode applies on create only; chmod keeps a pre-existing file at 0600. - writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 }); - chmodSync(tokenPath, 0o600); + // Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink. + rmSync(tokenPath, { force: true }); + writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); return { mcpServers: { From 91d4629f4c658170f9b9c313c11955b6c9ac64e2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:03:10 -0700 Subject: [PATCH 3/5] fix(cli): the MCP token copy follows the agent token: detach removes it, reattach rewrites it (TASK-666) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/mcp-config.test.mjs | 18 ++++++++++++++++++ cli/src/commands/agent.js | 3 +++ cli/src/commands/mcp.js | 14 ++------------ cli/src/lib/mcp-token.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 cli/src/lib/mcp-token.js diff --git a/cli/__tests__/mcp-config.test.mjs b/cli/__tests__/mcp-config.test.mjs index f01eb7039a..23a07bb6ca 100644 --- a/cli/__tests__/mcp-config.test.mjs +++ b/cli/__tests__/mcp-config.test.mjs @@ -7,6 +7,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-config-')); jest.unstable_mockModule('os', () => ({ ...os, default: { ...os, homedir: () => tmp }, homedir: () => tmp })); const { buildMcpConfig } = await import('../src/commands/mcp.js'); +const { performDetach, saveAgentToken } = await import('../src/commands/agent.js'); const TOKEN = 'cm_agent_secretvalue123'; const tokensDir = path.join(tmp, '.commonly', 'tokens'); @@ -59,6 +60,23 @@ describe('commonly mcp config', () => { expect(() => buildMcpConfig({ agent: 'user' })).toThrow(/cm_agent_/); }); + it('detach (even --force, local only) deletes the MCP copy', async () => { + seed('lead'); + const copy = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE; + await performDetach({ agentName: 'lead', skipBackend: true }); + expect(fs.existsSync(copy)).toBe(false); + }); + + it('reattach (saveAgentToken) rewrites an existing MCP copy, and creates none otherwise', () => { + seed('lead'); + saveAgentToken('lead', { runtimeToken: 'cm_agent_before', instanceUrl: 'https://api.example.test' }); + expect(fs.existsSync(path.join(tmp, '.commonly', 'mcp', 'lead.token'))).toBe(false); + const copy = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE; + saveAgentToken('lead', { runtimeToken: 'cm_agent_after', instanceUrl: 'https://api.example.test' }); + expect(fs.readFileSync(copy, 'utf8').trim()).toBe('cm_agent_after'); + expect(fs.statSync(copy).mode & 0o777).toBe(0o600); + }); + it('names the attach command when no agent exists', () => { expect(() => buildMcpConfig({})).toThrow(/commonly agent attach/); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 209da246c5..04eaee7ba4 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -16,6 +16,7 @@ import { createRuntimeReporter } from '../lib/runtime-observation.js'; * heartbeat — manually trigger an agent's heartbeat */ +import { refreshMcpTokenCopy, removeMcpTokenCopy } from '../lib/mcp-token.js'; import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs'; import { join, dirname, basename, resolve as pathResolve } from 'path'; import { homedir, tmpdir } from 'os'; @@ -139,6 +140,7 @@ const tokensDir = () => join(homedir(), '.commonly', 'tokens'); const tokenFile = (name) => join(tokensDir(), `${name}.json`); export const saveAgentToken = (name, record) => { + refreshMcpTokenCopy(name, record?.runtimeToken); if (!existsSync(tokensDir())) mkdirSync(tokensDir(), { recursive: true }); // mode applies on create only — the record carries a live cm_agent_* secret, // same handling as performInit's .commonly-env. @@ -162,6 +164,7 @@ export const loadAgentToken = (name) => { export const deleteAgentToken = (name) => { const file = tokenFile(name); if (existsSync(file)) rmSync(file); + removeMcpTokenCopy(name); }; // ── env-var bootstrap for `agent run` (#913) ──────────────────────────────── diff --git a/cli/src/commands/mcp.js b/cli/src/commands/mcp.js index 00554e5fee..30078e5880 100644 --- a/cli/src/commands/mcp.js +++ b/cli/src/commands/mcp.js @@ -3,12 +3,8 @@ * The MCP server only accepts a cm_agent_* runtime token, so the config points * COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token. */ -import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; import { listLocalAgents, loadAgentToken } from './agent.js'; - -const mcpTokenDir = () => join(homedir(), '.commonly', 'mcp'); +import { writeMcpTokenCopy } from '../lib/mcp-token.js'; const NO_AGENT_HINT = 'Attach one first: commonly agent attach --pod --name '; @@ -29,13 +25,7 @@ export const buildMcpConfig = ({ agent } = {}) => { if (!token.startsWith('cm_agent_')) throw new Error(`The saved token for '${name}' is not a cm_agent_* runtime token. Re-attach it: commonly agent attach`); if (!record.instanceUrl) throw new Error(`Agent '${name}' has no saved instance URL. Re-attach it.`); - const dir = mcpTokenDir(); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - chmodSync(dir, 0o700); - const tokenPath = join(dir, `${name}.token`); - // Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink. - rmSync(tokenPath, { force: true }); - writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + const tokenPath = writeMcpTokenCopy(name, token); return { mcpServers: { diff --git a/cli/src/lib/mcp-token.js b/cli/src/lib/mcp-token.js new file mode 100644 index 0000000000..bd4fad73f4 --- /dev/null +++ b/cli/src/lib/mcp-token.js @@ -0,0 +1,28 @@ +/** + * The 0600 copy of an agent's runtime token that `commonly mcp config` points + * COMMONLY_TOKEN_FILE at (TASK-666). It is a second live secret, so the agent + * token's lifecycle owns it: save refreshes it, delete removes it. + */ +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +export const mcpTokenPath = (name) => join(homedir(), '.commonly', 'mcp', `${name}.token`); + +// Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink. +export const writeMcpTokenCopy = (name, token) => { + const dir = join(homedir(), '.commonly', 'mcp'); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const path = mcpTokenPath(name); + rmSync(path, { force: true }); + writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + return path; +}; + +export const removeMcpTokenCopy = (name) => rmSync(mcpTokenPath(name), { force: true }); + +// Reattach mints a new token; a copy that already exists must follow it. +export const refreshMcpTokenCopy = (name, token) => { + if (token && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token); +}; From 438fd9b40afb8254494df8f0b186b5fc70044200 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:04:11 -0700 Subject: [PATCH 4/5] fix(cli): validate the agent name in the MCP token helpers; refresh after the record is saved (TASK-666) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/mcp-config.test.mjs | 9 +++++++++ cli/src/commands/agent.js | 7 ++++++- cli/src/commands/mcp.js | 4 ++-- cli/src/lib/mcp-token.js | 14 +++++++++++--- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/mcp-config.test.mjs b/cli/__tests__/mcp-config.test.mjs index 23a07bb6ca..82e1d48edb 100644 --- a/cli/__tests__/mcp-config.test.mjs +++ b/cli/__tests__/mcp-config.test.mjs @@ -77,6 +77,15 @@ describe('commonly mcp config', () => { expect(fs.statSync(copy).mode & 0o777).toBe(0o600); }); + it('a traversal name cannot make remove or refresh touch anything, and does not block detach', async () => { + const outside = path.join(tmp, '.commonly', 'x.token'); + fs.mkdirSync(path.dirname(outside), { recursive: true }); + fs.writeFileSync(outside, 'keep'); + saveAgentToken('../x', { runtimeToken: 'cm_agent_t', instanceUrl: 'u' }); + await performDetach({ agentName: '../x', skipBackend: true }); + expect(fs.readFileSync(outside, 'utf8')).toBe('keep'); + }); + it('names the attach command when no agent exists', () => { expect(() => buildMcpConfig({})).toThrow(/commonly agent attach/); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 04eaee7ba4..854c63ee78 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -140,7 +140,6 @@ const tokensDir = () => join(homedir(), '.commonly', 'tokens'); const tokenFile = (name) => join(tokensDir(), `${name}.json`); export const saveAgentToken = (name, record) => { - refreshMcpTokenCopy(name, record?.runtimeToken); if (!existsSync(tokensDir())) mkdirSync(tokensDir(), { recursive: true }); // mode applies on create only — the record carries a live cm_agent_* secret, // same handling as performInit's .commonly-env. @@ -149,6 +148,12 @@ export const saveAgentToken = (name, record) => { JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 }, ); + // After the record is durable, so a failed refresh never loses a freshly minted token. + try { + refreshMcpTokenCopy(name, record?.runtimeToken); + } catch (err) { + console.warn(`Could not refresh the MCP token copy for '${name}': ${err.message}. Run: commonly mcp config --agent ${name}`); + } }; export const loadAgentToken = (name) => { diff --git a/cli/src/commands/mcp.js b/cli/src/commands/mcp.js index 30078e5880..e0bcdd7b6d 100644 --- a/cli/src/commands/mcp.js +++ b/cli/src/commands/mcp.js @@ -4,7 +4,7 @@ * COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token. */ import { listLocalAgents, loadAgentToken } from './agent.js'; -import { writeMcpTokenCopy } from '../lib/mcp-token.js'; +import { validMcpTokenName, writeMcpTokenCopy } from '../lib/mcp-token.js'; const NO_AGENT_HINT = 'Attach one first: commonly agent attach --pod --name '; @@ -18,7 +18,7 @@ export const buildMcpConfig = ({ agent } = {}) => { } name = local[0].name; } - if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith('.')) throw new Error(`Invalid agent name '${name}'`); + if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`); const record = loadAgentToken(name); const token = String(record?.runtimeToken || '').trim(); if (!token) throw new Error(`No runtime token saved for agent '${name}'. ${NO_AGENT_HINT}`); diff --git a/cli/src/lib/mcp-token.js b/cli/src/lib/mcp-token.js index bd4fad73f4..05879583db 100644 --- a/cli/src/lib/mcp-token.js +++ b/cli/src/lib/mcp-token.js @@ -7,7 +7,12 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; -export const mcpTokenPath = (name) => join(homedir(), '.commonly', 'mcp', `${name}.token`); +export const validMcpTokenName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.'); + +export const mcpTokenPath = (name) => { + if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`); + return join(homedir(), '.commonly', 'mcp', `${name}.token`); +}; // Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink. export const writeMcpTokenCopy = (name, token) => { @@ -20,9 +25,12 @@ export const writeMcpTokenCopy = (name, token) => { return path; }; -export const removeMcpTokenCopy = (name) => rmSync(mcpTokenPath(name), { force: true }); +// A bad name never has a copy, so remove and refresh are no-ops for it and cannot block detach or save. +export const removeMcpTokenCopy = (name) => { + if (validMcpTokenName(name)) rmSync(mcpTokenPath(name), { force: true }); +}; // Reattach mints a new token; a copy that already exists must follow it. export const refreshMcpTokenCopy = (name, token) => { - if (token && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token); + if (token && validMcpTokenName(name) && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token); }; From 9f76109356c41d83f3342d4f5bb6e835437ce9d9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:24:29 -0700 Subject: [PATCH 5/5] test(cli): guard POSIX mode assertions on Windows (TASK-666) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/mcp-config.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/mcp-config.test.mjs b/cli/__tests__/mcp-config.test.mjs index 82e1d48edb..a603484fea 100644 --- a/cli/__tests__/mcp-config.test.mjs +++ b/cli/__tests__/mcp-config.test.mjs @@ -28,7 +28,7 @@ describe('commonly mcp config', () => { expect(JSON.stringify(config)).not.toContain(TOKEN); expect(env.COMMONLY_API_URL).toBe('https://api.example.test'); expect(fs.readFileSync(env.COMMONLY_TOKEN_FILE, 'utf8').trim()).toBe(TOKEN); - expect(fs.statSync(env.COMMONLY_TOKEN_FILE).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') expect(fs.statSync(env.COMMONLY_TOKEN_FILE).mode & 0o777).toBe(0o600); }); it('keeps 0600 when the file already exists with a looser mode', () => { @@ -36,7 +36,7 @@ describe('commonly mcp config', () => { const first = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE; fs.chmodSync(first, 0o644); buildMcpConfig({ agent: 'lead' }); - expect(fs.statSync(first).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') expect(fs.statSync(first).mode & 0o777).toBe(0o600); }); it('does not write through a symlink at the token path', () => { @@ -55,7 +55,7 @@ describe('commonly mcp config', () => { const dir = path.join(tmp, '.commonly', 'mcp'); fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); buildMcpConfig({ agent: 'lead' }); - expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') expect(fs.statSync(dir).mode & 0o777).toBe(0o700); seed('user', { runtimeToken: 'eyJuser.jwt' }); expect(() => buildMcpConfig({ agent: 'user' })).toThrow(/cm_agent_/); }); @@ -74,7 +74,7 @@ describe('commonly mcp config', () => { const copy = buildMcpConfig({}).mcpServers.commonly.env.COMMONLY_TOKEN_FILE; saveAgentToken('lead', { runtimeToken: 'cm_agent_after', instanceUrl: 'https://api.example.test' }); expect(fs.readFileSync(copy, 'utf8').trim()).toBe('cm_agent_after'); - expect(fs.statSync(copy).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') expect(fs.statSync(copy).mode & 0o777).toBe(0o600); }); it('a traversal name cannot make remove or refresh touch anything, and does not block detach', async () => {