diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 976dbcc2..c08312b0 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,10 +1,10 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2427, - "filesAffected": 258, - "hotpathOccurrences": 1142, - "hotpathFilesAffected": 152, + "totalOccurrences": 2446, + "filesAffected": 260, + "hotpathOccurrences": 1161, + "hotpathFilesAffected": 154, "topHotpathFiles": [ { "file": "src/management/shared-manager/diverged-file-adopter.ts", @@ -498,10 +498,10 @@ "maintainability": { "typedErrors": { "totalThrows": 452, - "typedThrows": 80, - "plainThrows": 312, + "typedThrows": 81, + "plainThrows": 311, "otherThrows": 60, - "adoptionRatio": 0.177, + "adoptionRatio": 0.1792, "topSubdomainsByThrows": [ { "subdomain": "web-server", @@ -579,8 +579,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 759, - "coverageRatio": 0.0856, + "totalSourceFiles": 761, + "coverageRatio": 0.0854, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -616,7 +616,7 @@ }, { "subdomain": "management", - "count": 33, + "count": 35, "withLogger": 1 }, { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0b8a3082..e5ec8d34 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2427 | -| Sync fs files affected (all) | 258 | -| Sync fs occurrences (runtime hotpaths) | 1142 | -| Sync fs files affected (runtime hotpaths) | 152 | +| Sync fs occurrences (all) | 2446 | +| Sync fs files affected (all) | 260 | +| Sync fs occurrences (runtime hotpaths) | 1161 | +| Sync fs files affected (runtime hotpaths) | 154 | | Legacy shim markers | 458 | | Legacy shim files affected | 173 | @@ -56,11 +56,11 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| typed-error adoption (typed/total throws) | 17.7% (80/452) | +| typed-error adoption (typed/total throws) | 17.9% (81/452) | | typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | | hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 65/759 | +| files with createLogger | 65/761 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 7556094e..e4309702 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -142,7 +142,7 @@ class AuthCommands { ` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts` ); console.log( - ` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)` + ` ${color('--bare', 'command')} Create clean profile without shared settings/instructions/resources` ); console.log( ` ${color('--mode ', 'command')} Shared resource mode for resources: shared|profile-local` @@ -169,10 +169,10 @@ class AuthCommands { ); console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`); 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.` + ` Non-bare account profiles share ${color('settings.json', 'path')} and ${color('CLAUDE.md', 'path')} from ${color('~/.claude/', 'path')}; ${color('ccs auth show ', 'command')} shows resource mode and settings link state.` ); console.log( - ` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.` + ` Shared Resources control plugins/commands/skills/agents/settings.json/CLAUDE.md; 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')}.` diff --git a/src/config/schemas/auth.ts b/src/config/schemas/auth.ts index 2b4eb944..1b9287e7 100644 --- a/src/config/schemas/auth.ts +++ b/src/config/schemas/auth.ts @@ -27,9 +27,9 @@ 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 */ + /** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */ shared_resource_mode?: 'shared' | 'profile-local'; - /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ + /** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } diff --git a/src/management/instance-directory.ts b/src/management/instance-directory.ts index 56b0b384..ab5a15c9 100644 --- a/src/management/instance-directory.ts +++ b/src/management/instance-directory.ts @@ -5,6 +5,116 @@ export function isAccountInstanceName(name: string): boolean { return !name.startsWith('.'); } +export function normalizeAccountInstanceName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); +} + +function unsafeInstancePath(targetPath: string): never { + throw Object.assign(new TypeError(`Unsafe account instance path: ${targetPath}`), { + code: 'EINVAL', + }); +} + +interface DirectoryIdentity { + device: number; + inode: number; + realPath: string; +} + +function readDirectoryIdentity(directoryPath: string): DirectoryIdentity { + const stats = fs.lstatSync(directoryPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(directoryPath); + return { + device: stats.dev, + inode: stats.ino, + realPath: fs.realpathSync.native(directoryPath), + }; +} + +function identitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return ( + left.device === right.device && left.inode === right.inode && left.realPath === right.realPath + ); +} + +function createDirectoryUnderStableParent(directoryPath: string): void { + const parentPath = path.dirname(directoryPath); + const parentBefore = readDirectoryIdentity(parentPath); + + try { + fs.mkdirSync(directoryPath, { mode: 0o700 }); + readDirectoryIdentity(directoryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + + let parentAfter: DirectoryIdentity; + try { + parentAfter = readDirectoryIdentity(parentPath); + } catch { + unsafeInstancePath(parentPath); + } + + if (!identitiesMatch(parentBefore, parentAfter)) { + unsafeInstancePath(parentPath); + } + + readDirectoryIdentity(directoryPath); +} + +function isPathWithinDirectory(candidatePath: string, directoryPath: string): boolean { + const relative = path.relative(directoryPath, candidatePath); + return relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`); +} + +/** Reject symlink roots/entries and require the real instance path to stay under the real root. */ +export function isSafeAccountInstancePath(instancesDir: string, instancePath: string): boolean { + try { + const rootStats = fs.lstatSync(instancesDir); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) return false; + + const instanceStats = fs.lstatSync(instancePath); + if (instanceStats.isSymbolicLink() || !instanceStats.isDirectory()) return false; + + const realRoot = fs.realpathSync.native(instancesDir); + const realInstance = fs.realpathSync.native(instancePath); + return isPathWithinDirectory(realInstance, realRoot); + } catch { + return false; + } +} + +/** Create a missing managed instances root without traversing symlink parents. */ +export function ensureSafeAccountInstancesDirectory(instancesDir: string): void { + const missingPaths: string[] = []; + let currentPath = instancesDir; + + while (true) { + try { + const stats = fs.lstatSync(currentPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(currentPath); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + missingPaths.push(currentPath); + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) unsafeInstancePath(instancesDir); + currentPath = parentPath; + } + } + + for (const directoryPath of missingPaths.reverse()) { + createDirectoryUnderStableParent(directoryPath); + } + + const rootStats = fs.lstatSync(instancesDir); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) unsafeInstancePath(instancesDir); +} + +export function assertSafeAccountInstancePath(instancesDir: string, instancePath: string): void { + if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath); +} + export function listAccountInstanceNames(instancesDir: string): string[] { if (!fs.existsSync(instancesDir)) { return []; @@ -15,11 +125,7 @@ export function listAccountInstanceNames(instancesDir: string): string[] { return false; } - try { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - } catch { - return false; - } + return isSafeAccountInstancePath(instancesDir, path.join(instancesDir, name)); }); } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index ba23138e..5667784e 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -15,7 +15,13 @@ import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsHome } from '../utils/config-manager'; import { createLogger } from '../services/logging'; import { getCcsDir } from '../config/config-loader-facade'; -import { listAccountInstanceNames } from './instance-directory'; +import { ProfileError } from '../errors/error-types'; +import { + assertSafeAccountInstancePath, + ensureSafeAccountInstancesDirectory, + listAccountInstanceNames, + normalizeAccountInstanceName, +} from './instance-directory'; const logger = createLogger('management:instance-manager'); @@ -23,7 +29,7 @@ const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', /** Options for instance creation */ export interface InstanceOptions { - /** Skip shared symlinks (commands, skills, agents, settings.json) */ + /** Skip shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } @@ -52,11 +58,18 @@ class InstanceManager { options: InstanceOptions = {} ): Promise { const instancePath = this.getInstancePath(profileName); + const initialInstanceStats = this.getInstanceStats(instancePath); + if (initialInstanceStats) { + assertSafeAccountInstancePath(this.instancesDir, instancePath); + } else { + ensureSafeAccountInstancesDirectory(this.instancesDir); + } // Serialize context sync operations per profile across processes. await this.contextSyncLock.withLock(profileName, async () => { // Lazy initialization - if (!fs.existsSync(instancePath)) { + if (!this.getInstanceStats(instancePath)) { + ensureSafeAccountInstancesDirectory(this.instancesDir); logger.stage('route', 'instance.init', 'Initializing new profile instance', { profile: profileName, bare: options.bare === true, @@ -64,6 +77,9 @@ class InstanceManager { this.initializeInstance(profileName, instancePath, options); } + // Re-check after locking/creation before any operation can mutate the instance. + assertSafeAccountInstancePath(this.instancesDir, instancePath); + // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); @@ -94,7 +110,7 @@ class InstanceManager { * Get instance path for profile */ getInstancePath(profileName: string): string { - const safeName = this.sanitizeName(profileName); + const safeName = normalizeAccountInstanceName(profileName); return path.join(this.instancesDir, safeName); } @@ -108,30 +124,14 @@ class InstanceManager { ): void { try { // Create base directory - fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 }); + fs.mkdirSync(instancePath, { mode: 0o700 }); - // Create Claude-expected subdirectories (profile-specific only) - const subdirs = [ - 'session-env', - 'todos', - 'logs', - 'file-history', - 'shell-snapshots', - 'debug', - '.anthropic', - ]; - - subdirs.forEach((dir) => { - const dirPath = path.join(instancePath, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - } - }); - - // Shared links are created during ensureInstance() under the plugin layout lock. + // Expected subdirectories and shared links are created only after the + // caller validates that this new root stayed inside the managed parent. } catch (error) { - throw new Error( - `Failed to initialize instance for ${profileName}: ${(error as Error).message}` + throw new ProfileError( + `Failed to initialize instance for ${profileName}: ${(error as Error).message}`, + profileName ); } } @@ -268,12 +268,13 @@ class InstanceManager { } } - /** - * Sanitize profile name for filesystem - */ - private sanitizeName(name: string): string { - // Replace unsafe characters with dash - return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + private getInstanceStats(instancePath: string): fs.Stats | null { + try { + return fs.lstatSync(instancePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } } } diff --git a/src/management/shared-manager/canonical-first-file-claims.ts b/src/management/shared-manager/canonical-first-file-claims.ts new file mode 100644 index 00000000..ed7d11fb --- /dev/null +++ b/src/management/shared-manager/canonical-first-file-claims.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { warn } from '../../utils/ui'; +import { getLstatSync } from './fs-helpers'; + +let claimSequence = 0; + +export function readRegularFileContent(filePath: string): Buffer | null { + const stats = getLstatSync(filePath); + return stats?.isFile() ? fs.readFileSync(filePath) : null; +} + +function preserveClaim(filePath: string, claimPath: string, reason: string): string { + const basePath = `${filePath}.ccs-shared-conflict`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + fs.linkSync(claimPath, recoveryPath); + try { + fs.unlinkSync(claimPath); + } catch { + // Both names preserve the same inode, so leaving the claim is safe. + } + console.log(warn(`${reason}; preserved content at ${recoveryPath}`)); + return recoveryPath; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EEXIST') { + sequence++; + continue; + } + + try { + fs.copyFileSync(claimPath, recoveryPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(recoveryPath, fs.statSync(claimPath).mode & 0o777); + fs.unlinkSync(claimPath); + console.log( + warn(`${reason}; copied content to ${recoveryPath} (${code ?? 'link failed'})`) + ); + return recoveryPath; + } catch (copyError) { + if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') { + sequence++; + continue; + } + + if (!getLstatSync(filePath)) { + try { + fs.renameSync(claimPath, filePath); + } catch { + // The claim remains the only recoverable copy if restoration loses a race. + } + } + console.log(warn(`${reason}; unable to publish recovery, restored original when possible`)); + throw err; + } + } + } +} + +function interruptedClaimPaths(filePath: string): string[] { + const directory = path.dirname(filePath); + const prefix = `${path.basename(filePath)}.ccs-shared-claim-`; + try { + return fs + .readdirSync(directory) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => path.join(directory, entry)) + .filter((entryPath) => fs.lstatSync(entryPath).isFile()) + .sort(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } +} + +export function recoverInterruptedClaims(filePath: string): void { + const claims = interruptedClaimPaths(filePath); + if (claims.length === 0) return; + + if (claims.length === 1 && !getLstatSync(filePath)) { + fs.renameSync(claims[0], filePath); + console.log(warn(`Recovered interrupted shared-file claim at ${filePath}`)); + return; + } + + for (const claimPath of claims) { + preserveClaim(filePath, claimPath, `Quarantined interrupted shared-file claim for ${filePath}`); + } +} + +export function claimCanonicalFirstCopy( + filePath: string, + canonicalContent: Buffer | null, + reason: string +): boolean { + const initialStats = getLstatSync(filePath); + if (!initialStats?.isFile()) return false; + + const claimPath = `${filePath}.ccs-shared-claim-${process.pid}-${Date.now()}-${claimSequence++}`; + fs.renameSync(filePath, claimPath); + if (getLstatSync(filePath)) { + preserveClaim(filePath, claimPath, `Concurrent replacement detected at ${filePath}`); + throw Object.assign(new TypeError(`Concurrent replacement detected at ${filePath}`), { + code: 'EEXIST', + }); + } + + const claimedContent = fs.readFileSync(claimPath); + if (canonicalContent?.equals(claimedContent)) { + fs.unlinkSync(claimPath); + } else { + preserveClaim(filePath, claimPath, reason); + } + if (getLstatSync(filePath)) { + throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${filePath}`), { + code: 'EEXIST', + }); + } + return true; +} diff --git a/src/management/shared-manager/canonical-first-file-reconciler.ts b/src/management/shared-manager/canonical-first-file-reconciler.ts new file mode 100644 index 00000000..86496b9a --- /dev/null +++ b/src/management/shared-manager/canonical-first-file-reconciler.ts @@ -0,0 +1,142 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, warn } from '../../utils/ui'; +import ProfileRegistry from '../../auth/profile-registry'; +import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy'; +import { + isSafeAccountInstancePath, + listAccountInstancePaths, + normalizeAccountInstanceName, +} from '../instance-directory'; +import { adoptDivergedFileContent } from './diverged-file-adopter'; +import { getLstatSync } from './fs-helpers'; +import { + claimCanonicalFirstCopy, + readRegularFileContent, + recoverInterruptedClaims, +} from './canonical-first-file-claims'; + +interface CanonicalFirstRoots { + claudeDir: string; + sharedDir: string; + instancesDir: string; +} + +function candidatePaths(roots: CanonicalFirstRoots, fileName: string): string[] { + const profiles = new ProfileRegistry().getAllProfilesMerged(); + const profilesByInstanceName = new Map>(); + for (const [profileName, profile] of Object.entries(profiles)) { + const instanceName = normalizeAccountInstanceName(profileName); + const matches = profilesByInstanceName.get(instanceName) ?? []; + matches.push(profile); + profilesByInstanceName.set(instanceName, matches); + } + + return [ + path.join(roots.sharedDir, fileName), + ...listAccountInstancePaths(roots.instancesDir) + .filter((instancePath) => { + const instanceName = normalizeAccountInstanceName(path.basename(instancePath)); + const matches = profilesByInstanceName.get(instanceName) ?? []; + if (matches.length > 1) return false; + return matches.length === 0 || !isProfileLocalSharedResourceMode(matches[0]); + }) + .map((instancePath) => path.join(instancePath, fileName)), + ]; +} + +function reconcileCandidatesAgainstCanonical( + canonicalPath: string, + candidates: readonly string[] +): void { + let canonicalContent: Buffer | null = null; + try { + canonicalContent = fs.readFileSync(canonicalPath); + } catch { + // A present but unreadable or dangling canonical path still owns precedence. + } + + for (const candidatePath of candidates) { + claimCanonicalFirstCopy( + candidatePath, + canonicalContent, + `Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${candidatePath}` + ); + } +} + +/** + * Reconcile a canonical-first shared file before any seed or symlink mutation. + * Existing canonical content always wins. With no canonical, one unique byte + * variant is adopted; conflicting variants are all quarantined for recovery. + */ +export function reconcileCanonicalFirstFile(roots: CanonicalFirstRoots, fileName: string): void { + const canonicalPath = path.join(roots.claudeDir, fileName); + const candidates = candidatePaths(roots, fileName); + for (const candidatePath of candidates) { + if (candidatePath !== path.join(roots.sharedDir, fileName)) { + const instancePath = path.dirname(candidatePath); + if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) continue; + } + recoverInterruptedClaims(candidatePath); + } + + if (getLstatSync(canonicalPath)) { + reconcileCandidatesAgainstCanonical(canonicalPath, candidates); + return; + } + + const variants: Array<{ path: string; content: Buffer }> = []; + for (const candidatePath of candidates) { + const content = readRegularFileContent(candidatePath); + if (content) variants.push({ path: candidatePath, content }); + } + if (variants.length === 0) return; + + const uniqueContents: Buffer[] = []; + for (const variant of variants) { + if (!uniqueContents.some((content) => content.equals(variant.content))) { + uniqueContents.push(variant.content); + } + } + + if (uniqueContents.length === 1) { + adoptDivergedFileContent(variants[0].path, canonicalPath); + reconcileCandidatesAgainstCanonical(canonicalPath, candidates); + console.log(info(`Adopted the only existing ${fileName} variant as canonical content`)); + return; + } + + for (const variant of variants) { + claimCanonicalFirstCopy( + variant.path, + null, + `Conflicting ${fileName} variants found while no canonical file exists` + ); + } + console.log( + warn( + `No ${fileName} variant was selected; recover one of the .ccs-shared-conflict files explicitly` + ) + ); +} + +/** Preserve a later divergent copy without allowing it to replace canonical content. */ +export function preserveCanonicalFirstDivergence( + divergedPath: string, + canonicalPath: string +): boolean { + let canonicalContent: Buffer | null = null; + try { + canonicalContent = fs.readFileSync(canonicalPath); + } catch { + // Preserve the divergence when canonical cannot be compared safely. + } + + return claimCanonicalFirstCopy( + divergedPath, + canonicalContent, + `Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${divergedPath}` + ); +} diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index 961dd5bd..94072f05 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -18,6 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../../utils/ui'; +import { isSafeAccountInstancePath } from '../instance-directory'; import { adoptDivergedFileContent, assertAdoptionPathAbsent, @@ -41,6 +42,10 @@ import { ensureSharedPluginLayoutDefaults, linkInstancePlugins, } from './plugin-layout-internals'; +import { + preserveCanonicalFirstDivergence, + reconcileCanonicalFirstFile, +} from './canonical-first-file-reconciler'; import { SHARED_ITEMS } from './types'; /** @@ -120,11 +125,15 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { recoverOrphanedCanonicalClaim(claudePath); } + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + reconcileCanonicalFirstFile(roots, item.name); + } + if (!getLstatSync(claudePath)) { if (item.type === 'directory') { fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); } else if (item.type === 'file') { - fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); + fs.writeFileSync(claudePath, item.seedContent, 'utf8'); } } @@ -149,7 +158,9 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { // Continue to recreate } - if (item.type === 'file') { + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath); + } else if (item.type === 'file') { sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath); removeExisting = sharedAdoption === 'not-claimed'; } @@ -196,6 +207,11 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { * Link shared directories into a specific instance path. */ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void { + if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) { + throw Object.assign(new TypeError(`Unsafe account instance path: ${instancePath}`), { + code: 'EINVAL', + }); + } ensureSharedDirectories(roots); const sharedDir = roots.sharedDir; @@ -210,7 +226,11 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): const targetPath = path.join(sharedDir, item.name); let adoption: ReturnType = 'not-claimed'; - if (item.type === 'file') { + if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { + if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) { + removeExistingPath(linkPath, item.type); + } + } else if (item.type === 'file') { adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name)); if (adoption === 'not-claimed') { removeExistingPath(linkPath, item.type); diff --git a/src/management/shared-manager/types.ts b/src/management/shared-manager/types.ts index 7555f1ed..6264b22c 100644 --- a/src/management/shared-manager/types.ts +++ b/src/management/shared-manager/types.ts @@ -16,6 +16,20 @@ export interface SharedItem { type: 'directory' | 'file'; } +export interface ManagedSharedDirectoryItem extends SharedItem { + type: 'directory'; +} + +export interface ManagedSharedFileItem extends SharedItem { + type: 'file'; + /** Exact bytes used when no canonical or adoptable source file exists. */ + seedContent: string; + /** Controls whether an instance may replace an existing canonical file. */ + divergencePolicy: 'newer-wins' | 'canonical-first'; +} + +export type ManagedSharedItem = ManagedSharedDirectoryItem | ManagedSharedFileItem; + /** * Default content for a freshly provisioned installed_plugins.json registry. * Version 2 schema with an empty plugins map. @@ -36,12 +50,23 @@ export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( * Order matters: consumers rely on a stable iteration order when reconciling * symlinks, and 'plugins' is special-cased by the linker. */ -export const SHARED_ITEMS: readonly SharedItem[] = [ +export const SHARED_ITEMS: readonly ManagedSharedItem[] = [ { name: 'commands', type: 'directory' }, { name: 'skills', type: 'directory' }, { name: 'agents', type: 'directory' }, { name: 'plugins', type: 'directory' }, - { name: 'settings.json', type: 'file' }, + { + name: 'settings.json', + type: 'file', + seedContent: JSON.stringify({}, null, 2), + divergencePolicy: 'newer-wins', + }, + { + name: 'CLAUDE.md', + type: 'file', + seedContent: '', + divergencePolicy: 'canonical-first', + }, ]; /** diff --git a/src/types/config.ts b/src/types/config.ts index 32c7f286..74a0ec7f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -121,9 +121,9 @@ 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 */ + /** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */ shared_resource_mode?: 'shared' | 'profile-local'; - /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ + /** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */ bare?: boolean; } diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index f7d1b626..dcece2a6 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -284,6 +284,168 @@ describe('InstanceManager MCP sync', () => { expect(manager.listInstances()).toEqual(['work']); }); + for (const testCase of [ + { + label: 'isolated bare', + policy: { mode: 'isolated' as const }, + options: { bare: true }, + }, + { + label: 'shared context', + policy: { mode: 'shared' as const, group: 'review-safety' }, + options: {}, + }, + ]) { + it(`rejects an existing symlink instance root before ${testCase.label} mutation`, async () => { + const externalInstance = path.join(tempRoot, `external-${testCase.label.replace(' ', '-')}`); + const instancesDir = path.join(tempRoot, '.ccs', 'instances'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8'); + fs.mkdirSync(instancesDir, { recursive: true }); + fs.symlinkSync(externalInstance, path.join(instancesDir, 'work'), 'dir'); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('work', testCase.policy, testCase.options) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']); + expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe( + 'unchanged' + ); + expect(fs.readdirSync(instancesDir)).toEqual(['work']); + }); + } + + it('rejects a symlinked instances parent before creating a new instance or lock', async () => { + const externalInstances = path.join(tempRoot, 'external-instances'); + const managedInstances = path.join(tempRoot, '.ccs', 'instances'); + fs.mkdirSync(externalInstances, { recursive: true }); + fs.mkdirSync(path.dirname(managedInstances), { recursive: true }); + fs.symlinkSync(externalInstances, managedInstances, 'dir'); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'shared', group: 'review-safety' }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstances)).toEqual([]); + }); + + it('detects parent replacement and leaves only an empty redirected artifact', async () => { + const managedCcsDir = path.join(tempRoot, '.ccs'); + const managedInstances = path.join(managedCcsDir, 'instances'); + const displacedCcsDir = path.join(tempRoot, 'displaced-ccs'); + const externalTarget = path.join(tempRoot, 'parent-race-target'); + fs.mkdirSync(managedCcsDir, { recursive: true }); + fs.mkdirSync(externalTarget, { recursive: true }); + fs.writeFileSync(path.join(externalTarget, 'sentinel.txt'), 'unchanged', 'utf8'); + + const originalMkdirSync = fs.mkdirSync; + let replacedParent = false; + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === managedInstances && !replacedParent) { + replacedParent = true; + fs.renameSync(managedCcsDir, displacedCcsDir); + fs.symlinkSync(externalTarget, managedCcsDir, 'dir'); + } + return originalMkdirSync(directoryPath, options); + }); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(replacedParent).toBe(true); + expect(fs.readdirSync(externalTarget).sort()).toEqual(['instances', 'sentinel.txt']); + expect(fs.readdirSync(path.join(externalTarget, 'instances'))).toEqual([]); + expect(fs.existsSync(path.join(externalTarget, 'instances', '.locks'))).toBe(false); + expect(fs.existsSync(path.join(externalTarget, 'instances', 'new-profile'))).toBe(false); + expect(fs.readdirSync(displacedCcsDir)).toEqual([]); + }); + + it('preserves created and unrelated directories when the redirected path is replaced', async () => { + const managedCcsDir = path.join(tempRoot, '.ccs'); + const managedInstances = path.join(managedCcsDir, 'instances'); + const displacedCcsDir = path.join(tempRoot, 'displaced-ccs-cleanup-race'); + const externalTarget = path.join(tempRoot, 'cleanup-race-target'); + const createdDirectory = path.join(externalTarget, 'created-instances'); + const redirectedInstances = path.join(externalTarget, 'instances'); + fs.mkdirSync(managedCcsDir, { recursive: true }); + fs.mkdirSync(externalTarget, { recursive: true }); + + const originalMkdirSync = fs.mkdirSync; + const originalLstatSync = fs.lstatSync; + let replacedParent = false; + let createdIdentityObserved = false; + let substitutedUnrelatedDirectory = false; + + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === managedInstances && !replacedParent) { + replacedParent = true; + fs.renameSync(managedCcsDir, displacedCcsDir); + fs.symlinkSync(externalTarget, managedCcsDir, 'dir'); + } + return originalMkdirSync(directoryPath, options); + }); + spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => { + if ( + targetPath === managedCcsDir && + createdIdentityObserved && + !substitutedUnrelatedDirectory + ) { + substitutedUnrelatedDirectory = true; + fs.renameSync(redirectedInstances, createdDirectory); + originalMkdirSync(redirectedInstances); + } + const stats = originalLstatSync(targetPath, options); + if (targetPath === managedInstances && replacedParent && stats.isDirectory()) { + createdIdentityObserved = true; + } + return stats; + }); + + const manager = new InstanceManager(); + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(substitutedUnrelatedDirectory).toBe(true); + expect(fs.existsSync(redirectedInstances)).toBe(true); + expect(fs.readdirSync(redirectedInstances)).toEqual([]); + expect(fs.existsSync(createdDirectory)).toBe(true); + expect(fs.readdirSync(createdDirectory)).toEqual([]); + expect(fs.existsSync(path.join(redirectedInstances, '.locks'))).toBe(false); + expect(fs.existsSync(path.join(redirectedInstances, 'new-profile'))).toBe(false); + expect(fs.existsSync(path.join(createdDirectory, '.locks'))).toBe(false); + expect(fs.existsSync(path.join(createdDirectory, 'new-profile'))).toBe(false); + }); + + it('revalidates a newly created instance root before initializing its contents', async () => { + const externalInstance = path.join(tempRoot, 'post-create-race-target'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8'); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('new-profile'); + const originalMkdirSync = fs.mkdirSync; + spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => { + if (directoryPath === instancePath) { + fs.symlinkSync(externalInstance, instancePath, 'dir'); + return undefined; + } + return originalMkdirSync(directoryPath, options); + }); + + await expect( + manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true }) + ).rejects.toThrow('Unsafe account instance path'); + + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']); + expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe('unchanged'); + }); + it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, @@ -491,7 +653,9 @@ describe('InstanceManager MCP sync', () => { metadata?: Record; } >; - const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + const personalRegistry = readJson( + path.join(personalPath, 'plugins', 'known_marketplaces.json') + ) as Record< string, { installLocation?: string; @@ -552,10 +716,7 @@ describe('InstanceManager MCP sync', () => { const legacyRegistry = readJson( path.join(legacyPath, 'plugins', 'known_marketplaces.json') - ) as Record< - string, - { installLocation?: string; label?: string; refreshToken?: string } - >; + ) as Record; expect(legacyRegistry['claude-code-plugins']).toMatchObject({ installLocation: marketplacePath(legacyPath), label: 'Legacy marketplace', diff --git a/tests/unit/management/instance-directory.test.ts b/tests/unit/management/instance-directory.test.ts index da64d7f1..f3456d2d 100644 --- a/tests/unit/management/instance-directory.test.ts +++ b/tests/unit/management/instance-directory.test.ts @@ -86,6 +86,26 @@ describe('account instance directory enumeration', () => { expect(listAccountInstanceNames(instancesDir())).toEqual(['work']); }); + it('rejects instance entries that symlink outside the managed root', () => { + const externalInstance = path.join(tempRoot, 'external-instance'); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.mkdirSync(instancesDir(), { recursive: true }); + fs.symlinkSync(externalInstance, path.join(instancesDir(), 'escaped'), 'dir'); + + expect(listAccountInstanceNames(instancesDir())).toEqual([]); + expect(listAccountInstancePaths(instancesDir())).toEqual([]); + }); + + it('rejects an instances root that is itself a symlink', () => { + const externalInstances = path.join(tempRoot, 'external-instances'); + fs.mkdirSync(path.join(externalInstances, 'work'), { recursive: true }); + fs.mkdirSync(path.dirname(instancesDir()), { recursive: true }); + fs.symlinkSync(externalInstances, instancesDir(), 'dir'); + + expect(listAccountInstanceNames(instancesDir())).toEqual([]); + expect(listAccountInstancePaths(instancesDir())).toEqual([]); + }); + it('keeps ccs doctor settings symlinks healthy when .locks exists', () => { createValidSettingsLayout(); diff --git a/tests/unit/shared-claude-memory.test.ts b/tests/unit/shared-claude-memory.test.ts new file mode 100644 index 00000000..4bfb4e09 --- /dev/null +++ b/tests/unit/shared-claude-memory.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import SharedManager from '../../src/management/shared-manager'; + +describe('shared CLAUDE.md memory', () => { + let tempHome = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + const claudeFile = () => path.join(tempHome, '.claude', 'CLAUDE.md'); + const ccsDir = () => path.join(tempHome, '.ccs'); + const sharedFile = () => path.join(ccsDir(), 'shared', 'CLAUDE.md'); + const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name); + const instanceFile = (name: string) => path.join(instanceDir(name), 'CLAUDE.md'); + + function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + } + + function conflictFiles(filePath: string): string[] { + const prefix = `${path.basename(filePath)}.ccs-shared-conflict`; + return fs + .readdirSync(path.dirname(filePath)) + .filter((entry) => entry === prefix || entry.startsWith(`${prefix}-`)) + .map((entry) => path.join(path.dirname(filePath), entry)); + } + + function writeLegacyProfiles( + profiles: Record + ): void { + writeFile( + path.join(ccsDir(), 'profiles.json'), + JSON.stringify( + { + version: '2.0.0', + default: null, + profiles: Object.fromEntries( + Object.entries(profiles).map(([name, metadata]) => [ + name, + { + type: 'account', + created: '2026-08-08T00:00:00.000Z', + last_used: null, + ...metadata, + }, + ]) + ), + }, + null, + 2 + ) + ); + } + + function expectLinkTo(filePath: string, targetPath: string): void { + expect(fs.lstatSync(filePath).isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath); + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + spyOn(os, 'homedir').mockReturnValue(tempHome); + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + mock.restore(); + + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + if (originalCcsDir === undefined) delete process.env.CCS_DIR; + else process.env.CCS_DIR = originalCcsDir; + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('seeds missing Markdown as empty text and links it through the shared root', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + fs.mkdirSync(workInstance, { recursive: true }); + + manager.linkSharedDirectories(workInstance); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe(''); + expectLinkTo(sharedFile(), claudeFile()); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('preserves canonical instructions and quarantines an older instance variant', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Stale local copy\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + const recoveries = conflictFiles(instanceFile('work')); + expect(recoveries).toHaveLength(1); + expect(fs.readFileSync(recoveries[0], 'utf8')).toBe('# Stale local copy\n'); + }); + + it('adopts the only instance variant when the canonical file is absent', () => { + const manager = new SharedManager(); + writeFile(instanceFile('work'), '# Adopt me\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Adopt me\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('never inventories or mutates profile-local CLAUDE.md content', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + sandbox: { shared_resource_mode: 'profile-local', bare: true }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Isolated account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Isolated account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('matches uppercase profile metadata to its normalized instance name', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + Sandbox: { shared_resource_mode: 'profile-local', bare: true }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Uppercase isolated account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Uppercase isolated account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + }); + + it('excludes legacy profile-name normalization collisions from inventory', () => { + const manager = new SharedManager(); + writeLegacyProfiles({ + work: { shared_resource_mode: 'shared' }, + Sandbox: { shared_resource_mode: 'profile-local', bare: true }, + sandbox: { shared_resource_mode: 'shared' }, + }); + writeFile(instanceFile('work'), '# Shared account\n'); + writeFile(instanceFile('sandbox'), '# Ambiguous legacy account\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n'); + expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Ambiguous legacy account\n'); + expect(conflictFiles(instanceFile('sandbox'))).toEqual([]); + }); + + it('quarantines every distinct variant instead of selecting by mtime', () => { + const manager = new SharedManager(); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + writeFile(instanceFile('work'), '# Work\n'); + writeFile(instanceFile('personal'), '# Personal\n'); + const now = Date.now(); + fs.utimesSync(instanceFile('work'), new Date(now - 60_000), new Date(now - 60_000)); + fs.utimesSync(instanceFile('personal'), new Date(now), new Date(now)); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe(''); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Work\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('personal'))[0], 'utf8')).toBe( + '# Personal\n' + ); + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes('No CLAUDE.md variant was selected') + ) + ).toBe(true); + }); + + it('is idempotent after canonical-first reconciliation', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + + manager.linkSharedDirectories(instanceDir('work')); + const firstRecoveries = conflictFiles(instanceFile('work')); + manager.linkSharedDirectories(instanceDir('work')); + + expect(conflictFiles(instanceFile('work'))).toEqual(firstRecoveries); + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('never lets a later diverged instance overwrite canonical instructions', () => { + const manager = new SharedManager(); + writeFile(claudeFile(), '# Canonical\n'); + fs.mkdirSync(instanceDir('work'), { recursive: true }); + manager.linkSharedDirectories(instanceDir('work')); + + fs.unlinkSync(instanceFile('work')); + writeFile(instanceFile('work'), '# Newer but local\n'); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(instanceFile('work'), future, future); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe( + '# Newer but local\n' + ); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + for (const linkErrorCode of ['EPERM', 'EACCES', 'ENOTSUP']) { + it(`copies conflict recovery when hard links fail with ${linkErrorCode}`, () => { + const manager = new SharedManager(); + const originalLinkSync = fs.linkSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => { + if (String(newPath).includes('.ccs-shared-conflict')) { + throw Object.assign(new Error('simulated hard-link failure'), { code: linkErrorCode }); + } + return originalLinkSync(existingPath, newPath); + }); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Diverged\n'); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + } + + it('restores the original divergence when hard-link and copy recovery both fail', () => { + const manager = new SharedManager(); + const originalCopyFileSync = fs.copyFileSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Diverged\n'); + spyOn(fs, 'linkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated hard-link failure'), { code: 'EPERM' }); + }); + spyOn(fs, 'copyFileSync').mockImplementation((source, destination, mode) => { + if (String(destination).includes('.ccs-shared-conflict')) { + throw Object.assign(new Error('simulated copy failure'), { code: 'EACCES' }); + } + return originalCopyFileSync(source, destination, mode); + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'simulated hard-link failure' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Diverged\n'); + expect( + fs.readdirSync(instanceDir('work')).some((entry) => entry.includes('.ccs-shared-claim-')) + ).toBe(false); + }); + + it('recovers an interrupted claim before inventory and adoption', () => { + const manager = new SharedManager(); + const claimPath = `${instanceFile('work')}.ccs-shared-claim-interrupted`; + writeFile(claimPath, '# Interrupted\n'); + + manager.linkSharedDirectories(instanceDir('work')); + + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Interrupted\n'); + expect(fs.existsSync(claimPath)).toBe(false); + expectLinkTo(instanceFile('work'), sharedFile()); + }); + + it('preserves both copies and aborts when a concurrent writer replaces the claimed path', () => { + const manager = new SharedManager(); + const originalRenameSync = fs.renameSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Claimed\n'); + spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { + originalRenameSync(oldPath, newPath); + if (oldPath === instanceFile('work') && String(newPath).includes('.ccs-shared-claim-')) { + writeFile(instanceFile('work'), '# Concurrent writer\n'); + } + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'Concurrent replacement detected' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Concurrent writer\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n'); + expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n'); + }); + + it('aborts when a late writer recreates the path during recovery publication', () => { + const manager = new SharedManager(); + const originalLinkSync = fs.linkSync; + writeFile(claudeFile(), '# Canonical\n'); + writeFile(instanceFile('work'), '# Claimed\n'); + spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => { + const result = originalLinkSync(existingPath, newPath); + if (String(newPath).includes('.ccs-shared-conflict')) { + writeFile(instanceFile('work'), '# Late writer\n'); + } + return result; + }); + + expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow( + 'Path reappeared during reconciliation' + ); + expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Late writer\n'); + expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n'); + }); + + it('rejects linking through a symlinked instances root without touching its target', () => { + const manager = new SharedManager(); + const managedInstances = path.join(ccsDir(), 'instances'); + const externalInstances = path.join(tempHome, 'external-instances'); + const externalWork = path.join(externalInstances, 'work'); + writeFile(path.join(externalWork, 'CLAUDE.md'), '# External\n'); + fs.mkdirSync(path.dirname(managedInstances), { recursive: true }); + fs.symlinkSync(externalInstances, managedInstances, 'dir'); + + expect(() => manager.linkSharedDirectories(path.join(managedInstances, 'work'))).toThrow( + 'Unsafe account instance path' + ); + expect(fs.readFileSync(path.join(externalWork, 'CLAUDE.md'), 'utf8')).toBe('# External\n'); + }); +});