Skip to content

Commit e6ecfe0

Browse files
lilyshen0722claude
andauthored
fix(cli): agent run refuses an --adapter that differs from the token file (#2099)
* fix(cli): agent run refuses an --adapter that differs from the token file `--adapter` chose the CLI only at first-run bootstrap; once a token file existed, the record's adapter ran and a different flag was dropped without a word, so switching an agent from Claude to Codex kept running Claude (#2098). The run now exits 1 with the binding, the token path and the remedy for each path: `detach --force` and a re-bootstrap for a foreground run, `agent config --adapter` for a daemon-managed seat. The option help no longer says the flag is ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv * chore(cli): 0.1.87 for the --adapter mismatch refusal cli/src changed, so the published version must rise ("Source changed ⇒ version bumped"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 64b28c1 commit e6ecfe0

4 files changed

Lines changed: 153 additions & 4 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* run-adapter-mismatch.test.mjs — #2098
3+
*
4+
* `commonly agent run <name> --adapter X` only ever chose the CLI at first-run
5+
* bootstrap. Once ~/.commonly/tokens/<name>.json exists, the record's adapter
6+
* runs, and a different `--adapter` was dropped silently: switching an agent
7+
* from Claude to Codex kept running Claude. The run now refuses the mismatch
8+
* and names the remedy.
9+
*
10+
* Covers the pure check and the command wiring: the real `agent run` action,
11+
* built by registerAgent, against a token file in a throwaway home directory.
12+
*/
13+
14+
import { jest } from '@jest/globals';
15+
import os from 'os';
16+
import path from 'path';
17+
import fs from 'fs';
18+
import { Command } from 'commander';
19+
20+
const homeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-run-adapter-test-'));
21+
22+
await jest.unstable_mockModule('os', () => {
23+
const actual = os;
24+
return {
25+
...actual,
26+
default: { ...actual, homedir: () => homeTmpDir },
27+
homedir: () => homeTmpDir,
28+
};
29+
});
30+
31+
const { checkRunAdapterRequest, saveAgentToken, registerAgent } = await import('../src/commands/agent.js');
32+
33+
afterAll(() => fs.rmSync(homeTmpDir, { recursive: true, force: true }));
34+
35+
const record = (adapter) => ({
36+
agentName: 'byo-test',
37+
podId: 'pod-1',
38+
instanceUrl: 'https://api.commonly.me',
39+
runtimeToken: 'cm_agent_test',
40+
adapter,
41+
});
42+
const tokenPath = path.join(homeTmpDir, '.commonly', 'tokens', 'byo-test.json');
43+
44+
describe('checkRunAdapterRequest', () => {
45+
test('no --adapter runs the bound adapter', () => {
46+
expect(checkRunAdapterRequest({ record: record('claude'), requestedAdapter: undefined, tokenPath })).toEqual({ ok: true });
47+
expect(checkRunAdapterRequest({ record: record('claude'), requestedAdapter: null, tokenPath })).toEqual({ ok: true });
48+
});
49+
50+
test('a matching --adapter runs, whatever its case', () => {
51+
expect(checkRunAdapterRequest({ record: record('codex'), requestedAdapter: 'codex', tokenPath })).toEqual({ ok: true });
52+
expect(checkRunAdapterRequest({ record: record('codex'), requestedAdapter: ' Codex ', tokenPath })).toEqual({ ok: true });
53+
});
54+
55+
test('a mismatched --adapter refuses and names the binding and both remedies', () => {
56+
const result = checkRunAdapterRequest({ record: record('claude'), requestedAdapter: 'codex', tokenPath });
57+
expect(result.ok).toBe(false);
58+
expect(result.message).toContain("'byo-test' is bound to the claude adapter");
59+
expect(result.message).toContain(tokenPath);
60+
expect(result.message).toContain('--adapter codex was not applied');
61+
expect(result.message).toContain('commonly agent detach byo-test --force');
62+
expect(result.message).toContain('commonly agent config byo-test --adapter codex');
63+
// Plain detach also uninstalls the agent from its pod; the remedy must not suggest it.
64+
expect(result.message).not.toMatch(/detach byo-test(?! --force)/);
65+
});
66+
67+
test('a record with no adapter is left to the existing unknown-adapter error', () => {
68+
expect(checkRunAdapterRequest({ record: record(undefined), requestedAdapter: 'codex', tokenPath })).toEqual({ ok: true });
69+
});
70+
});
71+
72+
describe('agent run --adapter against an existing token file', () => {
73+
const exitSentinel = new Error('process.exit called');
74+
let exitSpy;
75+
let errorSpy;
76+
77+
beforeEach(() => {
78+
saveAgentToken('byo-test', record('claude'));
79+
exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => {
80+
exitSentinel.code = code;
81+
throw exitSentinel;
82+
});
83+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
84+
});
85+
86+
afterEach(() => {
87+
exitSpy.mockRestore();
88+
errorSpy.mockRestore();
89+
});
90+
91+
const run = async (...args) => {
92+
const program = new Command();
93+
program.exitOverride();
94+
registerAgent(program);
95+
return program.parseAsync(['agent', 'run', 'byo-test', ...args], { from: 'user' });
96+
};
97+
98+
test('a mismatched --adapter exits 1 with the remedy before anything runs', async () => {
99+
await expect(run('--adapter', 'codex')).rejects.toBe(exitSentinel);
100+
expect(exitSentinel.code).toBe(1);
101+
const stderr = errorSpy.mock.calls.map((call) => call.join(' ')).join('\n');
102+
expect(stderr).toContain("[byo-test] 'byo-test' is bound to the claude adapter");
103+
expect(stderr).toContain(`(${tokenPath})`);
104+
expect(stderr).toContain('commonly agent detach byo-test --force');
105+
// The token file is untouched: refusing must not edit the record.
106+
expect(JSON.parse(fs.readFileSync(tokenPath, 'utf8')).adapter).toBe('claude');
107+
});
108+
109+
test('the option help tells the truth about an existing token file', () => {
110+
const program = new Command();
111+
registerAgent(program);
112+
const runCommand = program.commands.find((c) => c.name() === 'agent').commands.find((c) => c.name() === 'run');
113+
const option = runCommand.options.find((o) => o.long === '--adapter');
114+
expect(option.description).toContain('bound to a different adapter stops the run');
115+
expect(option.description).not.toContain('ignored');
116+
});
117+
});

