From 0b9f9826e2c838e19c4bf9bc406ab14a5456fbcc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 02:21:35 +0700 Subject: [PATCH] feat(continuity): support cross-profile continuity inheritance from account profiles --- README.md | 14 ++ docs/session-sharing-technical-analysis.md | 19 ++ src/auth/profile-continuity-inheritance.ts | 129 +++++++++++ src/ccs.ts | 67 +++++- src/cliproxy/executor/env-resolver.ts | 4 + src/cliproxy/executor/index.ts | 18 ++ src/cliproxy/types.ts | 2 + src/commands/help-command.ts | 4 + src/config/unified-config-loader.ts | 73 ++++++ src/config/unified-config-types.ts | 11 + src/copilot/copilot-executor.ts | 11 +- src/types/config.ts | 2 + .../profile-continuity-inheritance.test.ts | 213 ++++++++++++++++++ .../env-resolver-codex-fallback.test.ts | 20 ++ .../unit/copilot/copilot-executor-env.test.ts | 26 +++ tests/unit/unified-config.test.ts | 39 ++++ 16 files changed, 646 insertions(+), 6 deletions(-) create mode 100644 src/auth/profile-continuity-inheritance.ts create mode 100644 tests/unit/auth/profile-continuity-inheritance.test.ts create mode 100644 tests/unit/copilot/copilot-executor-env.test.ts diff --git a/README.md b/README.md index ada3dd8f..45aec431 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,20 @@ accounts: Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account. +#### Cross-Profile Continuity Inheritance (Claude Target) + +You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile: + +```yaml +continuity: + inherit_from_account: + glm: pro + gemini: pro + copilot: pro +``` + +With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings. + Alternative path for lower manual switching: - Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`. diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 9e4b9a68..a42b7dff 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -50,6 +50,25 @@ Deeper continuity links these directories per context group: `.anthropic` and account credentials remain isolated. +## Cross-Profile Inheritance (API / CLIProxy / Copilot) + +You can explicitly map non-account profiles (including `default`) to reuse continuity artifacts from an account profile: + +```yaml +continuity: + inherit_from_account: + glm: pro + gemini: pro + copilot: pro +``` + +Behavior: + +- Applies only when running Claude target (`ccs ` or `--target claude`) +- Does not change provider credentials or API routing +- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution +- Invalid/missing mapped accounts are skipped safely + ## User Workflows ### New account with shared context diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts new file mode 100644 index 00000000..eb5f85e8 --- /dev/null +++ b/src/auth/profile-continuity-inheritance.ts @@ -0,0 +1,129 @@ +import * as fs from 'fs'; +import { + getConfigJsonPath, + getContinuityInheritanceMap, + isUnifiedMode, +} from '../config/unified-config-loader'; +import { warn } from '../utils/ui'; +import InstanceManager from '../management/instance-manager'; +import ProfileRegistry from './profile-registry'; +import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; +import type { ProfileType } from '../types/profile'; + +export interface ProfileContinuityInheritanceInput { + profileName: string; + profileType: ProfileType; + target: string; +} + +export interface ProfileContinuityInheritanceResult { + sourceAccount?: string; + claudeConfigDir?: string; +} + +function loadLegacyContinuityInheritanceMap(): Record { + const configJsonPath = getConfigJsonPath(); + if (!fs.existsSync(configJsonPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as { continuity_inherit_from_account?: unknown }; + if ( + typeof parsed.continuity_inherit_from_account !== 'object' || + parsed.continuity_inherit_from_account === null || + Array.isArray(parsed.continuity_inherit_from_account) + ) { + return {}; + } + + const normalized: Record = {}; + for (const [profileName, accountName] of Object.entries( + parsed.continuity_inherit_from_account as Record + )) { + if (typeof accountName !== 'string') continue; + const normalizedProfile = profileName.trim(); + const normalizedAccount = accountName.trim(); + if (!normalizedProfile || !normalizedAccount) continue; + normalized[normalizedProfile] = normalizedAccount; + } + + return normalized; + } catch { + return {}; + } +} + +function resolveMappedAccount( + profileName: string, + inheritFromAccount: Record +): string | undefined { + const mapped = inheritFromAccount[profileName]; + if (typeof mapped !== 'string') { + return undefined; + } + + const normalized = mapped.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +/** + * Resolve optional cross-profile continuity inheritance. + * + * Rules: + * - Claude target only. + * - Never applies to account profiles. + * - Mapping source must be an account profile. + */ +export async function resolveProfileContinuityInheritance( + input: ProfileContinuityInheritanceInput +): Promise { + if (input.target !== 'claude' || input.profileType === 'account') { + return {}; + } + + const inheritFromAccount = getContinuityInheritanceMap(); + const sourceAccount = + resolveMappedAccount(input.profileName, inheritFromAccount) ?? + (!isUnifiedMode() + ? resolveMappedAccount(input.profileName, loadLegacyContinuityInheritanceMap()) + : undefined); + if (!sourceAccount) { + return {}; + } + + const registry = new ProfileRegistry(); + const profiles = registry.getAllProfilesMerged(); + const mappedProfile = profiles[sourceAccount]; + if (!mappedProfile || mappedProfile.type !== 'account') { + console.error( + warn( + `Continuity inheritance skipped for "${input.profileName}": source account "${sourceAccount}" not found` + ) + ); + return {}; + } + + const contextPolicy = resolveAccountContextPolicy( + isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined + ); + const instanceMgr = new InstanceManager(); + const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy); + + // Best-effort touch only; execution must continue even if touch fails. + try { + if (registry.hasAccountUnified(sourceAccount)) { + registry.touchAccountUnified(sourceAccount); + } else if (registry.hasProfile(sourceAccount)) { + registry.touchProfile(sourceAccount); + } + } catch { + // Ignore metadata touch failure. + } + + return { + sourceAccount, + claudeConfigDir: instancePath, + }; +} diff --git a/src/ccs.ts b/src/ccs.ts index 09b94e34..49e5c898 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -107,7 +107,8 @@ function detectProfile(args: string[]): DetectedProfile { async function execClaudeWithProxy( claudeCli: string, profileName: string, - args: string[] + args: string[], + claudeConfigDir?: string ): Promise { // 1. Read settings to get API key const settingsPath = getSettingsPath(profileName); @@ -208,6 +209,7 @@ async function execClaudeWithProxy( ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_MODEL: configuredModel, + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), }; const isWindows = process.platform === 'win32'; @@ -611,6 +613,8 @@ async function main(): Promise { const ProfileRegistry = ProfileRegistryModule.default; const AccountContextModule = await import('./auth/account-context'); const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; + const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); + const { resolveProfileContinuityInheritance } = ProfileContinuityModule; const detector = new ProfileDetector(); @@ -948,7 +952,23 @@ async function main(): Promise { console.error(fail('Copilot configuration not found')); process.exit(1); } - const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs); + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCopilotProfile( + copilotConfig, + remainingArgs, + continuityInheritance.claudeConfigDir + ); process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers @@ -963,6 +983,23 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); + const continuityInheritance = + resolvedTarget === 'claude' + ? await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }) + : {}; + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + // Pre-flight validation for GLM/GLMT/MiniMax profiles if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { const preflightSettingsPath = getSettingsPath(profileInfo.name); @@ -1026,7 +1063,12 @@ async function main(): Promise { process.exit(1); } // GLMT FLOW: Settings-based with embedded proxy for thinking support - await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); + await execClaudeWithProxy( + claudeCli, + profileInfo.name, + remainingArgs, + inheritedClaudeConfigDir + ); } else { // EXISTING FLOW: Settings-based profile (glm) // Use --settings flag (backward compatible) @@ -1055,6 +1097,7 @@ async function main(): Promise { const envVars: NodeJS.ProcessEnv = { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider @@ -1128,6 +1171,24 @@ async function main(): Promise { CCS_IMAGE_ANALYSIS_SKIP: '1', }; + if (resolvedTarget === 'claude') { + const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` + ) + ); + } + if (defaultContinuityInheritance.claudeConfigDir) { + envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + } + } + // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { const adapter = targetAdapter; diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index dbe49ad6..043ed4c3 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -59,6 +59,8 @@ export interface ProxyChainConfig { }; /** Composite variant: which tier is the default */ compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; + /** Optional inherited continuity directory from mapped account profile */ + claudeConfigDir?: string; } const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; @@ -113,6 +115,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record account "${continuityInheritance.sourceAccount}"` + ); + } + } // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ @@ -818,6 +834,7 @@ export async function execClaudeWithCLIProxy( isComposite: cfg.isComposite, compositeTiers: cfg.compositeTiers, compositeDefaultTier: cfg.compositeDefaultTier, + claudeConfigDir: inheritedClaudeConfigDir, }); if (initialEnvVars.ANTHROPIC_BASE_URL) { @@ -913,6 +930,7 @@ export async function execClaudeWithCLIProxy( isComposite: cfg.isComposite, compositeTiers: cfg.compositeTiers, compositeDefaultTier: cfg.compositeDefaultTier, + claudeConfigDir: inheritedClaudeConfigDir, }); if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) { diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 925ee534..f3b246de 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -197,6 +197,8 @@ export interface ExecutorConfig { compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; /** Original profile/variant name (e.g., "my-mix" for composite variants) */ profileName?: string; + /** Optional inherited continuity directory from mapped account profile */ + claudeConfigDir?: string; } /** diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index b7b8ed0e..25c53208 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -155,6 +155,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Create account profile (supports shared groups + --deeper-continuity)', ], ['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'], + [ + '~/.ccs/config.yaml', + 'Optional: continuity.inherit_from_account maps API/CLIProxy/copilot/default profiles to an account context', + ], ['ccs auth list', 'List all account profiles'], ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1fbec374..69260584 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -29,6 +29,7 @@ import { DashboardAuthConfig, ImageAnalysisConfig, CursorConfig, + ContinuityConfig, } from './unified-config-types'; import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -230,12 +231,58 @@ function validateCompositeVariants(config: UnifiedConfig): void { } } +/** + * Normalize continuity inheritance mapping payload. + * Keeps only non-empty string keys and values. + */ +function normalizeContinuityInheritanceMap(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + + const normalized: Record = {}; + for (const [profileName, accountName] of Object.entries(value as Record)) { + const normalizedProfile = profileName.trim(); + const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : ''; + + if (!normalizedProfile || !normalizedAccount) { + continue; + } + + normalized[normalizedProfile] = normalizedAccount; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +/** + * Normalize continuity section. + * Supports legacy root key: continuity_inherit_from_account. + */ +function normalizeContinuityConfig(partial: Partial): ContinuityConfig | undefined { + const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account); + if (continuityMap) { + return { inherit_from_account: continuityMap }; + } + + const legacyMap = normalizeContinuityInheritanceMap( + (partial as Partial & { continuity_inherit_from_account?: unknown }) + .continuity_inherit_from_account + ); + if (legacyMap) { + return { inherit_from_account: legacyMap }; + } + + return undefined; +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. */ function mergeWithDefaults(partial: Partial): UnifiedConfig { const defaults = createEmptyUnifiedConfig(); + const continuity = normalizeContinuityConfig(partial); return { version: partial.version ?? defaults.version, setup_completed: partial.setup_completed, @@ -328,6 +375,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.global_env?.enabled ?? true, env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, }, + continuity, // CLIProxy server config - remote/local CLIProxyAPI settings cliproxy_server: { remote: { @@ -650,6 +698,22 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Continuity inheritance section + if (config.continuity?.inherit_from_account) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles'); + lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).'); + lines.push('# Applies to Claude target only; credentials remain profile-specific.'); + lines.push('# Example: continuity.inherit_from_account.glm: pro'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + // Thinking section (extended thinking/reasoning configuration) if (config.thinking) { lines.push('# ----------------------------------------------------------------------------'); @@ -958,6 +1022,15 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get continuity inheritance mapping. + * Returns empty mapping when not configured. + */ +export function getContinuityInheritanceMap(): Record { + const config = loadOrCreateUnifiedConfig(); + return config.continuity?.inherit_from_account ?? {}; +} + /** * Get cliproxy safety configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 287cba72..5ddc755a 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -418,6 +418,15 @@ export interface GlobalEnvConfig { env: Record; } +/** + * Cross-profile continuity inheritance configuration. + * Maps execution profile names to source account profiles for CLAUDE_CONFIG_DIR reuse. + */ +export interface ContinuityConfig { + /** Profile name -> source account profile name */ + inherit_from_account?: Record; +} + /** * Default global env vars for third-party profiles. * These disable Claude Code telemetry/reporting since we're using proxy. @@ -717,6 +726,8 @@ export interface UnifiedConfig { websearch?: WebSearchConfig; /** Global environment variables for all non-Claude subscription profiles */ global_env?: GlobalEnvConfig; + /** Cross-profile continuity inheritance mapping */ + continuity?: ContinuityConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; /** Cursor IDE configuration (Cursor proxy daemon) */ diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 2fd90074..a44f7c75 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -39,7 +39,10 @@ export async function getCopilotStatus(config: CopilotConfig): Promise { +export function generateCopilotEnv( + config: CopilotConfig, + claudeConfigDir?: string +): Record { // Use mapped models if configured, otherwise fall back to default model const opusModel = config.opus_model || config.model; const sonnetModel = config.sonnet_model || config.model; @@ -59,6 +62,7 @@ export function generateCopilotEnv(config: CopilotConfig): Record { // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) try { @@ -130,7 +135,7 @@ export async function executeCopilotProfile( } // Generate environment for Claude - const copilotEnv = generateCopilotEnv(config); + const copilotEnv = generateCopilotEnv(config, claudeConfigDir); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); diff --git a/src/types/config.ts b/src/types/config.ts index bd3787ae..754753dc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -51,6 +51,8 @@ export interface Config { profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; + /** Legacy continuity inheritance mapping (profile -> source account) */ + continuity_inherit_from_account?: Record; } /** diff --git a/tests/unit/auth/profile-continuity-inheritance.test.ts b/tests/unit/auth/profile-continuity-inheritance.test.ts new file mode 100644 index 00000000..f46c1e69 --- /dev/null +++ b/tests/unit/auth/profile-continuity-inheritance.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as configLoader from '../../../src/config/unified-config-loader'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import InstanceManager from '../../../src/management/instance-manager'; +import { resolveProfileContinuityInheritance } from '../../../src/auth/profile-continuity-inheritance'; + +describe('resolveProfileContinuityInheritance', () => { + afterEach(() => { + mock.restore(); + }); + + it('returns no inheritance for non-claude targets', async () => { + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'droid', + }); + + expect(result).toEqual({}); + }); + + it('resolves mapped account and ensures inherited instance path', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'pro', + }, + }, + } as ReturnType); + + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + '/tmp/.ccs/instances/pro' + ); + const getProfilesSpy = spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue( + { + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: 'Team Alpha', + continuity_mode: 'deeper', + }, + } + ); + const hasUnifiedSpy = spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true); + const touchUnifiedSpy = spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation( + () => undefined + ); + const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation( + () => undefined + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + expect(getProfilesSpy).toHaveBeenCalledTimes(1); + expect(hasUnifiedSpy).toHaveBeenCalledWith('pro'); + expect(touchUnifiedSpy).toHaveBeenCalledWith('pro'); + expect(touchLegacySpy).not.toHaveBeenCalled(); + expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { + mode: 'shared', + group: 'team-alpha', + continuityMode: 'deeper', + }); + }); + + it('supports legacy continuity_inherit_from_account fallback', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + } as ReturnType); + spyOn(configLoader, 'isUnifiedMode').mockReturnValue(false); + spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json'); + spyOn(fs, 'existsSync').mockImplementation((filePath) => { + return filePath === '/tmp/ccs-test-config.json'; + }); + spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === '/tmp/ccs-test-config.json') { + return JSON.stringify({ + continuity_inherit_from_account: { + copilot: 'work', + }, + }); + } + return ''; + }); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + work: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(false); + spyOn(ProfileRegistry.prototype, 'hasProfile').mockReturnValue(true); + const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation( + () => undefined + ); + spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'copilot', + profileType: 'copilot', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'work', + claudeConfigDir: '/tmp/.ccs/instances/work', + }); + expect(touchLegacySpy).toHaveBeenCalledWith('work'); + }); + + it('returns empty result when mapped source account does not exist', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'missing', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({}); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('does not read legacy config fallback when unified mode is active', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + } as ReturnType); + spyOn(configLoader, 'isUnifiedMode').mockReturnValue(true); + spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json'); + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((filePath) => { + return filePath === '/tmp/ccs-test-config.json'; + }); + const readSpy = spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === '/tmp/ccs-test-config.json') { + return JSON.stringify({ + continuity_inherit_from_account: { + glm: 'pro', + }, + }); + } + return ''; + }); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({}); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + expect(existsSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + }); + + it('supports default profile inheritance when explicitly mapped', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + default: 'pro', + }, + }, + } as ReturnType); + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true); + spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(() => undefined); + spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'default', + profileType: 'default', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + }); +}); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 0162ed26..61d13406 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -92,4 +92,24 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); + + it('propagates inherited CLAUDE_CONFIG_DIR when provided', () => { + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex', + opusModel: 'gpt-5.3-codex', + sonnetModel: 'gpt-5.3-codex', + haikuModel: 'gpt-5-mini', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + claudeConfigDir: '/tmp/.ccs/instances/pro', + verbose: false, + }); + + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); + }); }); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts new file mode 100644 index 00000000..86b3db96 --- /dev/null +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test'; +import { generateCopilotEnv } from '../../../src/copilot/copilot-executor'; +import type { CopilotConfig } from '../../../src/config/unified-config-types'; + +const baseConfig: CopilotConfig = { + enabled: true, + auto_start: false, + port: 4141, + account_type: 'individual', + rate_limit: null, + wait_on_limit: true, + model: 'gpt-4.1', +}; + +describe('generateCopilotEnv', () => { + it('includes inherited CLAUDE_CONFIG_DIR when provided', () => { + const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro'); + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); + }); + + it('omits CLAUDE_CONFIG_DIR when inheritance is not configured', () => { + const env = generateCopilotEnv(baseConfig); + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); +}); + diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index b4094ea4..2b9d9c1e 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -2,6 +2,9 @@ * Unit tests for unified config module */ import { describe, it, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; // Import only modules without transitive dependencies on config-manager import { @@ -15,6 +18,7 @@ import { UNIFIED_CONFIG_VERSION, } from '../../src/config/unified-config-types'; import { isUnifiedConfigEnabled } from '../../src/config/feature-flags'; +import { loadOrCreateUnifiedConfig } from '../../src/config/unified-config-loader'; // Inline helper to test secret key detection (utility kept for potential reuse) function isSecretKey(key: string): boolean { @@ -188,3 +192,38 @@ describe('feature-flags', () => { }); }); }); + +describe('continuity-inheritance-config', () => { + it('normalizes legacy continuity_inherit_from_account into continuity.inherit_from_account', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-continuity-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'continuity_inherit_from_account:', + ' glm: pro', + ' empty: ""', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.continuity?.inherit_from_account).toEqual({ + glm: 'pro', + }); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +});