mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
1 parent
7fb56d62e1
commit
6721d47502
6 files changed
+729
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user