fix(auth): preserve explicit resume session continuity

Refs #1685
This commit is contained in:
Tam Nhu Tran committed 2026-08-08 21:17:01 -04:00
1 parent 7fb56d62e1
commit 6721d47502
6 files changed
+729 -9

No files matched your search

+130
View File
@@ -4,9 +4,11 @@ import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import InstanceManager from '../management/instance-manager';
import ProfileDetector from './profile-detector';
import ProfileRegistry from './profile-registry';
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
import type { ProfileType } from '../types/profile';
import { getCcsDir } from '../config/config-loader-facade';
import { isValidAccountProfileName } from './account-context';
export type ResumeLaneKind =
| 'native'
@@ -29,6 +31,37 @@ export interface ResumeFlagIntent {
explicitSessionId?: string;
}
export interface ResumeSessionLane {
kind: 'native' | 'account';
configDir: string;
accountName?: string;
}
export interface ResumeSessionScanDependencies {
beforeSessionOpen?: (sessionPath: string) => void;
}
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function isPathWithin(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
function hasSameIdentity(before: fs.Stats, after: fs.Stats): boolean {
return before.dev === after.dev && before.ino === after.ino;
}
function isStableDirectory(directoryPath: string, before: fs.Stats): boolean {
const after = fs.lstatSync(directoryPath);
return after.isDirectory() && !after.isSymbolicLink() && hasSameIdentity(before, after);
}
function isStableFile(filePath: string, before: fs.Stats): boolean {
const after = fs.lstatSync(filePath);
return after.isFile() && !after.isSymbolicLink() && hasSameIdentity(before, after);
}
function countTopLevelProjectDirs(projectsDir: string): number {
try {
return fs
@@ -39,6 +72,103 @@ function countTopLevelProjectDirs(projectsDir: string): number {
}
}
function laneContainsSession(
configDir: string,
sessionId: string,
deps: ResumeSessionScanDependencies
): boolean {
if (!SESSION_ID_PATTERN.test(sessionId)) {
return false;
}
const projectsDir = path.join(configDir, 'projects');
try {
const configStat = fs.lstatSync(configDir);
if (!configStat.isDirectory() || configStat.isSymbolicLink()) return false;
const configRealPath = fs.realpathSync(configDir);
const projectsStat = fs.lstatSync(projectsDir);
if (!projectsStat.isDirectory() || projectsStat.isSymbolicLink()) return false;
const projectsRealPath = fs.realpathSync(projectsDir);
if (!isPathWithin(projectsRealPath, configRealPath)) return false;
return fs.readdirSync(projectsDir, { withFileTypes: true }).some((projectEntry) => {
if (!projectEntry.isDirectory() || projectEntry.isSymbolicLink()) {
return false;
}
const projectPath = path.join(projectsDir, projectEntry.name);
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
let sessionFd: number | undefined;
try {
const projectStat = fs.lstatSync(projectPath);
if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) return false;
const projectRealPath = fs.realpathSync(projectPath);
if (!isPathWithin(projectRealPath, projectsRealPath)) return false;
const sessionRealPath = fs.realpathSync(sessionPath);
if (!isPathWithin(sessionRealPath, projectRealPath)) return false;
const sessionStat = fs.lstatSync(sessionPath);
if (!sessionStat.isFile() || sessionStat.isSymbolicLink()) return false;
deps.beforeSessionOpen?.(sessionPath);
sessionFd = fs.openSync(
sessionPath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
);
const openedSessionStat = fs.fstatSync(sessionFd);
if (!openedSessionStat.isFile() || !hasSameIdentity(sessionStat, openedSessionStat)) {
return false;
}
return (
isStableDirectory(configDir, configStat) &&
isStableDirectory(projectsDir, projectsStat) &&
isStableDirectory(projectPath, projectStat) &&
isStableFile(sessionPath, sessionStat) &&
fs.realpathSync(configDir) === configRealPath &&
fs.realpathSync(projectsDir) === projectsRealPath &&
fs.realpathSync(projectPath) === projectRealPath &&
fs.realpathSync(sessionPath) === sessionRealPath
);
} catch {
return false;
} finally {
if (sessionFd !== undefined) fs.closeSync(sessionFd);
}
});
} catch {
return false;
}
}
export function findResumeSessionLanes(
sessionId: string,
deps: ResumeSessionScanDependencies = {}
): ResumeSessionLane[] {
if (!SESSION_ID_PATTERN.test(sessionId)) {
return [];
}
const instanceMgr = new InstanceManager();
const profileRegistry = new ProfileRegistry();
const lanes: ResumeSessionLane[] = [];
const nativeConfigDir = getDefaultClaudeConfigDir();
if (laneContainsSession(nativeConfigDir, sessionId, deps)) {
lanes.push({ kind: 'native', configDir: nativeConfigDir });
}
const accountNames = Object.entries(profileRegistry.getAllProfilesMerged())
.filter(
([accountName, profile]) =>
profile.type === 'account' && isValidAccountProfileName(accountName)
)
.map(([accountName]) => accountName);
for (const accountName of accountNames) {
const configDir = instanceMgr.getInstancePath(accountName);
if (laneContainsSession(configDir, sessionId, deps)) {
lanes.push({ kind: 'account', configDir, accountName });
}
}
return lanes;
}
function resolveNativeLaneSummary(
kind: ResumeLaneKind = 'native',
profileName?: string
+119 -7
View File
@@ -1,17 +1,65 @@
import * as path from 'path';
import { info, warn } from '../utils/ui';
import { detectShell, formatExport, formatUnset, type Shell } from '../codex-auth/shell-detect';
import { ANTHROPIC_ROUTING_ENV_KEYS } from '../utils/shell-executor';
import { isValidAccountProfileName } from './account-context';
import {
findResumeSessionLanes,
parseResumeFlagIntent,
resolveRuntimePlainCcsResumeLane,
type ResumeSessionLane,
type ResumeLaneSummary,
} from './resume-lane-diagnostics';
interface ResumeLaneWarningDependencies {
resolvePlainLane?: () => Promise<ResumeLaneSummary>;
findSessionLanes?: (sessionId: string) => ResumeSessionLane[];
shell?: Shell;
env?: NodeJS.ProcessEnv;
platform?: string;
parentProcessName?: string;
log?: (message: string) => void;
debug?: boolean;
}
function joinShellStatements(shell: Shell, statements: string[]): string {
if (shell === 'cmd') return statements.join(' && ');
if (shell === 'fish') return statements.join(' ');
return statements.join('; ');
}
function buildExplicitNativeResumeCommand(
configDir: string,
sessionId: string,
shell: Shell
): string {
const statements = ANTHROPIC_ROUTING_ENV_KEYS.map((key) => formatUnset(shell, key));
statements.push(formatExport(shell, 'CLAUDE_CONFIG_DIR', configDir));
statements.push(`claude --resume ${sessionId}`);
return joinShellStatements(shell, statements);
}
function buildResumeCommand(
sourceLane: ResumeSessionLane,
plainLane: ResumeLaneSummary,
sessionId: string,
shell: Shell
): string | null {
if (sourceLane.kind === 'account') {
if (!sourceLane.accountName || !isValidAccountProfileName(sourceLane.accountName)) return null;
return `ccs ${sourceLane.accountName} --resume ${sessionId}`;
}
if (
plainLane.kind === 'native' &&
path.resolve(sourceLane.configDir) === path.resolve(plainLane.configDir)
) {
return `ccs --resume ${sessionId}`;
}
return buildExplicitNativeResumeCommand(sourceLane.configDir, sessionId, shell);
}
export async function maybeWarnAboutResumeLaneMismatch(
profileName: string,
accountConfigDir: string,
@@ -26,8 +74,20 @@ export async function maybeWarnAboutResumeLaneMismatch(
const log = deps.log ?? console.error;
try {
const sessionLanes = resumeIntent.explicitSessionId
? (deps.findSessionLanes ?? findResumeSessionLanes)(resumeIntent.explicitSessionId)
: [];
if (
sessionLanes.some((lane) => path.resolve(lane.configDir) === path.resolve(accountConfigDir))
) {
return;
}
const plainLane = await (deps.resolvePlainLane ?? resolveRuntimePlainCcsResumeLane)();
if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) {
if (
sessionLanes.length === 0 &&
path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)
) {
return;
}
@@ -38,18 +98,70 @@ export async function maybeWarnAboutResumeLaneMismatch(
);
log(info(` Account lane: ${accountConfigDir}`));
log(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`));
const sourceLanes = sessionLanes.filter(
(lane) => path.resolve(lane.configDir) !== path.resolve(accountConfigDir)
);
const shell =
deps.shell ??
detectShell(
deps.env ?? process.env,
deps.platform ?? process.platform,
deps.parentProcessName
);
if (resumeIntent.explicitSessionId) {
if (sourceLanes.length === 1) {
const sourceLane = sourceLanes[0];
const command = buildResumeCommand(
sourceLane,
plainLane,
resumeIntent.explicitSessionId,
shell
);
if (command) {
log(info(` Resume from the lane that owns this session: ${command}`));
} else {
log(warn(' Session owner has an unsafe account name; no executable command emitted.'));
}
} else if (sourceLanes.length > 1) {
log(info(' This session exists in multiple lanes; choose one without merging history:'));
for (const sourceLane of sourceLanes) {
const command = buildResumeCommand(
sourceLane,
plainLane,
resumeIntent.explicitSessionId,
shell
);
if (command) log(info(` ${command}`));
}
} else {
log(
info(
' This explicit session ID may have been created in a different lane, so Claude may not find it here.'
)
);
}
}
if (sourceLanes.length === 0) {
log(info(' Recover the original lane first: ccs -r'));
log(info(' Back it up before changing setup: ccs auth backup default'));
if (isValidAccountProfileName(profileName)) {
log(
info(
` For future work, align plain ccs with this account: ccs auth default ${profileName}`
)
);
}
} else if (
sourceLanes.length === 1 &&
sourceLanes[0].accountName &&
isValidAccountProfileName(sourceLanes[0].accountName)
) {
log(
info(
' This explicit session ID may have been created in a different lane, so Claude may not find it here.'
` Back up that lane before changing setup: ccs auth backup ${sourceLanes[0].accountName}`
)
);
}
log(info(' Recover the original lane first: ccs -r'));
log(info(' Back it up before changing setup: ccs auth backup default'));
log(
info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`)
);
log('');
} catch (error) {
log(
+14
View File
@@ -95,3 +95,17 @@ export function formatExport(shell: Shell, key: string, value: string): string {
return `export ${key}=${posixSingleQuote(value)}`;
}
}
/** Format removal of one environment variable for the target shell. */
export function formatUnset(shell: Shell, key: string): string {
switch (shell) {
case 'fish':
return `set -e ${key};`;
case 'pwsh':
return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
case 'cmd':
return `set "${key}="`;
default:
return `unset ${key}`;
}
}
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
findResumeSessionLanes,
parseResumeFlagIntent,
resolveConfiguredPlainCcsResumeLane,
} from '../../../src/auth/resume-lane-diagnostics';
@@ -140,4 +141,158 @@ describe('resume lane diagnostics', () => {
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
});
it('finds an explicit session in native and account resume lanes', () => {
const sessionId = '33333333-3333-4333-8333-333333333333';
writeConfig([
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const accountProject = path.join(
tempHome,
'.ccs',
'instances',
'work',
'projects',
'workspace'
);
fs.mkdirSync(nativeProject, { recursive: true });
fs.mkdirSync(accountProject, { recursive: true });
fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), '{}\n');
fs.writeFileSync(path.join(accountProject, `${sessionId}.jsonl`), '{}\n');
expect(findResumeSessionLanes(sessionId)).toEqual([
{ kind: 'native', configDir: path.join(tempHome, '.claude') },
{
kind: 'account',
configDir: path.join(tempHome, '.ccs', 'instances', 'work'),
accountName: 'work',
},
]);
});
it('does not interpret resume search text as a session filename', () => {
expect(findResumeSessionLanes('../../settings')).toEqual([]);
});
it('does not follow a symlinked project directory outside the native lane', () => {
const sessionId = '77777777-7777-4777-8777-777777777777';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-project');
const nativeProjects = path.join(tempHome, '.claude', 'projects');
fs.mkdirSync(externalProject, { recursive: true });
fs.mkdirSync(nativeProjects, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(externalProject, path.join(nativeProjects, 'linked-project'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('does not follow a symlinked projects root outside the native lane', () => {
const sessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-projects', 'workspace');
const nativeDir = path.join(tempHome, '.claude');
fs.mkdirSync(externalProject, { recursive: true });
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(path.dirname(externalProject), path.join(nativeDir, 'projects'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('does not follow a symlinked native config root', () => {
const sessionId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-claude', 'projects', 'workspace');
fs.mkdirSync(externalProject, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(path.join(tempHome, 'external-claude'), path.join(tempHome, '.claude'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('rejects a project parent swapped to an external symlink at the open boundary', () => {
const sessionId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const displacedProject = path.join(tempHome, 'displaced-project');
const externalProject = path.join(tempHome, 'external-project');
fs.mkdirSync(nativeProject, { recursive: true });
fs.mkdirSync(externalProject, { recursive: true });
fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), 'native\n');
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), 'external\n');
let swapped = false;
expect(
findResumeSessionLanes(sessionId, {
beforeSessionOpen: () => {
if (swapped) return;
swapped = true;
fs.renameSync(nativeProject, displacedProject);
fs.symlinkSync(externalProject, nativeProject);
},
})
).toEqual([]);
});
it('does not follow a symlinked session file outside the native lane', () => {
const sessionId = '88888888-8888-4888-8888-888888888888';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const externalSession = path.join(tempHome, 'external-session.jsonl');
fs.mkdirSync(nativeProject, { recursive: true });
fs.writeFileSync(externalSession, '{}\n');
fs.symlinkSync(externalSession, path.join(nativeProject, `${sessionId}.jsonl`));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
});
+298 -1
View File
@@ -1,4 +1,9 @@
import { describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Shell } from '../../../src/codex-auth/shell-detect';
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
function stripAnsi(input: string): string {
@@ -6,6 +11,14 @@ function stripAnsi(input: string): string {
}
describe('resume lane warning', () => {
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
const logs: string[] = [];
@@ -56,4 +69,288 @@ describe('resume lane warning', () => {
expect(logs[0]).toContain('Resume lane guidance skipped because diagnostics failed');
expect(logs[1]).toContain('Diagnostic error: broken config');
});
it('does not warn when an explicit session already exists in the target account lane', async () => {
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch(
'work',
'/tmp/account-lane',
['--resume', '11111111-1111-4111-8111-111111111111'],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{
kind: 'account',
configDir: '/tmp/account-lane',
accountName: 'work',
},
],
}
);
expect(logs).toHaveLength(0);
});
it('points an explicit session at its unique account lane', async () => {
const logs: string[] = [];
const sessionId = '22222222-2222-4222-8222-222222222222';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{
kind: 'account',
configDir: '/tmp/work-lane',
accountName: 'work',
},
],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: ccs work --resume ${sessionId}`
);
expect(plainLogs).toContain(
'[i] Back up that lane before changing setup: ccs auth backup work'
);
expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r');
expect(plainLogs).not.toContain(
'[i] For future work, align plain ccs with this account: ccs auth default personal'
);
});
it('lists ambiguous source lanes without choosing or merging one', async () => {
const logs: string[] = [];
const sessionId = '44444444-4444-4444-8444-444444444444';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{ kind: 'native', configDir: '/tmp/native-lane' },
{ kind: 'account', configDir: '/tmp/work-lane', accountName: 'work' },
],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
'[i] This session exists in multiple lanes; choose one without merging history:'
);
expect(plainLogs).toContain(`[i] ccs --resume ${sessionId}`);
expect(plainLogs).toContain(`[i] ccs work --resume ${sessionId}`);
expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r');
});
it('uses explicit native Claude routing when plain ccs defaults to another account', async () => {
const logs: string[] = [];
const sessionId = '55555555-5555-4555-8555-555555555555';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 12,
}),
shell: 'bash',
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}`
);
expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it('routes to native Claude when the target account is also the plain ccs default', async () => {
const logs: string[] = [];
const sessionId = '66666666-6666-4666-8666-666666666666';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 12,
}),
shell: 'bash',
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
});
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}`
);
expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it('uses explicit native routing for an ambient lane even when its path matches', async () => {
const logs: string[] = [];
const sessionId = '99999999-9999-4999-8999-999999999999';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'zsh',
resolvePlainLane: async () => ({
kind: 'ambient',
label: 'current shell CLAUDE_CONFIG_DIR',
configDir: '/tmp/native-lane',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
});
expect(logs.some((message) => message.includes('export CLAUDE_CONFIG_DIR'))).toBe(true);
expect(logs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it.each([
[
'bash',
"unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume",
],
[
'zsh',
"unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume",
],
[
'fish',
"set -e ANTHROPIC_BASE_URL; set -e ANTHROPIC_AUTH_TOKEN; set -e ANTHROPIC_API_KEY; set -gx CLAUDE_CONFIG_DIR '/tmp/native lane'; claude --resume",
],
[
'pwsh',
'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_AUTH_TOKEN -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_API_KEY -ErrorAction SilentlyContinue; $env:CLAUDE_CONFIG_DIR = "/tmp/native lane"; claude --resume',
],
[
'cmd',
'set "ANTHROPIC_BASE_URL=" && set "ANTHROPIC_AUTH_TOKEN=" && set "ANTHROPIC_API_KEY=" && set "CLAUDE_CONFIG_DIR=/tmp/native lane" && claude --resume',
],
] as Array<[Shell, string]>)(
'formats executable native recovery for %s',
async (shell, prefix) => {
const logs: string[] = [];
const sessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell,
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native lane' }],
});
expect(logs.some((message) => message.includes(`${prefix} ${sessionId}`))).toBe(true);
}
);
it('does not emit executable guidance for an unsafe account name', async () => {
const logs: string[] = [];
const sessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
await maybeWarnAboutResumeLaneMismatch('personal', '/tmp/personal', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'bash',
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native',
projectCount: 1,
}),
findSessionLanes: () => [
{ kind: 'account', configDir: '/tmp/unsafe', accountName: 'work; touch /tmp/pwned' },
],
});
expect(logs.some((message) => message.includes('unsafe account name'))).toBe(true);
expect(logs.some((message) => message.includes('ccs work;'))).toBe(false);
expect(logs.some((message) => message.includes('backup work;'))).toBe(false);
});
it('executes the printed bash route with poisoned provider routing removed', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-command-'));
temporaryDirectories.push(tempDir);
const binDir = path.join(tempDir, 'bin');
const capturePath = path.join(tempDir, 'capture');
const nativeConfigDir = path.join(tempDir, 'native lane');
const sessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
fs.mkdirSync(binDir);
fs.mkdirSync(path.join(nativeConfigDir, 'projects', 'workspace'), { recursive: true });
fs.writeFileSync(
path.join(nativeConfigDir, 'projects', 'workspace', `${sessionId}.jsonl`),
'{}\n'
);
fs.writeFileSync(
path.join(binDir, 'claude'),
'#!/bin/sh\nsession_path="$CLAUDE_CONFIG_DIR/projects/workspace/$2.jsonl"\n[ -f "$session_path" ] || exit 9\nprintf "%s|%s|%s|%s|%s|found\\n" "$CLAUDE_CONFIG_DIR" "${ANTHROPIC_BASE_URL-unset}" "${ANTHROPIC_AUTH_TOKEN-unset}" "${ANTHROPIC_API_KEY-unset}" "$*" > "$CAPTURE_PATH"\n',
{ mode: 0o700 }
);
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'bash',
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work',
accountName: 'work',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: nativeConfigDir }],
});
const guidance = logs.find((message) => message.includes('Resume from the lane'))!;
const command = guidance.slice(guidance.indexOf(': ') + 2);
const result = spawnSync('/bin/bash', ['-c', command], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
CAPTURE_PATH: capturePath,
ANTHROPIC_BASE_URL: 'https://poison.invalid',
ANTHROPIC_AUTH_TOKEN: 'poison-token',
ANTHROPIC_API_KEY: 'poison-key',
},
});
expect(result.status).toBe(0);
expect(fs.readFileSync(capturePath, 'utf8').trim()).toBe(
`${nativeConfigDir}|unset|unset|unset|--resume ${sessionId}|found`
);
});
});
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { detectShell, formatExport } from '../../../src/codex-auth/shell-detect';
import { detectShell, formatExport, formatUnset } from '../../../src/codex-auth/shell-detect';
import type { Shell } from '../../../src/codex-auth/shell-detect';
// ── detectShell ───────────────────────────────────────────────────────────────
@@ -146,6 +146,18 @@ describe('formatExport — cmd', () => {
});
});
describe('formatUnset', () => {
it('formats environment removal for every supported shell', () => {
expect(formatUnset('bash', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL');
expect(formatUnset('zsh', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL');
expect(formatUnset('fish', 'ANTHROPIC_BASE_URL')).toBe('set -e ANTHROPIC_BASE_URL;');
expect(formatUnset('pwsh', 'ANTHROPIC_BASE_URL')).toBe(
'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue'
);
expect(formatUnset('cmd', 'ANTHROPIC_BASE_URL')).toBe('set "ANTHROPIC_BASE_URL="');
});
});
describe('formatExport — each shell produces distinct syntax', () => {
const shells: Shell[] = ['bash', 'zsh', 'fish', 'pwsh', 'cmd'];
it('all shells produce different output for same input', () => {