fix(auth): close shared-context isolation edge cases

This commit is contained in:
Tam Nhu Tran
2026-02-25 04:09:46 +07:00
parent 7d5e604e53
commit 0b070a3f34
14 changed files with 767 additions and 123 deletions
+144 -81
View File
@@ -15,6 +15,7 @@ import {
policyToAccountContextMetadata,
formatAccountContextPolicy,
isValidAccountProfileName,
resolveAccountContextPolicy,
} from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
@@ -24,6 +25,47 @@ function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = ['_API_KEY', '_AUTH_TOKEN', '_ACCESS_TOKEN', '_SECRET_KEY'];
function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
if (envKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => envKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(envKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => envKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
/**
* Handle the create command
*/
@@ -31,6 +73,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
await initUI();
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
}
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
@@ -43,13 +92,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
}
if (!isValidAccountProfileName(profileName)) {
const error =
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.';
@@ -100,33 +142,73 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
useUnifiedConfig && existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const previousContextPolicy =
!createdProfile && (previousUnifiedProfile || previousLegacyProfile)
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
: undefined;
const claudeInfo = getClaudeCliInfo();
if (!claudeInfo) {
console.log(fail('Claude CLI not found'));
console.log('');
console.log('Please install Claude CLI first:');
console.log(` ${color('https://claude.ai/download', 'path')}`);
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
}
let rollbackCompleted = false;
const rollbackMetadata = (): void => {
try {
if (useUnifiedConfig) {
if (createdProfile) {
if (ctx.registry.hasAccountUnified(profileName)) {
ctx.registry.removeAccountUnified(profileName);
}
} else if (previousUnifiedProfile) {
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
}
return;
}
if (createdProfile) {
if (ctx.registry.hasProfile(profileName)) {
ctx.registry.deleteProfile(profileName);
}
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
}
} catch {
// Best-effort rollback to avoid leaving stale accounts after failed login.
}
};
const rollbackFailedCreate = async (): Promise<void> => {
if (rollbackCompleted) {
return;
}
rollbackCompleted = true;
rollbackMetadata();
if (createdProfile) {
try {
ctx.instanceMgr.deleteInstance(profileName);
} catch {
// Best-effort cleanup.
}
return;
}
if (previousContextPolicy) {
try {
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
} catch {
// Best-effort rollback for context mode/group.
}
}
};
try {
const rollbackMetadata = (): void => {
try {
if (useUnifiedConfig) {
if (createdProfile) {
if (ctx.registry.hasAccountUnified(profileName)) {
ctx.registry.removeAccountUnified(profileName);
}
} else if (previousUnifiedProfile) {
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
}
return;
}
if (createdProfile) {
if (ctx.registry.hasProfile(profileName)) {
ctx.registry.deleteProfile(profileName);
}
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
}
} catch {
// Best-effort rollback to avoid leaving stale accounts after failed login.
}
};
// Create instance directory
console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
@@ -170,53 +252,39 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(warn('You will be prompted to login with your account.'));
console.log('');
// Detect Claude CLI
const claudeInfo = getClaudeCliInfo();
if (!claudeInfo) {
console.log(fail('Claude CLI not found'));
console.log('');
console.log('Please install Claude CLI first:');
console.log(` ${color('https://claude.ai/download', 'path')}`);
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
}
const { path: claudeCli, needsShell } = claudeInfo;
const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath });
// Avoid ambient provider credentials influencing account-login bootstrap behavior.
const ambientProviderPrefixes = ['ANTHROPIC_', 'OPENAI_', 'GOOGLE_', 'GEMINI_', 'MINIMAX_'];
for (const envKey of Object.keys(childEnv)) {
if (envKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
ambientProviderPrefixes.some((prefix) => envKey.startsWith(prefix)) ||
envKey === 'OPENROUTER_API_KEY'
) {
delete childEnv[envKey];
}
}
const childEnv = stripAmbientProviderCredentials(
stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath })
);
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
// On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
let child: ChildProcess;
if (needsShell) {
const cmdString = escapeShellArg(claudeCli);
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env: childEnv,
});
} else {
child = spawn(claudeCli, [], {
stdio: 'inherit',
windowsHide: true,
env: childEnv,
});
try {
if (needsShell) {
const cmdString = escapeShellArg(claudeCli);
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env: childEnv,
});
} else {
child = spawn(claudeCli, [], {
stdio: 'inherit',
windowsHide: true,
env: childEnv,
});
}
} catch (error) {
await rollbackFailedCreate();
exitWithError(
`Failed to execute Claude CLI: ${(error as Error).message}`,
ExitCode.BINARY_ERROR
);
}
child.on('exit', (code: number | null) => {
child.on('exit', async (code: number | null) => {
if (code === 0) {
console.log('');
console.log(
@@ -246,10 +314,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log('');
process.exit(0);
} else {
rollbackMetadata();
if (createdProfile) {
ctx.instanceMgr.deleteInstance(profileName);
}
await rollbackFailedCreate();
console.log('');
console.log(fail('Login failed or cancelled'));
@@ -261,14 +326,12 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
}
});
child.on('error', (err: Error) => {
rollbackMetadata();
if (createdProfile) {
ctx.instanceMgr.deleteInstance(profileName);
}
child.on('error', async (err: Error) => {
await rollbackFailedCreate();
exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR);
});
} catch (error) {
await rollbackFailedCreate();
exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
}
}
+11 -3
View File
@@ -159,7 +159,7 @@ export class ProfileRegistry {
throw new Error(`Profile not found: ${name}`);
}
return data.profiles[name];
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
}
/**
@@ -215,7 +215,11 @@ export class ProfileRegistry {
*/
getAllProfiles(): Record<string, ProfileMetadata> {
const data = this._read();
return data.profiles;
const normalized: Record<string, ProfileMetadata> = {};
for (const [name, profile] of Object.entries(data.profiles)) {
normalized[name] = this.normalizeLegacyProfileMetadata(profile);
}
return normalized;
}
/**
@@ -357,7 +361,11 @@ export class ProfileRegistry {
getAllAccountsUnified(): Record<string, AccountConfig> {
if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig();
return config.accounts;
const normalized: Record<string, AccountConfig> = {};
for (const [name, account] of Object.entries(config.accounts)) {
normalized[name] = this.normalizeUnifiedAccountConfig(account);
}
return normalized;
}
/**
+12 -4
View File
@@ -21,6 +21,7 @@ import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unif
import { createEmptyUnifiedConfig } from './unified-config-types';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context';
import { infoBox, warn } from '../utils/ui';
const BACKUP_DIR_PREFIX = 'backup-v1-';
@@ -151,10 +152,17 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
const rawContextMode = metadata.context_mode;
const rawContextGroup = metadata.context_group;
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
const contextGroup =
typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0
? rawContextGroup
: undefined;
let contextGroup: string | undefined;
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
if (isValidContextGroupName(normalizedGroup)) {
contextGroup = normalizedGroup;
} else {
warnings.push(
`Skipped invalid context group for account "${name}": "${rawContextGroup}" (fallback to default shared group)`
);
}
}
const account: AccountConfig = {
created: (metadata.created as string) || new Date().toISOString(),
last_used: (metadata.last_used as string) || null,
+39 -2
View File
@@ -8,6 +8,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import SharedManager from './shared-manager';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
@@ -203,7 +204,39 @@ class InstanceManager {
private getContextSyncLockPath(profileName: string): string {
const safeName = this.sanitizeName(profileName);
return path.join(this.locksDir, `${safeName}.lock`);
// Keep lock filenames deterministic while preventing normalized-name collisions.
const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8);
return path.join(this.locksDir, `${safeName}-${profileHash}.lock`);
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
return true;
}
return false;
}
}
private tryRemoveDeadOwnerLock(lockPath: string): boolean {
try {
const lockContent = fs.readFileSync(lockPath, 'utf8').trim();
const lockPid = Number.parseInt(lockContent, 10);
if (!this.isProcessAlive(lockPid)) {
fs.unlinkSync(lockPath);
return true;
}
} catch {
// Best-effort stale lock cleanup.
}
return false;
}
private async withContextSyncLock<T>(
@@ -212,8 +245,8 @@ class InstanceManager {
): Promise<T> {
const lockPath = this.getContextSyncLockPath(profileName);
const retryDelayMs = 50;
const timeoutMs = 5000;
const staleLockMs = 30000;
const timeoutMs = staleLockMs + 5000;
const start = Date.now();
fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 });
@@ -236,6 +269,10 @@ class InstanceManager {
fs.unlinkSync(lockPath);
continue;
}
if (this.tryRemoveDeadOwnerLock(lockPath)) {
continue;
}
} catch {
// Best-effort stale lock cleanup.
}
+28
View File
@@ -240,6 +240,7 @@ class SharedManager {
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
@@ -247,6 +248,10 @@ class SharedManager {
sharedProjectsPath,
instanceName
);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
await fs.promises.unlink(projectsPath);
@@ -286,9 +291,14 @@ class SharedManager {
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(projectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
return;
@@ -678,6 +688,24 @@ class SharedManager {
}
}
/**
* Guard project merge operations to known CCS-managed roots only.
*/
private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean {
const resolvedSource = path.resolve(sourcePath);
const sharedContextRoot = path.resolve(path.join(this.sharedDir, 'context-groups'));
const instanceProjectsRoot = path.resolve(
path.join(this.instancesDir, instanceName, 'projects')
);
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(`${root}${path.sep}`);
return (
isWithin(resolvedSource, sharedContextRoot) || isWithin(resolvedSource, instanceProjectsRoot)
);
}
/**
* Link directory with Windows fallback to recursive copy.
*/
@@ -0,0 +1,45 @@
import type { CLIProxyProvider } from '../../cliproxy/types';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
export interface MergedAccountEntry {
type: string;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
provider?: string;
displayName?: string;
}
/** Parse CLIProxy account key format: "provider:accountId" */
export function parseCliproxyKey(
key: string
): { provider: CLIProxyProvider; accountId: string } | null {
let normalizedKey = key;
if (key.startsWith('cliproxy:')) {
normalizedKey = key.slice('cliproxy:'.length);
} else if (key.startsWith('cliproxy+')) {
normalizedKey = key.slice('cliproxy+'.length);
}
const colonIndex = normalizedKey.indexOf(':');
if (colonIndex === -1) return null;
const provider = normalizedKey.slice(0, colonIndex);
const accountId = normalizedKey.slice(colonIndex + 1);
if (!isCLIProxyProvider(provider) || !accountId) return null;
return { provider, accountId };
}
export function buildCliproxyAccountKey(
rawKey: string,
merged: Record<string, MergedAccountEntry>
): string | null {
const candidateKeys = [rawKey, `cliproxy:${rawKey}`, `cliproxy+${rawKey}`];
for (const key of candidateKeys) {
if (!merged[key]) {
return key;
}
}
return null;
}
+26 -31
View File
@@ -12,31 +12,24 @@ import { isUnifiedMode } from '../../config/unified-config-loader';
import {
getAllAccountsSummary,
setDefaultAccount as setCliproxyDefault,
getDefaultAccount as getCliproxyDefaultAccount,
removeAccount as removeCliproxyAccount,
bulkPauseAccounts,
bulkResumeAccounts,
soloAccount,
} from '../../cliproxy/account-manager';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import { resolveAccountContextPolicy } from '../../auth/account-context';
import {
buildCliproxyAccountKey,
parseCliproxyKey,
type MergedAccountEntry,
} from './account-route-helpers';
const router = Router();
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
/** Parse CLIProxy account key format: "provider:accountId" */
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
const normalizedKey = key.startsWith('cliproxy:') ? key.slice('cliproxy:'.length) : key;
const colonIndex = normalizedKey.indexOf(':');
if (colonIndex === -1) return null;
const provider = normalizedKey.slice(0, colonIndex);
const accountId = normalizedKey.slice(colonIndex + 1);
if (!isCLIProxyProvider(provider) || !accountId) return null;
return { provider, accountId };
}
function hasAuthAccount(name: string): boolean {
return registry.hasAccountUnified(name) || registry.hasProfile(name);
}
@@ -54,38 +47,29 @@ router.get('/', (_req: Request, res: Response): void => {
const cliproxyAccounts = getAllAccountsSummary();
// Merge profiles: unified config takes precedence
const merged: Record<
string,
{
type: string;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
provider?: string;
displayName?: string;
}
> = {};
const merged: Record<string, MergedAccountEntry> = {};
// Add legacy profiles first
for (const [name, meta] of Object.entries(legacyProfiles)) {
const contextPolicy = resolveAccountContextPolicy(meta);
merged[name] = {
type: meta.type || 'account',
created: meta.created,
last_used: meta.last_used || null,
context_mode: meta.context_mode,
context_group: meta.context_group,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
};
}
// Override with unified config accounts (takes precedence)
for (const [name, account] of Object.entries(unifiedAccounts)) {
const contextPolicy = resolveAccountContextPolicy(account);
merged[name] = {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
};
}
@@ -99,7 +83,10 @@ router.get('/', (_req: Request, res: Response): void => {
// Use unique ID for key to prevent collisions between accounts with same nickname/email
const displayName = acct.nickname || acct.email || acct.id;
const rawKey = `${provider}:${acct.id}`;
const key = merged[rawKey] ? `cliproxy:${rawKey}` : rawKey;
const key = buildCliproxyAccountKey(rawKey, merged);
if (!key) {
continue;
}
merged[key] = {
type: 'cliproxy',
provider,
@@ -202,6 +189,14 @@ router.delete('/:name', (req: Request, res: Response): void => {
// Check if this is a CLIProxy account (format: "provider:accountId")
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
if (cliproxyKey) {
const defaultCliproxyAccount = getCliproxyDefaultAccount(cliproxyKey.provider);
if (defaultCliproxyAccount?.id === cliproxyKey.accountId) {
res.status(400).json({
error: `Cannot delete default CLIProxy account: ${name}. Set another default first.`,
});
return;
}
const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId);
if (!success) {
res.status(404).json({ error: `CLIProxy account not found: ${name}` });
+54
View File
@@ -18,9 +18,57 @@ import {
getBackupDirectories,
} from '../../config/migration-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/account-context';
const router = Router();
function validateAccountContextMetadata(config: unknown): string | null {
if (typeof config !== 'object' || config === null) {
return 'Invalid config payload';
}
const candidate = config as Record<string, unknown>;
const accounts = candidate.accounts;
if (accounts === undefined) {
return null;
}
if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) {
return 'Invalid config.accounts: expected object';
}
for (const [accountName, accountValue] of Object.entries(accounts as Record<string, unknown>)) {
if (typeof accountValue !== 'object' || accountValue === null || Array.isArray(accountValue)) {
return `Invalid config.accounts.${accountName}: expected object`;
}
const account = accountValue as Record<string, unknown>;
const mode = account.context_mode;
const group = account.context_group;
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`;
}
if (group !== undefined && typeof group !== 'string') {
return `Invalid config.accounts.${accountName}.context_group: expected string`;
}
if (mode !== 'shared' && group !== undefined) {
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(group);
if (!isValidContextGroupName(normalizedGroup)) {
return `Invalid config.accounts.${accountName}.context_group`;
}
}
}
return null;
}
/**
* GET /api/config/format - Return current config format and migration status
*/
@@ -82,6 +130,12 @@ router.put('/', (req: Request, res: Response): void => {
return;
}
const accountContextError = validateAccountContextMetadata(config);
if (accountContextError) {
res.status(400).json({ error: accountContextError });
return;
}
try {
saveUnifiedConfig(config);
res.json({ success: true });