‎cli/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@commonlyai/cli",
3-
"version": "0.1.86",
3+
"version": "0.1.87",
44
"license": "Apache-2.0",
55
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
66
"type": "module",

‎cli/src/commands/agent.js‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,30 @@ export const deleteAgentToken = (name) => {
127127
// falls back to the attach hint).
128128
export const BOOTSTRAP_ADAPTER_DETECT_ORDER = ['claude', 'codex', 'pi'];
129129

130+
// ── `agent run --adapter` against an existing token file (#2098) ────────────
131+
// The flag picks the CLI once, at first-run bootstrap; after that the token
132+
// file's adapter is what runs. A different `--adapter` used to be dropped
133+
// without a word, so "switch this agent to Codex" kept running Claude and read
134+
// as Codex misbehaving. Refuse instead, and name the way out for each path:
135+
// a foreground run re-bootstraps from the Connect page's env exports after the
136+
// local record is removed; a daemon-managed seat is switched through the
137+
// registry, which the daemon applies on its next pass.
138+
export const checkRunAdapterRequest = ({ record, requestedAdapter, tokenPath }) => {
139+
const requested = String(requestedAdapter || '').trim().toLowerCase();
140+
if (!requested) return { ok: true };
141+
const bound = String(record?.adapter || '').trim().toLowerCase();
142+
if (!bound || bound === requested) return { ok: true };
143+
const name = record.agentName;
144+
return {
145+
ok: false,
146+
message: [
147+
`'${name}' is bound to the ${bound} adapter by its token file (${tokenPath}); --adapter ${requested} was not applied.`,
148+
`To run it with ${requested} here: commonly agent detach ${name} --force, then re-run with the Connect-page env exports and --adapter ${requested}.`,
149+
`A daemon-managed seat switches with: commonly agent config ${name} --adapter ${requested}`,
150+
].join('\n'),
151+
};
152+
};
153+
130154
export const bootstrapAgentRecordFromEnv = async ({
131155
name,
132156
env = process.env,
@@ -2690,7 +2714,7 @@ Docs:
26902714
.command('run <name>')
26912715
.description('Run the local-CLI wrapper loop for an attached agent')
26922716
.option('--interval <ms>', 'Poll interval in ms', '5000')
2693-
.option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); ignored when a token file already exists')
2717+
.option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); an existing token file bound to a different adapter stops the run')
26942718
.option('--cascade-cap <n>', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`)
26952719
.option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
26962720
.option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
@@ -2722,6 +2746,14 @@ Docs:
27222746
}
27232747
}
27242748

2749+
const adapterRequest = checkRunAdapterRequest({
2750+
record, requestedAdapter: opts.adapter, tokenPath: tokenFile(name),
2751+
});
2752+
if (!adapterRequest.ok) {
2753+
console.error(`${stamp()} [${name}] ${adapterRequest.message}`);
2754+
process.exit(1);
2755+
}
2756+
27252757
const adapter = getAdapter(record.adapter);
27262758
if (!adapter) {
27272759
console.error(`Unknown adapter '${record.adapter}' in token file. Known: ${listAdapterNames().join(', ')}`);

0 commit comments

Comments
 (0)