feat(continuity): support cross-profile continuity inheritance from account profiles

This commit is contained in:
Tam Nhu Tran
2026-03-03 02:49:17 +07:00
parent dbe8f6f68c
commit 0b9f9826e2
16 changed files with 646 additions and 6 deletions
+14
View File
@@ -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`.
@@ -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 <profile>` 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
+129
View File
@@ -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<string, string> {
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<string, string> = {};
for (const [profileName, accountName] of Object.entries(
parsed.continuity_inherit_from_account as Record<string, unknown>
)) {
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, string>
): 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<ProfileContinuityInheritanceResult> {
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,
};
}
+64 -3
View File
@@ -107,7 +107,8 @@ function detectProfile(args: string[]): DetectedProfile {
async function execClaudeWithProxy(
claudeCli: string,
profileName: string,
args: string[]
args: string[],
claudeConfigDir?: string
): Promise<void> {
// 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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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;
+4
View File
@@ -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<string,
isComposite,
compositeTiers,
compositeDefaultTier,
claudeConfigDir,
} = config;
// Build base env vars - check remote mode first
@@ -264,6 +267,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
const mergedEnv = {
...baseEnv,
...effectiveEnvVarsFiltered,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
+18
View File
@@ -53,6 +53,7 @@ import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unifi
import { installImageAnalyzerHook } from '../../utils/hooks';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
// Import modular components
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
@@ -795,6 +796,21 @@ export async function execClaudeWithCLIProxy(
// 9. Setup tool sanitization proxy
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
if (!inheritedClaudeConfigDir && cfg.profileName) {
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: cfg.profileName,
profileType: 'cliproxy',
target: 'claude',
});
inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
log(
`Continuity inheritance active: profile "${cfg.profileName}" -> 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) {
+2
View File
@@ -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;
}
/**
+4
View File
@@ -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 <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
+73
View File
@@ -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<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [profileName, accountName] of Object.entries(value as Record<string, unknown>)) {
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<UnifiedConfig>): ContinuityConfig | undefined {
const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account);
if (continuityMap) {
return { inherit_from_account: continuityMap };
}
const legacyMap = normalizeContinuityInheritanceMap(
(partial as Partial<UnifiedConfig> & { 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>): 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>): 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<string, string> {
const config = loadOrCreateUnifiedConfig();
return config.continuity?.inherit_from_account ?? {};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
+11
View File
@@ -418,6 +418,15 @@ export interface GlobalEnvConfig {
env: Record<string, string>;
}
/**
* 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<string, string>;
}
/**
* 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) */
+8 -3
View File
@@ -39,7 +39,10 @@ export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotSt
* Generate environment variables for Claude Code to use copilot-api.
* Uses model mapping for opus/sonnet/haiku tiers if configured.
*/
export function generateCopilotEnv(config: CopilotConfig): Record<string, string> {
export function generateCopilotEnv(
config: CopilotConfig,
claudeConfigDir?: string
): Record<string, string> {
// 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<string, string
// Disable non-essential traffic to avoid rate limiting
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
};
}
@@ -71,7 +75,8 @@ export function generateCopilotEnv(config: CopilotConfig): Record<string, string
*/
export async function executeCopilotProfile(
config: CopilotConfig,
claudeArgs: string[]
claudeArgs: string[],
claudeConfigDir?: string
): Promise<number> {
// 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();
+2
View File
@@ -51,6 +51,8 @@ export interface Config {
profile_targets?: Record<string, TargetType>;
/** User-defined CLIProxy profile variants (optional) */
cliproxy?: CLIProxyVariantsConfig;
/** Legacy continuity inheritance mapping (profile -> source account) */
continuity_inherit_from_account?: Record<string, string>;
}
/**
@@ -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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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<typeof configLoader.loadOrCreateUnifiedConfig>);
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',
});
});
});
@@ -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');
});
});
@@ -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();
});
});
+39
View File
@@ -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 });
}
});
});