From 64e1d1f815836802c1f2ed37758a9d4d104d9e14 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 8 May 2026 11:10:11 -0400 Subject: [PATCH] feat(auth): add shared resource controls --- docs/dashboard-auth-cli.md | 21 +- docs/session-sharing-technical-analysis.md | 26 ++- src/auth/auth-commands.ts | 27 ++- src/auth/commands/backup-command.ts | 3 +- src/auth/commands/create-command.ts | 35 +++- src/auth/commands/index.ts | 1 + src/auth/commands/list-command.ts | 13 +- src/auth/commands/resources-command.ts | 141 +++++++++++++ src/auth/commands/show-command.ts | 11 +- src/auth/commands/types.ts | 24 ++- src/auth/profile-continuity-inheritance.ts | 3 +- src/auth/profile-detector.ts | 1 + src/auth/profile-registry.ts | 9 +- src/auth/shared-resource-policy.ts | 97 +++++++++ src/commands/command-catalog.ts | 1 + src/commands/completion-backend.ts | 7 + src/commands/help-command.ts | 1 + src/commands/sync-command.ts | 5 +- src/config/migration-manager.ts | 11 + src/config/schemas/auth.ts | 2 + src/dispatcher/flows/account-flow.ts | 10 +- src/shared/claude-extension-setup.ts | 7 +- src/types/config.ts | 2 + .../routes/account-route-helpers.ts | 3 + src/web-server/routes/account-routes.ts | 112 ++++++++++- tests/unit/auth-command-args.test.ts | 14 ++ tests/unit/auth-resources-command.test.ts | 180 +++++++++++++++++ ...ile-registry-context-normalization.test.ts | 45 +++++ .../unit/auth/shared-resource-policy.test.ts | 55 +++++ tests/unit/commands/sync-command.test.ts | 18 +- tests/unit/config/migration-manager.test.ts | 37 ++++ .../web-server/account-routes-context.test.ts | 189 +++++++++++++++++- 32 files changed, 1068 insertions(+), 43 deletions(-) create mode 100644 src/auth/commands/resources-command.ts create mode 100644 src/auth/shared-resource-policy.ts create mode 100644 tests/unit/auth-resources-command.test.ts create mode 100644 tests/unit/auth/shared-resource-policy.test.ts diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index b2c2a33d..f99a6423 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -26,6 +26,7 @@ Dashboard auth and account context metadata are separate: - `dashboard_auth`: protects dashboard access with username/password - `accounts..context_mode/context_group`: controls isolated vs shared account context +- `accounts..shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing Account context is isolation-first. The recommended two-account route is: @@ -48,15 +49,22 @@ Shared continuity depth: - `standard` (default): shares project workspace context only - `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos` -`ccs auth show ` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. +`ccs auth show ` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. -Non-bare account profiles share the basic Claude `settings.json` with native Claude: +Non-bare account profiles share Claude-local resources with native Claude: ```text ~/.ccs/instances//settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json ``` -This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. +This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. `context_group` normalization and validation: @@ -83,6 +91,13 @@ Dashboard accounts context editing: - rejects CLIProxy OAuth account keys for this route - applies normalization/validation rules above +Shared resource editing: + +- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts +- accepts only `shared` or `profile-local` +- rejects CLIProxy OAuth account keys for this route +- reconciles the account instance after metadata is updated + ## Commands ### `ccs config auth setup` diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index a160cccb..f60734de 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -26,7 +26,7 @@ ccs personal This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials. -Basic Claude settings are shared for non-bare account profiles: +Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude: ```text ~/.ccs/instances//settings.json @@ -34,7 +34,17 @@ Basic Claude settings are shared for non-bare account profiles: -> ~/.claude/settings.json ``` -This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned. +This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned. + +For existing accounts, change Shared Resources from the CLI: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout. +- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting. Only opt in to shared history when both accounts should see the same local continuity: @@ -42,7 +52,7 @@ Only opt in to shared history when both accounts should see the same local conti ccs auth create work2 --share-context --context-group daily --deeper-continuity ``` -For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. +For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that. ## Why This Is Safe Enough @@ -147,11 +157,20 @@ ccs auth create backup2 --share-context --context-group sprint-a --deeper-contin ### Existing account +History Sync: + - Open `ccs config` - Go to `Accounts` - Click the pencil icon (`Edit History Sync`) - Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity +Shared Resources: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + No account recreation required for this workflow. ### Backup Before Changing Sync @@ -172,6 +191,7 @@ ccs auth backup default - Shared context is local filesystem sharing. It does not bypass remote provider permission models. - Session continuity still depends on what the upstream tool/provider stores and allows. - Context sharing should only be enabled for accounts you intentionally trust to share workspace history. +- Dashboard controls for existing-account Shared Resources are tracked separately; CLI/API support is the source of truth until that UI lands. ## Alternative: CLIProxy Claude Pool diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index aef02f4c..dcd248b3 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -24,6 +24,7 @@ import { handleBackup, handleList, handleShow, + handleResources, handleRemove, handleDefault, handleResetDefault, @@ -74,6 +75,9 @@ class AuthCommands { ); console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('show ', 'command')} Show profile details`); + console.log( + ` ${color('resources ', 'command')} Show or change shared resource mode` + ); console.log(` ${color('remove ', 'command')} Remove saved profile`); console.log(` ${color('default ', 'command')} Set default profile`); console.log( @@ -105,6 +109,10 @@ class AuthCommands { console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`); console.log(` ${color('ccs auth create sandbox --bare', 'command')}`); console.log(''); + console.log(` ${dim('# Change shared resources for an existing account')}`); + console.log(` ${color('ccs auth resources work --mode profile-local', 'command')}`); + console.log(` ${color('ccs auth resources work --mode shared', 'command')}`); + console.log(''); console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); @@ -136,6 +144,9 @@ class AuthCommands { console.log( ` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)` ); + console.log( + ` ${color('--mode ', 'command')} Shared resource mode for resources: shared|profile-local` + ); console.log( ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); @@ -160,6 +171,9 @@ class AuthCommands { console.log( ` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show ', 'command')} shows the link state.` ); + console.log( + ` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.` + ); console.log( ` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.` ); @@ -170,7 +184,10 @@ class AuthCommands { ` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default ', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.` ); console.log( - ` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ` Existing history sync: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ); + console.log( + ` Existing shared resources: use ${color('ccs auth resources --mode shared|profile-local', 'command')}.` ); console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); console.log( @@ -204,6 +221,10 @@ class AuthCommands { return handleShow(this.getContext(), args); } + async handleResources(args: string[]): Promise { + return handleResources(this.getContext(), args); + } + /** * Remove profile - delegates to remove-command.ts */ @@ -263,6 +284,10 @@ class AuthCommands { await this.handleShow(commandArgs); break; + case 'resources': + await this.handleResources(commandArgs); + break; + case 'remove': await this.handleRemove(commandArgs); break; diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index 4a682367..e66068be 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -10,6 +10,7 @@ import { resolveRuntimePlainCcsResumeLane, } from '../resume-lane-diagnostics'; import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; +import { isProfileLocalSharedResourceMode } from '../shared-resource-policy'; import { CommandContext, parseArgs } from './types'; interface BackupManifest { @@ -69,7 +70,7 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise isAccountContextMetadata(profile) ? profile : undefined ); sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { - bare: profile.bare === true, + bare: isProfileLocalSharedResourceMode(profile), }); artifactNames = getContinuityArtifactNames('account'); } diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index df45122d..d407ace4 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -21,6 +21,10 @@ import { isValidAccountProfileName, resolveAccountContextPolicy, } from '../account-context'; +import { + isProfileLocalSharedResourceMode, + sharedResourceModeToMetadata, +} from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; @@ -36,11 +40,20 @@ function sanitizeProfileNameForInstance(name: string): string { */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } = - parseArgs(args); + const { + profileName, + force, + shareContext, + contextGroup, + deeperContinuity, + bare, + mode, + unknownFlags, + } = parseArgs(args); - if (unknownFlags && unknownFlags.length > 0) { - const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + const unsupportedOptions = [...(unknownFlags ?? []), ...(mode !== undefined ? ['--mode'] : [])]; + if (unsupportedOptions.length > 0) { + const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(''); console.log( @@ -117,8 +130,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ? ctx.registry.getAllAccountsUnified()[profileName] : undefined; const previousBare = - previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true; + isProfileLocalSharedResourceMode(previousLegacyProfile) || + isProfileLocalSharedResourceMode(previousUnifiedProfile); const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare); + const resourceMetadata = effectiveBare ? sharedResourceModeToMetadata('profile-local') : {}; const previousContextPolicy = profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) @@ -200,13 +215,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ctx.registry.updateAccountUnified(profileName, { context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); ctx.registry.touchAccountUnified(profileName); } else { ctx.registry.createAccountUnified(profileName, { ...contextMetadata, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } else { @@ -216,14 +231,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } else { ctx.registry.createProfile(profileName, { type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } @@ -282,7 +297,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise `Type: account\n` + `Context: ${formatAccountContextPolicy(contextPolicy)}\n` + `Tokens: isolated per account\n` + - `Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` + + `Resources: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude'}` + (effectiveBare ? '\nMode: bare (no shared symlinks)' : ''), 'Profile Created' ) diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index 81d55688..e21869d5 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -19,5 +19,6 @@ export { handleCreate } from './create-command'; export { handleBackup } from './backup-command'; export { handleList } from './list-command'; export { handleShow } from './show-command'; +export { handleResources } from './resources-command'; export { handleRemove } from './remove-command'; export { handleDefault, handleResetDefault } from './default-command'; diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index f25ab770..9172e90b 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -7,6 +7,7 @@ import { ProfileMetadata } from '../../types'; import { initUI, header, color, dim, warn, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; +import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; @@ -33,6 +34,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { const profile = profiles[name]; const contextPolicy = resolveAccountContextPolicy(profile); + const resourcePolicy = resolveSharedResourcePolicy(profile); const isDefault = name === defaultProfile; const instancePath = ctx.instanceMgr.getInstancePath(name); @@ -58,6 +62,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, mode, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log( + `Usage: ${color('ccs auth resources [--mode shared|profile-local] [--json]', 'command')}` + ); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + const profiles = ctx.registry.getAllProfilesMerged(); + const currentProfile = profiles[profileName]; + if (!currentProfile || currentProfile.type !== 'account') { + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + const currentPolicy = resolveSharedResourcePolicy(currentProfile); + + if (mode !== undefined && !isSharedResourceMode(mode)) { + exitWithError( + 'Invalid shared resource mode: expected shared|profile-local', + ExitCode.PROFILE_ERROR + ); + } + + if (!mode) { + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: currentPolicy.mode, + shared_resource_inferred: currentPolicy.inferred, + bare: currentPolicy.profileLocal ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(header(`Shared Resources: ${profileName}`)); + console.log(''); + console.log( + table( + [ + ['Mode', formatMode(currentPolicy.mode)], + ['Effective', modeDescription(currentPolicy.mode)], + ], + { colWidths: [14, 70] } + ) + ); + console.log(''); + return; + } + + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const existsLegacy = ctx.registry.hasProfile(profileName); + const previousUnified = existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; + const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined; + const contextPolicy = resolveAccountContextPolicy(currentProfile); + const metadata = sharedResourceModeToMetadata(mode); + + try { + if (existsUnified) { + ctx.registry.updateAccountUnified(profileName, metadata); + } + if (existsLegacy) { + ctx.registry.updateProfile(profileName, metadata); + } + + await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { + bare: mode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + ctx.registry.updateAccountUnified(profileName, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + ctx.registry.updateProfile(profileName, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: mode, + shared_resource_inferred: false, + bare: mode === 'profile-local' ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(mode)}`)); + console.log(''); + console.log(modeDescription(mode)); + console.log(''); +} diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 54ca56de..212ef7a1 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -10,6 +10,7 @@ import { initUI, header, color, fail, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics'; import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics'; +import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ProfileOutput, parseArgs } from './types'; @@ -45,7 +46,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise null); const plainCcsUsesThisAccount = @@ -74,6 +76,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise(); const knownBooleanFlags = new Set([ '--force', @@ -97,7 +101,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { '--deeper-continuity', '--bare', ]); - const knownValueFlags = new Set(['--context-group']); + const knownValueFlags = new Set(['--context-group', '--mode']); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -114,11 +118,28 @@ export function parseArgs(args: string[]): AuthCommandArgs { continue; } + if (arg === '--mode') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + mode = ''; + continue; + } + + mode = next; + i++; + continue; + } + if (arg.startsWith('--context-group=')) { contextGroup = arg.slice('--context-group='.length); continue; } + if (arg.startsWith('--mode=')) { + mode = arg.slice('--mode='.length); + continue; + } + if (arg.startsWith('-')) { const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; const isKnownFlag = @@ -149,6 +170,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { shareContext: args.includes('--share-context'), deeperContinuity: args.includes('--deeper-continuity'), bare: args.includes('--bare'), + mode, contextGroup, unknownFlags: [...unknownFlags], }; diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 1af11922..5d6575e3 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -4,6 +4,7 @@ import { warn } from '../utils/ui'; import InstanceManager from '../management/instance-manager'; import ProfileRegistry from './profile-registry'; import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; +import { isProfileLocalSharedResourceMode } from './shared-resource-policy'; import type { ProfileType } from '../types/profile'; import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; import { @@ -155,7 +156,7 @@ export async function resolveProfileContinuityInheritance( ); const instanceMgr = new InstanceManager(); const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, { - bare: mappedProfile.bare === true, + bare: isProfileLocalSharedResourceMode(mappedProfile), }); return { diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index f577c367..8fa35701 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -213,6 +213,7 @@ class ProfileDetector { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }, }; diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 31c5482d..50f43107 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -12,6 +12,7 @@ import { loadOrCreateUnifiedConfig, mutateConfig, } from '../config/config-loader-facade'; +import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy'; const logger = createLogger('auth:profile-registry'); @@ -50,6 +51,7 @@ interface CreateMetadata { context_mode?: 'isolated' | 'shared'; context_group?: string; continuity_mode?: 'standard' | 'deeper'; + shared_resource_mode?: SharedResourceMode; bare?: boolean; } @@ -90,7 +92,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig { @@ -110,7 +112,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } /** @@ -179,6 +181,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); @@ -335,6 +338,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); }); @@ -462,6 +466,7 @@ export class ProfileRegistry { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }; } diff --git a/src/auth/shared-resource-policy.ts b/src/auth/shared-resource-policy.ts new file mode 100644 index 00000000..a569a714 --- /dev/null +++ b/src/auth/shared-resource-policy.ts @@ -0,0 +1,97 @@ +export type SharedResourceMode = 'shared' | 'profile-local'; + +export interface SharedResourceMetadata { + shared_resource_mode?: unknown; + bare?: unknown; +} + +export interface SharedResourcePolicy { + mode: SharedResourceMode; + inferred: boolean; + profileLocal: boolean; +} + +export interface SharedResourceMetadataUpdate { + shared_resource_mode: SharedResourceMode; + bare?: boolean | undefined; +} + +export function isSharedResourceMode(value: unknown): value is SharedResourceMode { + return value === 'shared' || value === 'profile-local'; +} + +export function resolveSharedResourcePolicy( + metadata?: SharedResourceMetadata | null +): SharedResourcePolicy { + const explicitMode = metadata?.shared_resource_mode; + if (isSharedResourceMode(explicitMode)) { + return { + mode: explicitMode, + inferred: false, + profileLocal: explicitMode === 'profile-local', + }; + } + + if (metadata?.bare === true) { + return { + mode: 'profile-local', + inferred: true, + profileLocal: true, + }; + } + + return { + mode: 'shared', + inferred: true, + profileLocal: false, + }; +} + +export function isProfileLocalSharedResourceMode( + metadata?: SharedResourceMetadata | null +): boolean { + return resolveSharedResourcePolicy(metadata).profileLocal; +} + +export function sharedResourceModeToMetadata( + mode: SharedResourceMode +): SharedResourceMetadataUpdate { + if (mode === 'profile-local') { + return { + shared_resource_mode: 'profile-local', + bare: true, + }; + } + + return { + shared_resource_mode: 'shared', + bare: undefined, + }; +} + +export function normalizeSharedResourceMetadata(metadata: T): T { + const normalized = { ...metadata } as T & { + shared_resource_mode?: SharedResourceMode; + bare?: boolean; + }; + + if (normalized.shared_resource_mode === 'profile-local') { + normalized.bare = true; + return normalized; + } + + if (normalized.shared_resource_mode === 'shared') { + delete normalized.bare; + return normalized; + } + + delete normalized.shared_resource_mode; + if (normalized.bare === true) { + normalized.shared_resource_mode = 'profile-local'; + normalized.bare = true; + } else { + delete normalized.bare; + } + + return normalized; +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index a45b21e2..3dbe0371 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [ 'backup', 'list', 'show', + 'resources', 'remove', 'default', 'reset-default', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 660503af..78dfca09 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -108,6 +108,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']); if (subcommand === 'show') return completeSubcommands(getProfileNames('accounts'), ['--json']); + if (subcommand === 'resources') + return completeSubcommands(getProfileNames('accounts'), [ + '--mode', + '--mode=shared', + '--mode=profile-local', + '--json', + ]); if (subcommand === 'remove') return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']); if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts')); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 014df452..4dd736fb 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise { 'Profile Types', [ { name: 'ccs auth create ', summary: 'Concurrent Claude account profile' }, + { name: 'ccs auth resources ', summary: 'Shared resources for an account profile' }, { name: 'ccs api create', summary: 'API-backed settings profile' }, { name: 'ccs cliproxy create ', summary: 'Named CLIProxy variant profile' }, { name: 'ccs env ', summary: 'Export an existing profile for other tools' }, diff --git a/src/commands/sync-command.ts b/src/commands/sync-command.ts index 004c5903..d24b9051 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise { const { InstanceManager } = await import('../management/instance-manager'); const instanceMgr = new InstanceManager(); const ProfileRegistry = (await import('../auth/profile-registry')).default; + const { isProfileLocalSharedResourceMode } = await import('../auth/shared-resource-policy'); const registry = new ProfileRegistry(); const allProfiles = registry.getAllProfilesMerged(); let mcpSynced = 0; for (const [name, profile] of Object.entries(allProfiles)) { - if (profile.bare) { - continue; // Skip bare profiles + if (isProfileLocalSharedResourceMode(profile)) { + continue; // Skip profile-local shared-resource profiles } if (!instanceMgr.hasInstance(name)) { diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 650cd07d..f7490f7b 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -22,6 +22,7 @@ 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 { isSharedResourceMode, resolveSharedResourcePolicy } from '../auth/shared-resource-policy'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -222,6 +223,16 @@ export async function migrate(dryRun = false): Promise { context_group: contextMode === 'shared' ? contextGroup : undefined, continuity_mode: contextMode === 'shared' ? continuityMode : undefined, }; + const resourcePolicy = resolveSharedResourcePolicy({ + shared_resource_mode: metadata.shared_resource_mode, + bare: metadata.bare, + }); + if (resourcePolicy.mode === 'profile-local') { + account.shared_resource_mode = 'profile-local'; + account.bare = true; + } else if (isSharedResourceMode(metadata.shared_resource_mode)) { + account.shared_resource_mode = 'shared'; + } unifiedConfig.accounts[name] = account; } migratedFiles.push('profiles.json → config.yaml.accounts'); diff --git a/src/config/schemas/auth.ts b/src/config/schemas/auth.ts index 4056ecf8..2b4eb944 100644 --- a/src/config/schemas/auth.ts +++ b/src/config/schemas/auth.ts @@ -27,6 +27,8 @@ export interface AccountConfig { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; + /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + shared_resource_mode?: 'shared' | 'profile-local'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts index 6fec0073..bf67bc80 100644 --- a/src/dispatcher/flows/account-flow.ts +++ b/src/dispatcher/flows/account-flow.ts @@ -7,6 +7,7 @@ import { execClaude } from '../../utils/shell-executor'; import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy'; import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; import type { ProfileDispatchContext } from '../dispatcher-context'; @@ -26,10 +27,11 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise 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 isBareProfile = isProfileLocalSharedResourceMode( + typeof profileInfo.profile === 'object' && profileInfo.profile !== null + ? (profileInfo.profile as { shared_resource_mode?: unknown; bare?: unknown }) + : undefined + ); const contextPolicy = resolveAccountContextPolicy(accountMetadata); // Ensure instance exists (lazy init if needed) diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 9f1a860e..f29e5e21 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -2,6 +2,7 @@ import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector import ProfileDetector from '../auth/profile-detector'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context'; +import { isProfileLocalSharedResourceMode } from '../auth/shared-resource-policy'; import type { ProfileDetectionResult } from '../auth/profile-detector'; import { getEffectiveEnvVars, @@ -158,8 +159,12 @@ async function resolveExtensionEnv( const policy = resolveAccountContextPolicy( isAccountContextMetadata(result.profile) ? result.profile : undefined ); + const sharedResourceMetadata = + typeof result.profile === 'object' && result.profile !== null + ? (result.profile as { shared_resource_mode?: unknown; bare?: unknown }) + : undefined; const instancePath = await instanceManager.ensureInstance(result.name, policy, { - bare: result.profile?.bare === true, + bare: isProfileLocalSharedResourceMode(sharedResourceMetadata), }); notes.push('Account profiles authenticate through the isolated Claude config directory.'); return { diff --git a/src/types/config.ts b/src/types/config.ts index 47de4f0d..32c7f286 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -121,6 +121,8 @@ export interface ProfileMetadata { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; + /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + shared_resource_mode?: 'shared' | 'profile-local'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts index bbf05469..21f0e2e2 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -10,6 +10,9 @@ export interface MergedAccountEntry { continuity_mode?: 'standard' | 'deeper'; context_inferred?: boolean; continuity_inferred?: boolean; + shared_resource_mode?: 'shared' | 'profile-local'; + shared_resource_inferred?: boolean; + bare?: boolean; provider?: string; displayName?: string; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index df520087..50294ae8 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -26,6 +26,11 @@ import { normalizeContextGroupName, resolveAccountContextPolicy, } from '../../auth/account-context'; +import { + isSharedResourceMode, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, +} from '../../auth/shared-resource-policy'; import { buildCliproxyAccountKey, parseCliproxyKey, @@ -79,6 +84,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { const contextPolicy = resolveAccountContextPolicy(meta); + const resourcePolicy = resolveSharedResourcePolicy(meta); const hasExplicitContextMode = meta.context_mode === 'isolated' || meta.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -93,6 +99,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -100,6 +109,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { for (const [name, account] of Object.entries(unifiedAccounts)) { const rawAccount = rawUnifiedAccounts[name]; const contextPolicy = resolveAccountContextPolicy(account); + const resourcePolicy = resolveSharedResourcePolicy(account); const hasExplicitContextMode = rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -114,6 +124,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -305,7 +318,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; - const isBare = previousUnified?.bare === true || previousLegacy?.bare === true; + const resourcePolicy = resolveSharedResourcePolicy(previousUnified ?? previousLegacy); try { if (existsUnified) { @@ -315,13 +328,21 @@ router.put('/:name/context', async (req: Request, res: Response): Promise registry.updateProfile(name, metadata); } - await instanceMgr.ensureInstance(name, policy, { bare: isBare }); + await instanceMgr.ensureInstance(name, policy, { bare: resourcePolicy.profileLocal }); } catch (error) { if (existsUnified && previousUnified) { - registry.updateAccountUnified(name, previousUnified); + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); } if (existsLegacy && previousLegacy) { - registry.updateProfile(name, previousLegacy); + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); } throw error; } @@ -342,6 +363,89 @@ router.put('/:name/context', async (req: Request, res: Response): Promise } }); +/** + * PUT /api/accounts/:name/shared-resources - Update account shared resource mode + */ +router.put('/:name/shared-resources', async (req: Request, res: Response): Promise => { + try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; + if (cliproxyKey) { + res.status(400).json({ + error: `Shared resource mode is not supported for CLIProxy account: ${name}`, + }); + return; + } + + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); + if (!existsUnified && !existsLegacy) { + res.status(404).json({ error: `Account not found: ${name}` }); + return; + } + + const mode = req.body?.shared_resource_mode; + if (!isSharedResourceMode(mode)) { + res.status(400).json({ + error: 'Missing or invalid shared_resource_mode: expected shared|profile-local', + }); + return; + } + + const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; + const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + const previousMetadata = previousUnified ?? previousLegacy; + const contextPolicy = resolveAccountContextPolicy(previousMetadata); + const metadata = sharedResourceModeToMetadata(mode); + + try { + if (existsUnified) { + registry.updateAccountUnified(name, metadata); + } + if (existsLegacy) { + registry.updateProfile(name, metadata); + } + + await instanceMgr.ensureInstance(name, contextPolicy, { + bare: mode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + res.json({ + name, + shared_resource_mode: mode, + shared_resource_inferred: false, + bare: mode === 'profile-local' ? true : undefined, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index 5b5838cb..7673dee4 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -63,6 +63,20 @@ describe('auth command args parsing', () => { expect(parsed.contextGroup).toBe('sprint-a'); }); + it('parses shared resource mode value for resources command', () => { + const parsed = parseArgs(['work', '--mode', 'profile-local']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBe('profile-local'); + }); + + it('parses inline shared resource mode value', () => { + const parsed = parseArgs(['work', '--mode=shared']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBe('shared'); + }); + it('tracks unknown flags and keeps positional profile intact', () => { const parsed = parseArgs(['--foo', 'bar', 'work']); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts new file mode 100644 index 00000000..d76bc36f --- /dev/null +++ b/tests/unit/auth-resources-command.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../src/auth/profile-registry'; +import InstanceManager from '../../src/management/instance-manager'; +import { handleResources } from '../../src/auth/commands/resources-command'; +import { handleCreate } from '../../src/auth/commands/create-command'; + +describe('auth resources command', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempRoot; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('updates an existing account shared resource mode without changing context metadata', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'work') + ); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await handleResources( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local', '--json'] + ); + } finally { + console.log = originalLog; + ensureSpy.mockRestore(); + } + + const output = JSON.parse(lines.join('\n')) as { + shared_resource_mode: string; + bare?: boolean; + }; + expect(output.shared_resource_mode).toBe('profile-local'); + expect(output.bare).toBe(true); + + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBe('profile-local'); + expect(account.bare).toBe(true); + expect(account.context_mode).toBe('shared'); + expect(account.context_group).toBe('sprint-a'); + }); + + it('rolls back inferred shared metadata when instance reconciliation fails', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('reconcile failed') + ); + + try { + await expect( + handleResources( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local'] + ) + ).rejects.toThrow('reconcile failed'); + } finally { + ensureSpy.mockRestore(); + } + + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBeUndefined(); + expect(account.bare).toBeUndefined(); + }); + + it('rejects --mode on auth create instead of silently ignoring it', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const originalExit = process.exit; + const originalLog = console.log; + const originalError = console.error; + const lines: string[] = []; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await expect( + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local'] + ) + ).rejects.toThrow('process.exit(7)'); + } finally { + process.exit = originalExit; + console.log = originalLog; + console.error = originalError; + } + + expect(lines.join('\n')).toContain('Unknown option(s): "--mode"'); + }); +}); diff --git a/tests/unit/auth/profile-registry-context-normalization.test.ts b/tests/unit/auth/profile-registry-context-normalization.test.ts index 8ad59688..6616591d 100644 --- a/tests/unit/auth/profile-registry-context-normalization.test.ts +++ b/tests/unit/auth/profile-registry-context-normalization.test.ts @@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => { const profile = registry.getProfile('work'); expect(profile.bare).toBe(true); + expect(profile.shared_resource_mode).toBe('profile-local'); }); it('persists bare flag for unified accounts and merged projection', () => { @@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => { const accounts = registry.getAllAccountsUnified(); expect(accounts.work.bare).toBe(true); + expect(accounts.work.shared_resource_mode).toBe('profile-local'); const merged = registry.getAllProfilesMerged(); expect(merged.work.bare).toBe(true); + expect(merged.work.shared_resource_mode).toBe('profile-local'); + }); + + it('lets explicit shared resource mode override stale unified bare metadata', () => { + 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', + ' shared_resource_mode: shared', + ' bare: true', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const accounts = registry.getAllAccountsUnified(); + + expect(accounts.work.shared_resource_mode).toBe('shared'); + expect(accounts.work.bare).toBeUndefined(); + }); + + it('persists explicit profile-local mode for legacy profiles', () => { + const registry = new ProfileRegistry(); + registry.createProfile('work', { + type: 'account', + shared_resource_mode: 'profile-local', + }); + + const profile = registry.getProfile('work'); + expect(profile.shared_resource_mode).toBe('profile-local'); + expect(profile.bare).toBe(true); }); }); diff --git a/tests/unit/auth/shared-resource-policy.test.ts b/tests/unit/auth/shared-resource-policy.test.ts new file mode 100644 index 00000000..e0306a54 --- /dev/null +++ b/tests/unit/auth/shared-resource-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test'; +import { + normalizeSharedResourceMetadata, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, +} from '../../../src/auth/shared-resource-policy'; + +describe('shared resource policy', () => { + it('defaults missing metadata to shared resources', () => { + const policy = resolveSharedResourcePolicy(undefined); + + expect(policy.mode).toBe('shared'); + expect(policy.inferred).toBe(true); + expect(policy.profileLocal).toBe(false); + }); + + it('resolves legacy bare metadata as profile-local', () => { + const policy = resolveSharedResourcePolicy({ bare: true }); + + expect(policy.mode).toBe('profile-local'); + expect(policy.inferred).toBe(true); + expect(policy.profileLocal).toBe(true); + }); + + it('lets explicit shared mode override stale bare metadata', () => { + const normalized = normalizeSharedResourceMetadata({ + shared_resource_mode: 'shared', + bare: true, + }); + + expect(normalized.shared_resource_mode).toBe('shared'); + expect(normalized.bare).toBeUndefined(); + expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false); + }); + + it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => { + const normalized = normalizeSharedResourceMetadata({ + shared_resource_mode: 'profile-local', + }); + + expect(normalized.shared_resource_mode).toBe('profile-local'); + expect(normalized.bare).toBe(true); + }); + + it('builds write metadata for both supported modes', () => { + expect(sharedResourceModeToMetadata('profile-local')).toEqual({ + shared_resource_mode: 'profile-local', + bare: true, + }); + expect(sharedResourceModeToMetadata('shared')).toEqual({ + shared_resource_mode: 'shared', + bare: undefined, + }); + }); +}); diff --git a/tests/unit/commands/sync-command.test.ts b/tests/unit/commands/sync-command.test.ts index a0d94b98..c4e62382 100644 --- a/tests/unit/commands/sync-command.test.ts +++ b/tests/unit/commands/sync-command.test.ts @@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => { mock.restore(); }); - it('syncs MCP servers only to non-bare profiles', async () => { + it('syncs MCP servers only to shared-resource profiles', async () => { spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true); spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({ success: true, @@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => { spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ work: profile(), sandbox: profile({ bare: true }), + local: profile({ shared_resource_mode: 'profile-local' }), + restored: profile({ bare: true, shared_resource_mode: 'shared' }), personal: profile(), }); spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); @@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => { 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']); + expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([ + 'work', + 'restored', + 'personal', + ]); + expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([ + '/tmp/work', + '/tmp/restored', + '/tmp/personal', + ]); }); it('skips MCP sync when all profiles are bare', async () => { @@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => { spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {}); spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ sandbox: profile({ bare: true }), - experiment: profile({ bare: true }), + experiment: profile({ shared_resource_mode: 'profile-local' }), }); spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 0beb80f1..8e754151 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => { expect(unified?.accounts.personal.continuity_mode).toBeUndefined(); }); + it('migrates legacy bare accounts to profile-local shared resource mode', async () => { + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + default: 'sandbox', + profiles: { + sandbox: { + type: 'account', + created: '2026-02-01T00:00:00.000Z', + last_used: null, + bare: true, + }, + restored: { + type: 'account', + created: '2026-02-02T00:00:00.000Z', + last_used: null, + shared_resource_mode: 'shared', + bare: true, + }, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(false); + expect(result.success).toBe(true); + + const unified = loadUnifiedConfig(); + expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local'); + expect(unified?.accounts.sandbox.bare).toBe(true); + expect(unified?.accounts.restored.shared_resource_mode).toBe('shared'); + expect(unified?.accounts.restored.bare).toBeUndefined(); + }); + it('applies safe browser defaults when migrating legacy config files', async () => { fs.writeFileSync( path.join(ccsDir, 'config.json'), diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 093a6b14..9675a7b0 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import express from 'express'; import * as fs from 'fs'; import * as os from 'os'; @@ -262,6 +262,51 @@ describe('web-server account-routes context normalization', () => { expect(fs.existsSync(instancePath)).toBe(false); }); + it('reports effective shared resource mode without initializing an account instance', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' shared_resource_mode: profile-local', + ' restored:', + ' created: "2026-02-02T00:00:00.000Z"', + ' shared_resource_mode: shared', + ' bare: true', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + accounts: Array<{ + name: string; + shared_resource_mode?: string; + shared_resource_inferred?: boolean; + bare?: boolean; + }>; + }>(baseUrl, '/api/accounts'); + + const work = payload.accounts.find((account) => account.name === 'work'); + const restored = payload.accounts.find((account) => account.name === 'restored'); + expect(work?.shared_resource_mode).toBe('profile-local'); + expect(work?.shared_resource_inferred).toBe(false); + expect(work?.bare).toBe(true); + expect(restored?.shared_resource_mode).toBe('shared'); + expect(restored?.shared_resource_inferred).toBe(false); + expect(restored?.bare).toBeUndefined(); + expect(fs.existsSync(path.join(ccsDir, 'instances', 'work'))).toBe(false); + }); + it('does not delete metadata when instance deletion fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -354,6 +399,148 @@ describe('web-server account-routes context normalization', () => { expect(work?.continuity_mode).toBe('deeper'); }); + it('updates existing account shared resource mode without changing history sync metadata', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'work') + ); + + try { + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + shared_resource_mode: string; + shared_resource_inferred?: boolean; + }; + expect(payload.shared_resource_mode).toBe('profile-local'); + expect(payload.shared_resource_inferred).toBe(false); + expect(ensureSpy).toHaveBeenCalledWith( + 'work', + { + mode: 'shared', + group: 'sprint-a', + continuityMode: 'deeper', + }, + { bare: true } + ); + } finally { + ensureSpy.mockRestore(); + } + + const registry = new ProfileRegistry(); + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBe('profile-local'); + expect(account.bare).toBe(true); + expect(account.context_mode).toBe('shared'); + expect(account.context_group).toBe('sprint-a'); + expect(account.continuity_mode).toBe('deeper'); + }); + + it('rolls back inferred shared resource metadata when instance reconciliation fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('reconcile failed') + ); + + try { + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(500); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('reconcile failed'); + } finally { + ensureSpy.mockRestore(); + } + + const registry = new ProfileRegistry(); + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBeUndefined(); + expect(account.bare).toBeUndefined(); + }); + + it('rejects invalid shared resource mode updates', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'plugins-only', + }); + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('shared_resource_mode'); + }); + + it('rejects shared resource updates for CLIProxy account identifiers', async () => { + const response = await putJson(baseUrl, '/api/accounts/gemini:test/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(400); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('CLIProxy'); + }); + it('rejects shared mode updates without context_group', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true });