fix(recovery): harden shared commands startup recovery

This commit is contained in:
Tam Nhu Tran
2026-04-08 11:00:18 -04:00
parent 894a8ea15f
commit 103f0547c9
3 changed files with 209 additions and 2 deletions
+78 -2
View File
@@ -47,6 +47,68 @@ class RecoveryManager {
return false;
}
/**
* Remove a dangling symlink so recovery can recreate the directory.
* Mirrors scripts/postinstall.js behavior for skipped lifecycle installs.
*/
private removeIfBrokenSymlink(targetPath: string): boolean {
try {
const stats = fs.lstatSync(targetPath);
if (!stats.isSymbolicLink()) {
return false;
}
try {
fs.statSync(targetPath);
return false;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
return false;
}
fs.unlinkSync(targetPath);
this.recovered.push(`Removed broken symlink: ${targetPath}`);
return true;
}
} catch {
return false;
}
}
private inspectDirectoryPath(
targetPath: string
): { state: 'ready' } | { state: 'missing' } | { state: 'invalid'; reason: string } {
try {
const stats = fs.lstatSync(targetPath);
if (stats.isSymbolicLink()) {
try {
return fs.statSync(targetPath).isDirectory()
? { state: 'ready' }
: { state: 'invalid', reason: 'symlink target is not a directory' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === 'ENOENT' || code === 'ENOTDIR'
? { state: 'missing' }
: {
state: 'invalid',
reason: `symlink target is not accessible (${code || 'unknown'})`,
};
}
}
return stats.isDirectory()
? { state: 'ready' }
: { state: 'invalid', reason: 'existing path is not a directory' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === 'ENOENT'
? { state: 'missing' }
: { state: 'invalid', reason: `could not inspect path (${code || 'unknown'})` };
}
}
/**
* Ensure ~/.ccs/config.yaml exists with defaults
* This is the primary config format (YAML unified config)
@@ -126,20 +188,34 @@ class RecoveryManager {
ensureSharedDirectories(): boolean {
let created = false;
this.removeIfBrokenSymlink(this.sharedDir);
const sharedState = this.inspectDirectoryPath(this.sharedDir);
// Create shared directory
if (!fs.existsSync(this.sharedDir)) {
if (sharedState.state === 'missing') {
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 });
this.recovered.push(`Created ${this.sharedDir}`);
created = true;
} else if (sharedState.state === 'invalid') {
this.recovered.push(`Skipped ${this.sharedDir}: ${sharedState.reason}`);
return created;
}
// Create subdirectories
const subdirs = ['commands', 'skills', 'agents', 'plugins'];
for (const subdir of subdirs) {
const subdirPath = path.join(this.sharedDir, subdir);
if (!fs.existsSync(subdirPath)) {
this.removeIfBrokenSymlink(subdirPath);
const subdirState = this.inspectDirectoryPath(subdirPath);
if (subdirState.state === 'missing') {
fs.mkdirSync(subdirPath, { recursive: true, mode: 0o755 });
created = true;
continue;
}
if (subdirState.state === 'invalid') {
this.recovered.push(`Skipped ${subdirPath}: ${subdirState.reason}`);
}
}
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import RecoveryManager from '../../src/management/recovery-manager';
function createDirectorySymlink(targetDir: string, linkPath: string): void {
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
try {
fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EPERM' || code === 'EACCES') {
throw new Error(
`Symlink creation is not permitted in this environment (${code}) for ${linkPath}`
);
}
throw error;
}
}
describe('RecoveryManager', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-recovery-manager-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('recreates ~/.ccs/shared when recovery finds a dangling symlink', () => {
const ccsDir = path.join(tempHome, '.ccs');
const sharedDir = path.join(ccsDir, 'shared');
fs.mkdirSync(ccsDir, { recursive: true });
createDirectorySymlink(path.join(tempHome, 'missing-shared'), sharedDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.statSync(sharedDir).isDirectory()).toBe(true);
expect(fs.statSync(path.join(sharedDir, 'commands')).isDirectory()).toBe(true);
expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${sharedDir}`);
});
it('recreates ~/.ccs/shared/commands when recovery finds a dangling symlink', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
fs.mkdirSync(sharedDir, { recursive: true });
createDirectorySymlink(path.join(tempHome, 'missing-commands'), commandsDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${commandsDir}`);
});
it('preserves valid shared command symlinks', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
const externalCommandsDir = path.join(tempHome, 'external-commands');
fs.mkdirSync(sharedDir, { recursive: true });
fs.mkdirSync(externalCommandsDir, { recursive: true });
createDirectorySymlink(externalCommandsDir, commandsDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe(
externalCommandsDir
);
expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`);
});
it('does not delete a valid shared command symlink when target access is denied', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
const externalCommandsDir = path.join(tempHome, 'external-commands');
fs.mkdirSync(sharedDir, { recursive: true });
fs.mkdirSync(externalCommandsDir, { recursive: true });
createDirectorySymlink(externalCommandsDir, commandsDir);
const originalStatSync = fs.statSync.bind(fs);
spyOn(fs, 'statSync').mockImplementation((targetPath: fs.PathLike) => {
if (String(targetPath) === commandsDir) {
const error = new Error('simulated permission failure') as NodeJS.ErrnoException;
error.code = 'EACCES';
throw error;
}
return originalStatSync(targetPath);
});
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe(
externalCommandsDir
);
expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`);
expect(recovery.getRecoverySummary()).toContain(
`Skipped ${commandsDir}: symlink target is not accessible (EACCES)`
);
});
});
@@ -231,6 +231,14 @@ describe('web-server shared-routes', () => {
]);
});
it('returns an empty command list when shared commands directory is missing', async () => {
const payload = await getJson<{
items: Array<{ name: string; type: string; description: string; path: string }>;
}>(baseUrl, '/api/shared/commands');
expect(payload.items).toEqual([]);
});
it('returns full content for a shared command markdown file', async () => {
const commandsDir = path.join(ccsDir, 'shared', 'commands');
fs.mkdirSync(commandsDir, { recursive: true });