diff --git a/src/management/instance-directory.ts b/src/management/instance-directory.ts index ab5a15c9..82ca0e96 100644 --- a/src/management/instance-directory.ts +++ b/src/management/instance-directory.ts @@ -15,7 +15,7 @@ function unsafeInstancePath(targetPath: string): never { }); } -interface DirectoryIdentity { +export interface DirectoryIdentity { device: number; inode: number; realPath: string; @@ -115,6 +115,39 @@ export function assertSafeAccountInstancePath(instancesDir: string, instancePath if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath); } +/** + * Capture the validated directory identity used by later mutation guards. + * This is defense-in-depth for static and ordinary concurrent replacement, + * not a hostile same-UID race boundary; that would require dirfd/openat-style APIs. + */ +export function captureSafeAccountInstanceIdentity( + instancesDir: string, + instancePath: string +): DirectoryIdentity { + assertSafeAccountInstancePath(instancesDir, instancePath); + const identity = readDirectoryIdentity(instancePath); + assertAccountInstanceIdentity(instancesDir, instancePath, identity); + return identity; +} + +/** Require the lexical instance path to still name the validated directory. */ +export function assertAccountInstanceIdentity( + instancesDir: string, + instancePath: string, + expectedIdentity: DirectoryIdentity +): void { + if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath); + + let currentIdentity: DirectoryIdentity; + try { + currentIdentity = readDirectoryIdentity(instancePath); + } catch { + unsafeInstancePath(instancePath); + } + + if (!identitiesMatch(expectedIdentity, currentIdentity)) unsafeInstancePath(instancePath); +} + export function listAccountInstanceNames(instancesDir: string): string[] { if (!fs.existsSync(instancesDir)) { return []; diff --git a/src/management/shared-manager/plugin-layout-internals.ts b/src/management/shared-manager/plugin-layout-internals.ts index 0c9c2cf0..c215536b 100644 --- a/src/management/shared-manager/plugin-layout-internals.ts +++ b/src/management/shared-manager/plugin-layout-internals.ts @@ -39,6 +39,18 @@ import type { SharedItem } from './types'; */ export type PluginLayoutRoots = PluginMetadataRoots; +function runGuardedInstanceMutation( + assertInstanceIdentity: (() => void) | undefined, + operation: () => T +): T { + assertInstanceIdentity?.(); + try { + return operation(); + } finally { + assertInstanceIdentity?.(); + } +} + /** * Ensure the plugin layout default directories (cache, marketplaces) and * registry files (installed_plugins.json, known_marketplaces.json) exist @@ -75,7 +87,11 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void { * Link shared plugins directory entries into an instance, creating the * instance plugins directory first if needed. */ -export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: string): void { +export function linkInstancePlugins( + roots: PluginLayoutRoots, + instancePath: string, + assertInstanceIdentity?: () => void +): void { const linkPath = path.join(instancePath, 'plugins'); const targetPath = path.join(roots.sharedDir, 'plugins'); let linkStats: fs.Stats | null = null; @@ -87,30 +103,42 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri throw err; } } + assertInstanceIdentity?.(); if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { - removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + const existingType = linkStats.isDirectory() ? 'directory' : 'file'; + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkPath, existingType) + ); } if (!linkStats || !linkStats.isDirectory()) { - fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + runGuardedInstanceMutation(assertInstanceIdentity, () => + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }) + ); } - for (const item of getSharedPluginLinkItems(roots.sharedDir)) { + const linkItems = getSharedPluginLinkItems(roots.sharedDir); + assertInstanceIdentity?.(); + + for (const item of linkItems) { const targetEntryPath = path.join(targetPath, item.name); const linkEntryPath = path.join(linkPath, item.name); let adoption: ReturnType = 'not-claimed'; if (item.type === 'file') { - adoption = adoptDivergedFileContent( - linkEntryPath, - path.join(roots.claudeDir, 'plugins', item.name) + adoption = runGuardedInstanceMutation(assertInstanceIdentity, () => + adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name)) ); if (adoption === 'not-claimed') { - removeExistingPath(linkEntryPath, item.type); + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkEntryPath, item.type) + ); } } else { - removeExistingPath(linkEntryPath, item.type); + runGuardedInstanceMutation(assertInstanceIdentity, () => + removeExistingPath(linkEntryPath, item.type) + ); } assertAdoptionPathAbsent(linkEntryPath, adoption); @@ -119,10 +147,12 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri continue; } + assertInstanceIdentity?.(); try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); } catch (_err) { + assertInstanceIdentity?.(); assertAdoptionPathAbsent(linkEntryPath, adoption); if (getLstatSync(linkEntryPath)) { console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`)); @@ -130,9 +160,13 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri } if (process.platform === 'win32') { if (item.type === 'directory') { - copyDirectoryFallback(targetEntryPath, linkEntryPath); + runGuardedInstanceMutation(assertInstanceIdentity, () => + copyDirectoryFallback(targetEntryPath, linkEntryPath) + ); } else { - fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL); + runGuardedInstanceMutation(assertInstanceIdentity, () => + fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL) + ); } console.log( warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) @@ -141,6 +175,7 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri throw _err; } } + assertInstanceIdentity?.(); } } diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index 94072f05..b339f619 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -18,7 +18,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../../utils/ui'; -import { isSafeAccountInstancePath } from '../instance-directory'; +import { + assertAccountInstanceIdentity, + captureSafeAccountInstanceIdentity, +} from '../instance-directory'; import { adoptDivergedFileContent, assertAdoptionPathAbsent, @@ -54,6 +57,18 @@ import { SHARED_ITEMS } from './types'; */ export type LinkerRoots = PluginMetadataRoots; +function runInstanceMutationStage( + assertInstanceIdentity: (() => void) | undefined, + operation: () => T +): T { + assertInstanceIdentity?.(); + try { + return operation(); + } finally { + assertInstanceIdentity?.(); + } +} + /** * Detect a circular symlink before creation. A symlink is circular when its * target (raw or canonical) points back inside the shared root. @@ -102,104 +117,115 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea * Ensure shared directories exist as symlinks to ~/.claude/ and that the * plugin layout default directories and registry files are present. */ -export function ensureSharedDirectories(roots: LinkerRoots): void { +export function ensureSharedDirectories( + roots: LinkerRoots, + assertInstanceIdentity?: () => void +): void { const claudeDir = roots.claudeDir; const sharedDir = roots.sharedDir; - if (!getLstatSync(claudeDir)) { - console.log(info('Creating ~/.claude/ directory structure')); - fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); - } + runInstanceMutationStage(assertInstanceIdentity, () => { + if (!getLstatSync(claudeDir)) { + console.log(info('Creating ~/.claude/ directory structure')); + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + }); - if (!getLstatSync(sharedDir)) { - fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 }); - } + runInstanceMutationStage(assertInstanceIdentity, () => { + if (!getLstatSync(sharedDir)) { + fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 }); + } + }); - ensureSharedPluginLayoutDefaults(claudeDir); + runInstanceMutationStage(assertInstanceIdentity, () => + ensureSharedPluginLayoutDefaults(claudeDir) + ); for (const item of SHARED_ITEMS) { - const claudePath = path.join(claudeDir, item.name); - const sharedPath = path.join(sharedDir, item.name); + runInstanceMutationStage(assertInstanceIdentity, () => { + const claudePath = path.join(claudeDir, item.name); + const sharedPath = path.join(sharedDir, item.name); - if (item.type === 'file') { - 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, item.seedContent, 'utf8'); - } - } - - if (detectCircularSymlink(claudePath, sharedDir)) { - console.log(warn(`Skipping ${item.name}: circular symlink detected`)); - continue; - } - - let sharedAdoption: ReturnType = 'not-claimed'; - if (getLstatSync(sharedPath)) { - let removeExisting = true; - try { - const stats = fs.lstatSync(sharedPath); - if (stats.isSymbolicLink()) { - const currentTarget = fs.readlinkSync(sharedPath); - const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); - if (resolvedTarget === claudePath) { - continue; - } - } - } catch (_err) { - // Continue to recreate + if (item.type === 'file') { + recoverOrphanedCanonicalClaim(claudePath); } 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'; + reconcileCanonicalFirstFile(roots, item.name); } - if (item.type === 'directory' && removeExisting) { - fs.rmSync(sharedPath, { recursive: true, force: true }); - } else if (removeExisting) { - fs.unlinkSync(sharedPath); + if (!getLstatSync(claudePath)) { + if (item.type === 'directory') { + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + } else if (item.type === 'file') { + fs.writeFileSync(claudePath, item.seedContent, 'utf8'); + } } - assertAdoptionPathAbsent(sharedPath, sharedAdoption); - } - if (getLstatSync(sharedPath)) { - console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`)); - continue; - } + if (detectCircularSymlink(claudePath, sharedDir)) { + console.log(warn(`Skipping ${item.name}: circular symlink detected`)); + return; + } + + let sharedAdoption: ReturnType = 'not-claimed'; + if (getLstatSync(sharedPath)) { + let removeExisting = true; + try { + const stats = fs.lstatSync(sharedPath); + if (stats.isSymbolicLink()) { + const currentTarget = fs.readlinkSync(sharedPath); + const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); + if (resolvedTarget === claudePath) { + return; + } + } + } catch (_err) { + // Continue to recreate + } + + 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'; + } + + if (item.type === 'directory' && removeExisting) { + fs.rmSync(sharedPath, { recursive: true, force: true }); + } else if (removeExisting) { + fs.unlinkSync(sharedPath); + } + assertAdoptionPathAbsent(sharedPath, sharedAdoption); + } - 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; + return; } - if (process.platform === 'win32') { - if (item.type === 'directory') { - copyDirectoryFallback(claudePath, sharedPath); - } else if (item.type === 'file') { - fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL); + + 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`)); + return; + } + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(claudePath, sharedPath); + } else if (item.type === 'file') { + fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; } - } + }); } } @@ -207,73 +233,79 @@ 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 instanceIdentity = captureSafeAccountInstanceIdentity(roots.instancesDir, instancePath); + const assertInstanceIdentity = () => + assertAccountInstanceIdentity(roots.instancesDir, instancePath, instanceIdentity); + + ensureSharedDirectories(roots, assertInstanceIdentity); + assertInstanceIdentity(); const sharedDir = roots.sharedDir; for (const item of SHARED_ITEMS) { - if (item.name === 'plugins') { - linkInstancePlugins(roots, instancePath); - continue; - } + runInstanceMutationStage(assertInstanceIdentity, () => { + if (item.name === 'plugins') { + linkInstancePlugins(roots, instancePath, assertInstanceIdentity); + return; + } - const linkPath = path.join(instancePath, item.name); - const targetPath = path.join(sharedDir, item.name); - let adoption: ReturnType = 'not-claimed'; + const linkPath = path.join(instancePath, item.name); + const targetPath = path.join(sharedDir, item.name); + let adoption: ReturnType = 'not-claimed'; - if (item.type === 'file' && item.divergencePolicy === 'canonical-first') { - if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) { + 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); + } + } else { 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); - } - } 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; + return; } - if (process.platform === 'win32') { - if (item.type === 'directory') { - copyDirectoryFallback(targetPath, linkPath); - } else if (item.type === 'file') { - fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL); + + 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`)); + return; + } + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(targetPath, linkPath); + } else if (item.type === 'file') { + fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; } - } + }); } // Preserve original behavior: linkSharedDirectories always concludes by // normalizing plugin + marketplace metadata for the freshly linked // instance. migrateFromV311 relies on this side effect. - normalizePluginRegistryPaths(roots, instancePath); - normalizeMarketplaceRegistryPaths(roots, instancePath); + runInstanceMutationStage(assertInstanceIdentity, () => + normalizePluginRegistryPaths(roots, instancePath) + ); + runInstanceMutationStage(assertInstanceIdentity, () => + normalizeMarketplaceRegistryPaths(roots, instancePath) + ); } /** diff --git a/tests/unit/shared-claude-memory.test.ts b/tests/unit/shared-claude-memory.test.ts index 4bfb4e09..9ec355e0 100644 --- a/tests/unit/shared-claude-memory.test.ts +++ b/tests/unit/shared-claude-memory.test.ts @@ -62,6 +62,18 @@ describe('shared CLAUDE.md memory', () => { expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath); } + function expectInvalidInstancePath(operation: () => void): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(TypeError); + expect((thrown as NodeJS.ErrnoException).code).toBe('EINVAL'); + } + beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-')); originalHome = process.env.HOME; @@ -326,6 +338,98 @@ describe('shared CLAUDE.md memory', () => { expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n'); }); + it('aborts when the instance root is replaced during shared-root setup', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const externalInstance = path.join(tempHome, 'external-work'); + const sentinelPath = path.join(externalInstance, 'sentinel.bin'); + const sentinel = Buffer.from([0x00, 0x7f, 0x80, 0xff]); + fs.mkdirSync(workInstance, { recursive: true }); + fs.mkdirSync(externalInstance, { recursive: true }); + fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.writeFileSync(sentinelPath, sentinel); + + const originalMkdirSync = fs.mkdirSync; + let replaced = false; + spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => { + if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.symlinkSync(externalInstance, workInstance, 'dir'); + } + return originalMkdirSync(targetPath, options); + }) as typeof fs.mkdirSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(sentinelPath)).toEqual(sentinel); + expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.bin']); + }); + + it('does not reconcile a regular replacement directory during shared-root setup', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const replacementFixture = path.join(tempHome, 'replacement-work'); + const replacementMemory = path.join(replacementFixture, 'CLAUDE.md'); + const sentinelPath = path.join(replacementFixture, 'sentinel.bin'); + const memoryBytes = Buffer.from([0x23, 0x20, 0x80, 0xff, 0x0a]); + const sentinel = Buffer.from([0x11, 0x22, 0x33, 0x44]); + fs.mkdirSync(workInstance, { recursive: true }); + fs.mkdirSync(replacementFixture, { recursive: true }); + fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + fs.writeFileSync(replacementMemory, memoryBytes); + fs.writeFileSync(sentinelPath, sentinel); + + const originalMkdirSync = fs.mkdirSync; + let replaced = false; + spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => { + if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.renameSync(replacementFixture, workInstance); + } + return originalMkdirSync(targetPath, options); + }) as typeof fs.mkdirSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(path.join(workInstance, 'CLAUDE.md'))).toEqual(memoryBytes); + expect(fs.readFileSync(path.join(workInstance, 'sentinel.bin'))).toEqual(sentinel); + expect(fs.readdirSync(workInstance).sort()).toEqual(['CLAUDE.md', 'sentinel.bin']); + }); + + it('aborts when the instance root is replaced during plugin linking', () => { + const manager = new SharedManager(); + const workInstance = instanceDir('work'); + const originalInstance = instanceDir('work-original'); + const externalInstance = path.join(tempHome, 'external-work'); + const externalPlugins = path.join(externalInstance, 'plugins'); + const sentinelPath = path.join(externalPlugins, 'sentinel.bin'); + const sentinel = Buffer.from([0x10, 0x20, 0x30, 0x40]); + fs.mkdirSync(path.join(workInstance, 'plugins'), { recursive: true }); + fs.mkdirSync(externalPlugins, { recursive: true }); + fs.writeFileSync(sentinelPath, sentinel); + + const originalLstatSync = fs.lstatSync; + let replaced = false; + spyOn(fs, 'lstatSync').mockImplementation(((targetPath, options) => { + const result = originalLstatSync(targetPath, options); + if (!replaced && String(targetPath) === path.join(workInstance, 'plugins')) { + replaced = true; + fs.renameSync(workInstance, originalInstance); + fs.symlinkSync(externalInstance, workInstance, 'dir'); + } + return result; + }) as typeof fs.lstatSync); + + expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance)); + expect(fs.readFileSync(sentinelPath)).toEqual(sentinel); + expect(fs.readdirSync(externalPlugins)).toEqual(['sentinel.bin']); + expect(fs.readdirSync(externalInstance)).toEqual(['plugins']); + }); + it('rejects linking through a symlinked instances root without touching its target', () => { const manager = new SharedManager(); const managedInstances = path.join(ccsDir(), 'instances');