mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
fix(auth): guard shared linking against instance replacement
This commit is contained in:
1 parent
5f9db033e7
commit
54efb82055
4 files changed
+342
-138
No files matched your search
@@ -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 [];
|
||||
|
||||
@@ -39,6 +39,18 @@ import type { SharedItem } from './types';
|
||||
*/
|
||||
export type PluginLayoutRoots = PluginMetadataRoots;
|
||||
|
||||
function runGuardedInstanceMutation<T>(
|
||||
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<typeof adoptDivergedFileContent> = '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?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
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<typeof adoptDivergedFileContent> = '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<typeof adoptDivergedFileContent> = '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<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||
const linkPath = path.join(instancePath, item.name);
|
||||
const targetPath = path.join(sharedDir, item.name);
|
||||
let adoption: ReturnType<typeof adoptDivergedFileContent> = '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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user