From 6721d47502645325d58fc97a4d11bb53750d0c75 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 8 Aug 2026 21:17:01 -0400 Subject: [PATCH 1/2] fix(auth): preserve explicit resume session continuity Refs #1685 --- src/auth/resume-lane-diagnostics.ts | 130 ++++++++ src/auth/resume-lane-warning.ts | 126 +++++++- src/codex-auth/shell-detect.ts | 14 + .../unit/auth/resume-lane-diagnostics.test.ts | 155 +++++++++ tests/unit/auth/resume-lane-warning.test.ts | 299 +++++++++++++++++- tests/unit/codex-auth/shell-detect.test.ts | 14 +- 6 files changed, 729 insertions(+), 9 deletions(-) diff --git a/src/auth/resume-lane-diagnostics.ts b/src/auth/resume-lane-diagnostics.ts index ba3e3b95..260080d2 100644 --- a/src/auth/resume-lane-diagnostics.ts +++ b/src/auth/resume-lane-diagnostics.ts @@ -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 diff --git a/src/auth/resume-lane-warning.ts b/src/auth/resume-lane-warning.ts index ca59217c..de6fb121 100644 --- a/src/auth/resume-lane-warning.ts +++ b/src/auth/resume-lane-warning.ts @@ -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; + 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( diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts index d9046775..a8698eb1 100644 --- a/src/codex-auth/shell-detect.ts +++ b/src/codex-auth/shell-detect.ts @@ -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}`; + } +} diff --git a/tests/unit/auth/resume-lane-diagnostics.test.ts b/tests/unit/auth/resume-lane-diagnostics.test.ts index 938455b8..79617bd7 100644 --- a/tests/unit/auth/resume-lane-diagnostics.test.ts +++ b/tests/unit/auth/resume-lane-diagnostics.test.ts @@ -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([]); + }); }); diff --git a/tests/unit/auth/resume-lane-warning.test.ts b/tests/unit/auth/resume-lane-warning.test.ts index 83205178..89cd12f3 100644 --- a/tests/unit/auth/resume-lane-warning.test.ts +++ b/tests/unit/auth/resume-lane-warning.test.ts @@ -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` + ); + }); }); diff --git a/tests/unit/codex-auth/shell-detect.test.ts b/tests/unit/codex-auth/shell-detect.test.ts index a3ee6ac8..c48dfbb5 100644 --- a/tests/unit/codex-auth/shell-detect.test.ts +++ b/tests/unit/codex-auth/shell-detect.test.ts @@ -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', () => { From 379008383e1c5618255bb1b8936f001ca3466c00 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 8 Aug 2026 21:17:12 -0400 Subject: [PATCH 2/2] chore(reports): refresh hardening inventory Refs #1685 --- docs/reports/hardening-inventory.json | 4 ++-- docs/reports/hardening-inventory.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 976dbcc2..8053edfe 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,9 +1,9 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2427, + "totalOccurrences": 2445, "filesAffected": 258, - "hotpathOccurrences": 1142, + "hotpathOccurrences": 1160, "hotpathFilesAffected": 152, "topHotpathFiles": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0b8a3082..cce70897 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2427 | +| Sync fs occurrences (all) | 2445 | | Sync fs files affected (all) | 258 | -| Sync fs occurrences (runtime hotpaths) | 1142 | +| Sync fs occurrences (runtime hotpaths) | 1160 | | Sync fs files affected (runtime hotpaths) | 152 | | Legacy shim markers | 458 | | Legacy shim files affected | 173 |