diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index ccbd41b6..976dbcc2 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,11 +1,30 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2388, - "filesAffected": 257, - "hotpathOccurrences": 1103, - "hotpathFilesAffected": 151, + "totalOccurrences": 2427, + "filesAffected": 258, + "hotpathOccurrences": 1142, + "hotpathFilesAffected": 152, "topHotpathFiles": [ + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 39, + "calls": [ + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/utils/browser/mcp-installer.ts", "count": 32, @@ -139,24 +158,6 @@ "statSync" ], "markers": [] - }, - { - "file": "src/utils/claude-dir-installer.ts", - "count": 21, - "calls": [ - "copyFileSync", - "cpSync", - "existsSync", - "lstatSync", - "mkdirSync", - "readdirSync", - "renameSync", - "rmSync", - "statSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] } ], "topFilesOverall": [ @@ -227,6 +228,25 @@ ], "markers": [] }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 39, + "calls": [ + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", "count": 36, @@ -282,20 +302,6 @@ "writeFileSync" ], "markers": [] - }, - { - "file": "src/cliproxy/__tests__/session-tracker-port.test.js", - "count": 31, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "readFileSync", - "rmSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] } ] }, @@ -573,8 +579,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 758, - "coverageRatio": 0.0858, + "totalSourceFiles": 759, + "coverageRatio": 0.0856, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -609,12 +615,12 @@ "withLogger": 1 }, { - "subdomain": "cliproxy/auth", - "count": 32, + "subdomain": "management", + "count": 33, "withLogger": 1 }, { - "subdomain": "management", + "subdomain": "cliproxy/auth", "count": 32, "withLogger": 1 }, diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 79b13f92..0b8a3082 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) | 2388 | -| Sync fs files affected (all) | 257 | -| Sync fs occurrences (runtime hotpaths) | 1103 | -| Sync fs files affected (runtime hotpaths) | 151 | +| Sync fs occurrences (all) | 2427 | +| Sync fs files affected (all) | 258 | +| Sync fs occurrences (runtime hotpaths) | 1142 | +| Sync fs files affected (runtime hotpaths) | 152 | | Legacy shim markers | 458 | | Legacy shim files affected | 173 | @@ -17,6 +17,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Sync Calls | API Names | |---|---:|---| +| `src/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | @@ -26,7 +27,6 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/services/variant-settings.ts` | 23 | existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync | | `src/management/recovery-manager.ts` | 23 | copyFileSync, existsSync, lstatSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync | -| `src/utils/claude-dir-installer.ts` | 21 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync | ## Top Legacy Shim Marker Files @@ -60,7 +60,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | 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/758 | +| files with createLogger | 65/759 | | 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/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts new file mode 100644 index 00000000..43829a8f --- /dev/null +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -0,0 +1,367 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, warn } from '../../utils/ui'; +import { getLstatSync } from './fs-helpers'; + +export type DivergedFileAdoption = 'not-claimed' | 'claimed'; + +let adoptionClaimSequence = 0; + +function resolveLexicalSymlinkChain(targetPath: string): string { + let currentPath = path.resolve(targetPath); + const visited = new Set(); + while (true) { + if (visited.has(currentPath)) { + throw Object.assign(new TypeError(`Symlink loop while resolving ${targetPath}`), { + code: 'ELOOP', + }); + } + visited.add(currentPath); + + const stats = getLstatSync(currentPath); + if (!stats?.isSymbolicLink()) return currentPath; + currentPath = path.resolve(path.dirname(currentPath), fs.readlinkSync(currentPath)); + } +} + +export function assertAdoptionPathAbsent( + managedPath: string, + adoption: DivergedFileAdoption +): void { + if (adoption === 'claimed' && getLstatSync(managedPath)) { + throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${managedPath}`), { + code: 'EEXIST', + }); + } +} + +export function recoverOrphanedCanonicalClaim(canonicalPath: string): boolean { + const writePath = resolveLexicalSymlinkChain(canonicalPath); + const directory = path.dirname(writePath); + const prefix = `${path.basename(writePath)}.ccs-canonical-claim-`; + let candidates: string[]; + try { + candidates = fs + .readdirSync(directory) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => path.join(directory, entry)) + .filter((entryPath) => fs.lstatSync(entryPath).isFile()) + .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw err; + } + const claimPath = candidates[0]; + if (!claimPath) return false; + + let recovered = false; + if (!getLstatSync(writePath)) { + fs.linkSync(claimPath, writePath); + fs.unlinkSync(claimPath); + candidates.shift(); + recovered = true; + console.log(warn(`Recovered interrupted canonical adoption at ${canonicalPath}`)); + } + + for (const leftoverClaim of candidates) { + const recoveryBase = `${writePath}.ccs-canonical-recovery`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`; + try { + fs.linkSync(leftoverClaim, recoveryPath); + fs.unlinkSync(leftoverClaim); + console.log(warn(`Quarantined interrupted canonical claim at ${recoveryPath}`)); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } + } + return recovered; +} + +function preserveClaim(claimPath: string, divergedPath: string, reason: string): string { + const recoveryBase = `${divergedPath}.ccs-adopt-recovery`; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`; + try { + // link() is an atomic no-replace operation, so concurrent CCS + // processes cannot overwrite each other's recovery artifacts. + fs.linkSync(claimPath, recoveryPath); + try { + fs.unlinkSync(claimPath); + } catch { + // Both names preserve the same bytes; 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; + } + console.log(warn(`${reason}; content remains at ${claimPath}${code ? ` (${code})` : ''}`)); + return claimPath; + } + } +} + +function validateManagedJson(filePath: string, content: Buffer): boolean { + if (path.extname(filePath).toLowerCase() !== '.json') { + return true; + } + + try { + const parsed = JSON.parse(content.toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false; + } + + if (path.basename(filePath) === 'installed_plugins.json') { + const registry = parsed as Record; + return ( + typeof registry.plugins === 'object' && + registry.plugins !== null && + !Array.isArray(registry.plugins) + ); + } + + return true; + } catch { + return false; + } +} + +function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void { + const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + let descriptor: number | null = null; + try { + descriptor = fs.openSync(tempPath, 'wx', mode); + fs.fchmodSync(descriptor, mode); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + fs.linkSync(tempPath, targetPath); + fs.unlinkSync(tempPath); + } catch (err) { + if (descriptor !== null) { + fs.closeSync(descriptor); + } + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } + throw err; + } +} + +function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string { + const basePath = `${canonicalPath}.bak-ccs-adopt`; + const content = fs.readFileSync(sourcePath); + const mode = fs.statSync(sourcePath).mode & 0o777; + let sequence = 0; + while (true) { + const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + atomicWriteFile(backupPath, content, mode); + return backupPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } +} + +function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string { + const basePath = `${divergedPath}.ccs-adopted-recovery`; + const content = fs.readFileSync(sourcePath); + const mode = fs.statSync(sourcePath).mode & 0o777; + let sequence = 0; + while (true) { + const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + try { + atomicWriteFile(recoveryPath, content, mode); + return recoveryPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + sequence++; + } + } +} + +function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void { + try { + fs.linkSync(claimPath, writePath); + fs.unlinkSync(claimPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + publishBackupNoReplace(claimPath, canonicalPath); + fs.unlinkSync(claimPath); + } +} + +function getCanonicalFile(canonicalPath: string): { + content: Buffer | null; + mode: number; + mtimeMs: number | null; + writePath: string; +} { + const canonicalLstat = getLstatSync(canonicalPath); + if (!canonicalLstat) { + return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; + } + + let writePath = canonicalPath; + if (canonicalLstat.isSymbolicLink()) { + writePath = fs.realpathSync.native(canonicalPath); + } + + const canonicalStats = fs.statSync(canonicalPath); + if (!canonicalStats.isFile()) { + throw Object.assign(new TypeError(`Canonical path is not a regular file: ${canonicalPath}`), { + code: 'EINVAL', + }); + } + + return { + content: fs.readFileSync(canonicalPath), + mode: canonicalStats.mode & 0o777, + mtimeMs: canonicalStats.mtimeMs, + writePath, + }; +} + +export function adoptDivergedFileContent( + divergedPath: string, + canonicalPath: string +): DivergedFileAdoption { + const initialStats = getLstatSync(divergedPath); + if (!initialStats?.isFile()) { + return 'not-claimed'; + } + + const claimPath = `${divergedPath}.ccs-adopt-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + try { + fs.renameSync(divergedPath, claimPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + console.log( + warn( + `Unable to claim diverged ${divergedPath}; preserving original${code ? ` (${code})` : ''}` + ) + ); + throw err; + } + + let canonicalClaimPath: string | null = null; + let canonicalWritePath: string | null = null; + let divergencePreserved = false; + try { + const claimedStats = fs.lstatSync(claimPath); + if (!claimedStats.isFile()) { + preserveClaim(claimPath, divergedPath, `Refusing non-regular divergence at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Refusing non-regular divergence at ${divergedPath}`), { + code: 'EINVAL', + }); + } + + if (getLstatSync(divergedPath)) { + preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), { + code: 'EEXIST', + }); + } + + const diverged = fs.readFileSync(claimPath); + if (!validateManagedJson(divergedPath, diverged)) { + preserveClaim(claimPath, divergedPath, `Refusing malformed managed JSON at ${divergedPath}`); + return 'claimed'; + } + + const canonical = getCanonicalFile(canonicalPath); + let current = canonical.content; + let publishMode = canonical.mode; + if (current) { + canonicalWritePath = canonical.writePath; + canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + fs.renameSync(canonical.writePath, canonicalClaimPath); + const currentStats = fs.statSync(canonicalClaimPath); + publishMode = currentStats.mode & 0o777; + current = fs.readFileSync(canonicalClaimPath); + if (diverged.equals(current)) { + restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); + canonicalClaimPath = null; + fs.unlinkSync(claimPath); + return 'claimed'; + } + if (claimedStats.mtimeMs <= currentStats.mtimeMs) { + restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); + canonicalClaimPath = null; + preserveClaim( + claimPath, + divergedPath, + `Refusing stale or ambiguously-timed divergence at ${divergedPath}` + ); + return 'claimed'; + } + } + + if (canonicalClaimPath) { + publishBackupNoReplace(canonicalClaimPath, canonicalPath); + } + publishAdoptedRecoveryNoReplace(claimPath, divergedPath); + + atomicWriteFile(canonical.writePath, diverged, publishMode); + if (!fs.readFileSync(canonicalPath).equals(diverged)) { + throw Object.assign( + new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`), + { + code: 'EAGAIN', + } + ); + } + if (canonicalClaimPath) { + fs.unlinkSync(canonicalClaimPath); + canonicalClaimPath = null; + } + if (getLstatSync(divergedPath)) { + preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); + divergencePreserved = true; + throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), { + code: 'EEXIST', + }); + } + fs.unlinkSync(claimPath); + console.log( + info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`) + ); + return 'claimed'; + } catch (err) { + if (canonicalClaimPath && canonicalWritePath) { + restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath); + canonicalClaimPath = null; + } + if (divergencePreserved) { + throw err; + } + if (!getLstatSync(divergedPath)) { + try { + fs.renameSync(claimPath, divergedPath); + } catch { + preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`); + } + } else { + preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`); + } + throw err; + } +} diff --git a/src/management/shared-manager/fs-helpers.ts b/src/management/shared-manager/fs-helpers.ts index ab8d82bb..0a40f5e9 100644 --- a/src/management/shared-manager/fs-helpers.ts +++ b/src/management/shared-manager/fs-helpers.ts @@ -11,8 +11,18 @@ import * as fs from 'fs'; import * as path from 'path'; + import type { SharedItem } from './types'; +/** + * Claude Code saves managed files (settings.json, plugins/installed_plugins.json) + * with an atomic write (temp file + rename), which replaces a managed symlink + * with a regular file holding the user's latest changes — see #57 and #1681. + * When reconciliation finds such a diverged regular file, adopt its content + * into the canonical ~/.claude file before re-creating the symlink, instead + * of discarding the user's changes. The previous canonical content is kept + * in a `.bak-ccs-adopt` backup alongside it. + */ /** * Return canonical realpath for a path. Falls back to the lexical resolve * when realpath fails (e.g. path does not exist). diff --git a/src/management/shared-manager/plugin-layout-internals.ts b/src/management/shared-manager/plugin-layout-internals.ts index 5e4566d8..0c9c2cf0 100644 --- a/src/management/shared-manager/plugin-layout-internals.ts +++ b/src/management/shared-manager/plugin-layout-internals.ts @@ -14,7 +14,17 @@ import * as fs from 'fs'; import * as path from 'path'; import { warn } from '../../utils/ui'; -import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers'; +import { + adoptDivergedFileContent, + assertAdoptionPathAbsent, + recoverOrphanedCanonicalClaim, +} from './diverged-file-adopter'; +import { + copyDirectoryFallback, + getLstatSync, + removeExistingPath, + symlinkPointsTo, +} from './fs-helpers'; import type { PluginMetadataRoots } from './plugin-metadata-normalizer'; import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer'; import { @@ -40,6 +50,9 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void { for (const entry of SHARED_PLUGIN_ENTRIES) { const entryPath = path.join(pluginsDir, entry.name); + if (entry.type === 'file') { + recoverOrphanedCanonicalClaim(entryPath); + } if (fs.existsSync(entryPath)) { continue; } @@ -86,18 +99,40 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri for (const item of getSharedPluginLinkItems(roots.sharedDir)) { const targetEntryPath = path.join(targetPath, item.name); const linkEntryPath = path.join(linkPath, item.name); + let adoption: ReturnType = 'not-claimed'; - removeExistingPath(linkEntryPath, item.type); + if (item.type === 'file') { + adoption = adoptDivergedFileContent( + linkEntryPath, + path.join(roots.claudeDir, 'plugins', item.name) + ); + if (adoption === 'not-claimed') { + removeExistingPath(linkEntryPath, item.type); + } + } else { + removeExistingPath(linkEntryPath, item.type); + } + assertAdoptionPathAbsent(linkEntryPath, adoption); + + if (getLstatSync(linkEntryPath)) { + console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`)); + continue; + } try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); } catch (_err) { + assertAdoptionPathAbsent(linkEntryPath, adoption); + if (getLstatSync(linkEntryPath)) { + console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`)); + continue; + } if (process.platform === 'win32') { if (item.type === 'directory') { copyDirectoryFallback(targetEntryPath, linkEntryPath); } else { - fs.copyFileSync(targetEntryPath, linkEntryPath); + fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL); } console.log( warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) @@ -121,7 +156,16 @@ export function getSharedPluginLinkItems(sharedDir: string): SharedItem[] { ); for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { - if (items.has(entry.name) || INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name)) { + if ( + items.has(entry.name) || + INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name) || + entry.name.includes('.bak-ccs-adopt') || + entry.name.includes('.ccs-adopt-') || + entry.name.includes('.ccs-adopted-recovery') || + entry.name.includes('.ccs-canonical-claim-') || + entry.name.includes('.ccs-canonical-recovery') || + entry.name.includes('.ccs-write-') + ) { continue; } diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index 00acef38..961dd5bd 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -18,6 +18,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../../utils/ui'; +import { + adoptDivergedFileContent, + assertAdoptionPathAbsent, + recoverOrphanedCanonicalClaim, +} from './diverged-file-adopter'; import { copyDirectoryFallback, getLstatSync, @@ -111,6 +116,10 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { const claudePath = path.join(claudeDir, item.name); const sharedPath = path.join(sharedDir, item.name); + if (item.type === 'file') { + recoverOrphanedCanonicalClaim(claudePath); + } + if (!getLstatSync(claudePath)) { if (item.type === 'directory') { fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); @@ -124,7 +133,9 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { continue; } + let sharedAdoption: ReturnType = 'not-claimed'; if (getLstatSync(sharedPath)) { + let removeExisting = true; try { const stats = fs.lstatSync(sharedPath); if (stats.isSymbolicLink()) { @@ -138,22 +149,38 @@ export function ensureSharedDirectories(roots: LinkerRoots): void { // Continue to recreate } - if (item.type === 'directory') { + if (item.type === 'file') { + sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath); + removeExisting = sharedAdoption === 'not-claimed'; + } + + if (item.type === 'directory' && removeExisting) { fs.rmSync(sharedPath, { recursive: true, force: true }); - } else { + } else if (removeExisting) { fs.unlinkSync(sharedPath); } + assertAdoptionPathAbsent(sharedPath, sharedAdoption); + } + + if (getLstatSync(sharedPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + continue; } try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(claudePath, sharedPath, symlinkType); } catch (_err) { + assertAdoptionPathAbsent(sharedPath, sharedAdoption); + if (getLstatSync(sharedPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + continue; + } if (process.platform === 'win32') { if (item.type === 'directory') { copyDirectoryFallback(claudePath, sharedPath); } else if (item.type === 'file') { - fs.copyFileSync(claudePath, sharedPath); + fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL); } console.log( warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) @@ -181,18 +208,37 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): const linkPath = path.join(instancePath, item.name); const targetPath = path.join(sharedDir, item.name); + let adoption: ReturnType = 'not-claimed'; - removeExistingPath(linkPath, item.type); + if (item.type === 'file') { + adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name)); + if (adoption === 'not-claimed') { + removeExistingPath(linkPath, item.type); + } + } else { + removeExistingPath(linkPath, item.type); + } + assertAdoptionPathAbsent(linkPath, adoption); + + if (getLstatSync(linkPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + continue; + } try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(targetPath, linkPath, symlinkType); } catch (_err) { + assertAdoptionPathAbsent(linkPath, adoption); + if (getLstatSync(linkPath)) { + console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); + continue; + } if (process.platform === 'win32') { if (item.type === 'directory') { copyDirectoryFallback(targetPath, linkPath); } else if (item.type === 'file') { - fs.copyFileSync(targetPath, linkPath); + fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL); } console.log( warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 555953eb..0053a7da 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -35,6 +35,18 @@ describe('SharedManager', () => { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } + function setMtime(filePath: string, mtimeMs: number): void { + const timestamp = new Date(mtimeMs); + fs.utimesSync(filePath, timestamp, timestamp); + } + + function findRecovery(filePath: string): string | undefined { + const prefix = `${path.basename(filePath)}.ccs-adopt-recovery`; + return fs + .readdirSync(path.dirname(filePath)) + .find((entry) => entry === prefix || entry.startsWith(`${prefix}-`)); + } + function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string { const parsed = readJson(filePath) as Record; return parsed[name]?.installLocation ?? ''; @@ -306,6 +318,473 @@ describe('SharedManager', () => { }); }); + describe('diverged settings adoption', () => { + it('adopts a diverged shared settings.json into ~/.claude before re-linking', () => { + const manager = new SharedManager(); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(claudeSettingsPath, { enabledPlugins: { 'demo@market': false } }); + // Simulate Claude Code's atomic save (temp file + rename) replacing the + // managed symlink with a regular file carrying the user's latest change. + writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } }); + setMtime(sharedSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ + enabledPlugins: { 'demo@market': true }, + }); + expect(readJson(`${claudeSettingsPath}.bak-ccs-adopt`)).toEqual({ + enabledPlugins: { 'demo@market': false }, + }); + }); + + it('adopts a diverged instance settings.json during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'light' }); + writeJson(instanceSettingsPath, { theme: 'dark' }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' }); + }); + + it('adopts a diverged instance plugin registry during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeRegistryPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'installed_plugins.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(instancePath, 'plugins'), { recursive: true }); + writeJson(claudeRegistryPath, { version: 2, plugins: {} }); + // Simulate Claude Code's atomic save replacing the managed symlink with + // a regular file that records a plugin installed inside a session. + writeJson(instanceRegistryPath, { + version: 2, + plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] }, + }); + setMtime(instanceRegistryPath, fs.statSync(claudeRegistryPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(true); + const adopted = readJson(claudeRegistryPath) as { plugins: Record }; + expect(Object.keys(adopted.plugins)).toContain('demo@demo-market'); + }); + + it('does not create a backup when the diverged copy matches the canonical file', () => { + const manager = new SharedManager(); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'dark' }); + fs.copyFileSync(claudeSettingsPath, sharedSettingsPath); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true); + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' }); + expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false); + }); + + it('preserves diverged bytes when canonical backup creation fails', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { theme: 'light' }); + writeJson(instanceSettingsPath, { theme: 'dark' }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + const originalLinkSync = fs.linkSync; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (String(newPath).includes('.bak-ccs-adopt')) { + throw Object.assign(new Error('simulated backup failure'), { code: 'EIO' }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow('simulated backup failure'); + linkSpy.mockRestore(); + + expect(readJson(claudeSettingsPath)).toEqual({ theme: 'light' }); + expect(readJson(instanceSettingsPath)).toEqual({ theme: 'dark' }); + }); + + it('does not let a stale Windows fallback roll back newer canonical settings', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('windows-copy'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + writeJson(claudeSettingsPath, { generation: 2 }); + manager.ensureSharedDirectories(); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(instanceSettingsPath, { generation: 1 }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs - 2_000); + + Object.defineProperty(process, 'platform', { value: 'win32' }); + spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); + }); + + manager.linkSharedDirectories(instancePath); + + expect(readJson(claudeSettingsPath)).toEqual({ generation: 2 }); + expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 }); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({ + generation: 1, + }); + }); + + it('preserves divergence when the canonical settings symlink is dangling', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('dangling'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const missingTarget = path.join(tempRoot, 'missing', 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + fs.symlinkSync(missingTarget, claudeSettingsPath, 'file'); + writeJson(instanceSettingsPath, { preserved: true }); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); + + expect(fs.lstatSync(claudeSettingsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(missingTarget)).toBe(false); + expect(readJson(instanceSettingsPath)).toEqual({ preserved: true }); + }); + + it('preserves malformed managed JSON instead of poisoning canonical settings', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('malformed'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { valid: true }); + fs.writeFileSync(instanceSettingsPath, '{broken-json', 'utf8'); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + manager.linkSharedDirectories(instancePath); + + expect(readJson(claudeSettingsPath)).toEqual({ valid: true }); + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect( + fs.readFileSync(path.join(path.dirname(instanceSettingsPath), recoveryName!), 'utf8') + ).toBe('{broken-json'); + }); + + it('aborts relinking when a writer replaces the path after it is claimed', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('concurrent'); + const claudeSettingsPath = path.join(claudeDir(), 'settings.json'); + const instanceSettingsPath = path.join(instancePath, 'settings.json'); + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(claudeSettingsPath, { generation: 0 }); + writeJson(instanceSettingsPath, { generation: 1 }); + setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000); + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + if ( + String(oldPath) === instanceSettingsPath && + String(newPath).includes('.ccs-adopt-claim-') + ) { + writeJson(instanceSettingsPath, { generation: 2 }); + } + }) as typeof fs.renameSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Concurrent replacement detected' + ); + renameSpy.mockRestore(); + + expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 }); + expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(false); + expect(readJson(claudeSettingsPath)).toEqual({ generation: 0 }); + const recoveryName = findRecovery(instanceSettingsPath); + expect(recoveryName).toBeDefined(); + expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({ + generation: 1, + }); + }); + + it('preserves a canonical write that lands during no-replace publication', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('canonical-race'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalLinkSync = fs.linkSync; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) { + writeJson(canonicalPath, { generation: 99 }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); + linkSpy.mockRestore(); + expect(readJson(canonicalPath)).toEqual({ generation: 99 }); + expect(readJson(divergedPath)).toEqual({ generation: 1 }); + expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 }); + }); + + it('keeps adopted bytes recoverable when canonical changes after verification', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('late-canonical-writer'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalUnlinkSync = fs.unlinkSync; + let injected = false; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + injected = true; + writeJson(canonicalPath, { generation: 99 }); + } + return originalUnlinkSync(targetPath); + }) as typeof fs.unlinkSync); + + manager.linkSharedDirectories(instancePath); + unlinkSpy.mockRestore(); + expect(readJson(canonicalPath)).toEqual({ generation: 99 }); + expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); + }); + + it('fails reconciliation when the managed source reappears during late cleanup', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('late-source-writer'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalUnlinkSync = fs.unlinkSync; + let injected = false; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + injected = true; + writeJson(divergedPath, { generation: 2 }); + } + return originalUnlinkSync(targetPath); + }) as typeof fs.unlinkSync); + + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Concurrent replacement detected' + ); + unlinkSpy.mockRestore(); + expect(readJson(divergedPath)).toEqual({ generation: 2 }); + }); + + it('retries backup publication without replacing a concurrent backup', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('backup-race'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const backupPath = `${canonicalPath}.bak-ccs-adopt`; + const originalLinkSync = fs.linkSync; + let injected = false; + const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( + existingPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if (!injected && String(newPath) === backupPath) { + injected = true; + writeJson(backupPath, { competing: true }); + } + return originalLinkSync(existingPath, newPath); + }) as typeof fs.linkSync); + + manager.linkSharedDirectories(instancePath); + linkSpy.mockRestore(); + expect(readJson(backupPath)).toEqual({ competing: true }); + expect(readJson(`${backupPath}-1`)).toEqual({ generation: 0 }); + expect(readJson(canonicalPath)).toEqual({ generation: 1 }); + }); + + it('preserves regular and symlink-target modes under a restrictive umask', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const regularInstance = instanceDir('regular-mode'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(regularInstance, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + fs.chmodSync(canonicalPath, 0o640); + const regularDiverged = path.join(regularInstance, 'settings.json'); + writeJson(regularDiverged, { generation: 1 }); + setMtime(regularDiverged, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const previousUmask = process.umask(0o077); + try { + manager.linkSharedDirectories(regularInstance); + expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o640); + + const targetPath = path.join(tempRoot, 'external-settings.json'); + fs.renameSync(canonicalPath, targetPath); + fs.symlinkSync(targetPath, canonicalPath, 'file'); + fs.chmodSync(targetPath, 0o664); + const symlinkInstance = instanceDir('symlink-mode'); + fs.mkdirSync(symlinkInstance, { recursive: true }); + const symlinkDiverged = path.join(symlinkInstance, 'settings.json'); + writeJson(symlinkDiverged, { generation: 2 }); + setMtime(symlinkDiverged, fs.statSync(targetPath).mtimeMs + 2_000); + manager.linkSharedDirectories(symlinkInstance); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o664); + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + } finally { + process.umask(previousUmask); + } + }); + + it('uses the mode of the canonical inode actually claimed for publication', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('concurrent-mode'); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const divergedPath = path.join(instancePath, 'settings.json'); + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(instancePath, { recursive: true }); + writeJson(canonicalPath, { generation: 0 }); + fs.chmodSync(canonicalPath, 0o640); + writeJson(divergedPath, { generation: 1 }); + setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + if ( + String(oldPath) === canonicalPath && + String(newPath).includes('.ccs-canonical-claim-') + ) { + fs.chmodSync(canonicalPath, 0o664); + } + return originalRenameSync(oldPath, newPath); + }) as typeof fs.renameSync); + + manager.linkSharedDirectories(instancePath); + renameSpy.mockRestore(); + expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664); + }); + + it('recovers an interrupted canonical claim before provisioning defaults', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const claimPath = `${canonicalPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(claudeDir(), { recursive: true }); + writeJson(canonicalPath, { preserved: true }); + fs.renameSync(canonicalPath, claimPath); + + manager.ensureSharedDirectories(); + + expect(readJson(canonicalPath)).toEqual({ preserved: true }); + expect(fs.existsSync(claimPath)).toBe(false); + }); + + it('quarantines a leftover plugin canonical claim without replacing a live symlink target', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json'); + const targetPath = path.join(tempRoot, 'external', 'installed_plugins.json'); + const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(path.dirname(canonicalPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeJson(targetPath, { version: 2, plugins: { live: [] } }); + writeJson(claimPath, { version: 2, plugins: { preserved: [] } }); + fs.symlinkSync(targetPath, intermediatePath, 'file'); + fs.symlinkSync(intermediatePath, canonicalPath, 'file'); + + manager.ensureSharedDirectories(); + + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true); + expect(readJson(targetPath)).toEqual({ version: 2, plugins: { live: [] } }); + expect(readJson(`${targetPath}.ccs-canonical-recovery`)).toEqual({ + version: 2, + plugins: { preserved: [] }, + }); + expect(fs.existsSync(claimPath)).toBe(false); + }); + + it('recovers a claimed plugin registry through a multi-hop dangling symlink chain', () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json'); + const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json'); + const targetPath = path.join(tempRoot, 'external', 'missing', 'installed_plugins.json'); + const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`; + fs.mkdirSync(path.dirname(canonicalPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + writeJson(claimPath, { version: 2, plugins: { recovered: [] } }); + fs.symlinkSync(targetPath, intermediatePath, 'file'); + fs.symlinkSync(intermediatePath, canonicalPath, 'file'); + + manager.ensureSharedDirectories(); + + expect(readJson(targetPath)).toEqual({ version: 2, plugins: { recovered: [] } }); + expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(claimPath)).toBe(false); + }); + }); + describe('marketplace registry ownership', () => { it('skips unstatable shared plugin entries during instance linking', () => { const manager = new SharedManager();