fix(shared-manager): preserve diverged files safely

This commit is contained in:
Tam Nhu Tran committed 2026-08-02 20:21:09 -04:00
1 parent fa6b27f159
commit 7ae617f0ee
5 files changed
+847 -37

No files matched your search

+397
View File
@@ -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<string, { installLocation?: string }>;
return parsed[name]?.installLocation ?? '';
@@ -318,6 +330,7 @@ describe('SharedManager', () => {
// 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();
@@ -340,6 +353,7 @@ describe('SharedManager', () => {
fs.mkdirSync(instancePath, { recursive: true });
writeJson(claudeSettingsPath, { theme: 'light' });
writeJson(instanceSettingsPath, { theme: 'dark' });
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
manager.linkSharedDirectories(instancePath);
@@ -362,6 +376,7 @@ describe('SharedManager', () => {
version: 2,
plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] },
});
setMtime(instanceRegistryPath, fs.statSync(claudeRegistryPath).mtimeMs + 2_000);
manager.linkSharedDirectories(instancePath);
@@ -386,6 +401,388 @@ describe('SharedManager', () => {
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', () => {