fix(auth): preserve bare profile behavior across runtime and sync

This commit is contained in:
Tam Nhu Tran
2026-03-05 12:36:51 +07:00
parent bc9b04444e
commit 8f8684ce85
14 changed files with 355 additions and 34 deletions
+12 -7
View File
@@ -111,6 +111,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const previousUnifiedProfile = existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const previousBare =
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
const previousContextPolicy =
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
@@ -169,7 +172,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
if (previousContextPolicy) {
try {
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy, {
bare: previousBare,
});
} catch {
// Best-effort rollback for context mode/group.
}
@@ -180,7 +185,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
// Create instance directory
console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: !!bare,
bare: effectiveBare,
});
// Create/update profile entry based on config mode
@@ -190,13 +195,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
ctx.registry.updateAccountUnified(profileName, {
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
...(effectiveBare ? { bare: true } : {}),
});
ctx.registry.touchAccountUnified(profileName);
} else {
ctx.registry.createAccountUnified(profileName, {
...contextMetadata,
...(bare ? { bare: true } : {}),
...(effectiveBare ? { bare: true } : {}),
});
}
} else {
@@ -206,14 +211,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
...(effectiveBare ? { bare: true } : {}),
});
} else {
ctx.registry.createProfile(profileName, {
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
...(effectiveBare ? { bare: true } : {}),
});
}
}
@@ -271,7 +276,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
`Instance: ${instancePath}\n` +
`Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
(bare ? '\nMode: bare (no shared symlinks)' : ''),
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
'Profile Created'
)
);
+3 -1
View File
@@ -146,7 +146,9 @@ export async function resolveProfileContinuityInheritance(
isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined
);
const instanceMgr = new InstanceManager();
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy);
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
bare: mappedProfile.bare === true,
});
return {
sourceAccount,
+1
View File
@@ -203,6 +203,7 @@ class ProfileDetector {
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
bare: account.bare,
},
};
}
+4
View File
@@ -45,6 +45,7 @@ interface CreateMetadata {
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
bare?: boolean;
}
export class ProfileRegistry {
@@ -173,6 +174,7 @@ export class ProfileRegistry {
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
bare: metadata.bare,
});
// Note: No longer auto-set as default
@@ -321,6 +323,7 @@ export class ProfileRegistry {
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
bare: metadata.bare,
});
saveUnifiedConfig(config);
}
@@ -449,6 +452,7 @@ export class ProfileRegistry {
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
bare: account.bare,
};
}
+7 -1
View File
@@ -1132,10 +1132,16 @@ async function main(): Promise<void> {
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
? profileInfo.profile
: undefined;
const isBareProfile =
typeof profileInfo.profile === 'object' &&
profileInfo.profile !== null &&
(profileInfo.profile as { bare?: unknown }).bare === true;
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
// Ensure instance exists (lazy init if needed)
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy);
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, {
bare: isBareProfile,
});
// Update last_used timestamp (check unified config first, fallback to legacy)
if (registry.hasAccountUnified(profileInfo.name)) {
+6 -2
View File
@@ -53,9 +53,13 @@ export async function handleSyncCommand(): Promise<void> {
if (profile.bare) {
continue; // Skip bare profiles
}
if (!instanceMgr.hasInstance(name)) {
continue;
}
const instancePath = instanceMgr.getInstancePath(name);
if (instancePath) {
instanceMgr.syncMcpServers(instancePath);
if (instanceMgr.syncMcpServers(instancePath)) {
mcpSynced++;
}
}
+25 -20
View File
@@ -14,7 +14,7 @@ import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/acco
import { getCcsDir, getCcsHome } from '../utils/config-manager';
/** Options for instance creation */
interface InstanceOptions {
export interface InstanceOptions {
/** Skip shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
}
@@ -108,9 +108,6 @@ class InstanceManager {
if (!options.bare) {
this.sharedManager.linkSharedDirectories(instancePath);
}
// Copy global configs if exist (settings.json only)
this.copyGlobalConfigs(instancePath);
} catch (error) {
throw new Error(
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
@@ -180,33 +177,31 @@ class InstanceManager {
return fs.existsSync(instancePath);
}
/**
* Copy global configs to instance (optional)
*/
private copyGlobalConfigs(_instancePath: string): void {
// No longer needed - settings.json now symlinked via SharedManager
}
/**
* Sync MCP servers from global ~/.claude.json to instance .claude.json.
* Selectively copies only mcpServers key (not OAuth sessions or caches).
*/
syncMcpServers(instancePath: string): void {
syncMcpServers(instancePath: string): boolean {
const homeDir = getCcsHome();
const globalClaudeJson = path.join(homeDir, '.claude.json');
if (!fs.existsSync(globalClaudeJson)) {
return;
return false;
}
try {
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
const mcpServers = globalContent.mcpServers;
if (!mcpServers || Object.keys(mcpServers).length === 0) {
return;
const rawMcpServers = globalContent.mcpServers;
if (
!rawMcpServers ||
typeof rawMcpServers !== 'object' ||
Array.isArray(rawMcpServers) ||
Object.keys(rawMcpServers).length === 0
) {
return false;
}
const mcpServers = rawMcpServers as Record<string, unknown>;
const instanceClaudeJson = path.join(instancePath, '.claude.json');
let instanceContent: Record<string, unknown> = {};
@@ -220,12 +215,22 @@ class InstanceManager {
}
// Merge: global MCP servers as base, instance-specific overrides on top
const existingMcp = (instanceContent.mcpServers as Record<string, unknown> | undefined) || {};
const rawExistingMcp = instanceContent.mcpServers;
const existingMcp =
rawExistingMcp && typeof rawExistingMcp === 'object' && !Array.isArray(rawExistingMcp)
? (rawExistingMcp as Record<string, unknown>)
: {};
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), 'utf8');
} catch {
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), {
encoding: 'utf8',
mode: 0o600,
});
return true;
} catch (error) {
// Best-effort: don't fail instance creation if MCP sync fails
console.warn(`[!] MCP sync skipped: ${(error as Error).message}`);
return false;
}
}
+2 -1
View File
@@ -268,6 +268,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
const isBare = previousUnified?.bare === true || previousLegacy?.bare === true;
try {
if (existsUnified) {
@@ -277,7 +278,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
registry.updateProfile(name, metadata);
}
await instanceMgr.ensureInstance(name, policy);
await instanceMgr.ensureInstance(name, policy, { bare: isBare });
} catch (error) {
if (existsUnified && previousUnified) {
registry.updateAccountUnified(name, previousUnified);
+16
View File
@@ -47,6 +47,22 @@ describe('auth command args parsing', () => {
expect(parsed.deeperContinuity).toBe(true);
});
it('parses bare flag for create command', () => {
const parsed = parseArgs(['work', '--bare']);
expect(parsed.profileName).toBe('work');
expect(parsed.bare).toBe(true);
});
it('parses bare flag with shared context flags', () => {
const parsed = parseArgs(['work', '--bare', '--share-context', '--context-group', 'sprint-a']);
expect(parsed.profileName).toBe('work');
expect(parsed.bare).toBe(true);
expect(parsed.shareContext).toBe(true);
expect(parsed.contextGroup).toBe('sprint-a');
});
it('tracks unknown flags and keeps positional profile intact', () => {
const parsed = parseArgs(['--foo', 'bar', 'work']);
@@ -61,7 +61,7 @@ describe('resolveProfileContinuityInheritance', () => {
mode: 'shared',
group: 'team-alpha',
continuityMode: 'deeper',
});
}, { bare: false });
});
it('supports legacy continuity_inherit_from_account fallback', async () => {
@@ -255,7 +255,46 @@ describe('resolveProfileContinuityInheritance', () => {
});
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
mode: 'isolated',
}, { bare: false });
});
it('propagates bare source-account mode when inheriting continuity', async () => {
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
version: 8,
continuity: {
inherit_from_account: {
glm: 'pro',
},
},
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
'/tmp/.ccs/instances/pro'
);
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
pro: {
type: 'account',
created: '2026-03-01T00:00:00.000Z',
last_used: null,
bare: true,
},
});
const result = await resolveProfileContinuityInheritance({
profileName: 'glm',
profileType: 'settings',
target: 'claude',
});
expect(result).toEqual({
sourceAccount: 'pro',
claudeConfigDir: '/tmp/.ccs/instances/pro',
});
expect(ensureInstanceSpy).toHaveBeenCalledWith(
'pro',
{ mode: 'isolated' },
{ bare: true }
);
});
it('does not apply km settings alias mapping to kimi cliproxy profile', async () => {
+2 -1
View File
@@ -161,7 +161,7 @@ describe('ProfileDetector', () => {
const mockUnifiedConfig = {
version: 2,
accounts: {
work: { created: '2025-01-01', last_used: '2025-01-02' },
work: { created: '2025-01-01', last_used: '2025-01-02', bare: true },
},
};
@@ -176,6 +176,7 @@ describe('ProfileDetector', () => {
expect(result.name).toBe('work');
expect(result.profile).toBeDefined();
expect((result.profile as any).type).toBe('account');
expect((result.profile as any).bare).toBe(true);
} finally {
isUnifiedModeSpy.mockRestore();
loadUnifiedConfigSpy.mockRestore();
@@ -91,4 +91,43 @@ describe('profile-registry context normalization', () => {
expect(accounts.work.context_group).toBeUndefined();
expect(accounts.work.continuity_mode).toBe('standard');
});
it('persists bare flag for legacy profiles', () => {
const registry = new ProfileRegistry();
registry.createProfile('work', { type: 'account', bare: true });
const profile = registry.getProfile('work');
expect(profile.bare).toBe(true);
});
it('persists bare flag for unified accounts and merged projection', () => {
process.env.CCS_UNIFIED_CONFIG = '1';
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-03-05T00:00:00.000Z"',
' last_used: null',
' bare: true',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const accounts = registry.getAllAccountsUnified();
expect(accounts.work.bare).toBe(true);
const merged = registry.getAllProfilesMerged();
expect(merged.work.bare).toBe(true);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { handleSyncCommand } from '../../../src/commands/sync-command';
import { ClaudeDirInstaller } from '../../../src/utils/claude-dir-installer';
import { ClaudeSymlinkManager } from '../../../src/utils/claude-symlink-manager';
import SharedManager from '../../../src/management/shared-manager';
import { InstanceManager } from '../../../src/management/instance-manager';
import ProfileRegistry from '../../../src/auth/profile-registry';
import type { ProfileMetadata } from '../../../src/types';
function profile(metadata: Partial<ProfileMetadata> = {}): ProfileMetadata {
return {
type: 'account',
created: '2026-03-05T00:00:00.000Z',
last_used: null,
...metadata,
};
}
describe('sync command MCP sync behavior', () => {
let originalProcessExit: typeof process.exit;
beforeEach(() => {
originalProcessExit = process.exit;
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
});
afterEach(() => {
process.exit = originalProcessExit;
mock.restore();
});
it('syncs MCP servers only to non-bare profiles', async () => {
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
success: true,
cleanedFiles: [],
});
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
work: profile(),
sandbox: profile({ bare: true }),
personal: profile(),
});
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
const getInstancePathSpy = spyOn(InstanceManager.prototype, 'getInstancePath').mockImplementation(
(name: string) => `/tmp/${name}`
);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => true
);
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
});
it('skips MCP sync when all profiles are bare', async () => {
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
success: true,
cleanedFiles: [],
});
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
sandbox: profile({ bare: true }),
experiment: profile({ bare: true }),
});
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => true
);
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
expect(syncMcpSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { InstanceManager } from '../../src/management/instance-manager';
import SharedManager from '../../src/management/shared-manager';
describe('InstanceManager MCP sync', () => {
let tempRoot = '';
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('merges global MCP servers and preserves instance-specific overrides', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
globalOnly: { command: 'global-cmd' },
shared: { command: 'global-shared' },
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
},
otherKey: 'keep-me',
},
null,
2
),
'utf8'
);
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(true);
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
expect(instanceContent.otherKey).toBe('keep-me');
expect(instanceContent.mcpServers).toEqual({
globalOnly: { command: 'global-cmd' },
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
});
});
it('logs warning when global MCP sync fails', () => {
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(false);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped');
});
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
() => {}
);
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
expect(linkSharedSpy).not.toHaveBeenCalled();
expect(syncMcpSpy).not.toHaveBeenCalled();
});
});