mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
fix(install): harden Claude settings lifecycle
This commit is contained in:
@@ -10,6 +10,8 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
runWithSettingsLock,
|
||||
materializeManagedHooks,
|
||||
inspectManagedHooks,
|
||||
mergeManagedHooks,
|
||||
parseSettings,
|
||||
@@ -78,15 +80,49 @@ function runTests() {
|
||||
{ BogusEvent: [entry('bad:event', 'bad')] },
|
||||
{ SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] },
|
||||
{ SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] },
|
||||
{
|
||||
SessionStart: [{ id: 'shared' }],
|
||||
Stop: [{ id: 'shared' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const invalid of invalidValues) {
|
||||
assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i);
|
||||
}
|
||||
assert.throws(
|
||||
() => validateManagedHooks({
|
||||
Stop: [entry('shared', 'a')],
|
||||
SubagentStop: [entry('shared', 'b')],
|
||||
}),
|
||||
/expected globally unique id "shared"/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('materializes hook roots and rejects unresolved environment references', () => {
|
||||
const source = {
|
||||
hooks: {
|
||||
Stop: [entry(
|
||||
'ecc:stop',
|
||||
'var e=process.env.CLAUDE_PLUGIN_ROOT; '
|
||||
+ 'process.env.CLAUDE_PLUGIN_ROOT=r; ${CLAUDE_PLUGIN_ROOT}'
|
||||
)],
|
||||
},
|
||||
};
|
||||
const before = clone(source);
|
||||
const materialized = materializeManagedHooks(source, '/opt/ecc');
|
||||
const command = materialized.Stop[0].hooks[0].command;
|
||||
const encodedRoot = command.match(/Buffer\.from\('([^']+)','base64'\)/)[1];
|
||||
|
||||
assert.deepStrictEqual(source, before);
|
||||
assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
|
||||
assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}'));
|
||||
assert.strictEqual(Buffer.from(encodedRoot, 'base64').toString('utf8'), '/opt/ecc');
|
||||
assert.throws(() => materializeManagedHooks({}, '/opt/ecc'), /hooks object/);
|
||||
assert.throws(() => materializeManagedHooks(source, ''), /target root/);
|
||||
assert.throws(
|
||||
() => materializeManagedHooks({
|
||||
hooks: {
|
||||
Stop: [entry('ecc:stop', 'node -e "const e=process.env.CLAUDE_PLUGIN_ROOT"')],
|
||||
},
|
||||
}, '/opt/ecc'),
|
||||
/Unable to resolve CLAUDE_PLUGIN_ROOT/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('replaces every plugin-root placeholder recursively and immutably', () => {
|
||||
@@ -234,6 +270,71 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('atomic settings updates honor an already-held lock', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-held-'));
|
||||
const settingsPath = path.join(tempDir, 'settings.json');
|
||||
const lockPath = `${settingsPath}.ecc.lock`;
|
||||
try {
|
||||
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
|
||||
updateSettingsAtomic(
|
||||
settingsPath,
|
||||
settings => ({ settings: { ...settings, held: true } }),
|
||||
{ lockHeld: true }
|
||||
);
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { held: true });
|
||||
assert.ok(fs.existsSync(lockPath));
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('settings lock release failures do not replace the primary update error', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-error-'));
|
||||
const settingsPath = path.join(tempDir, 'settings.json');
|
||||
const lockPath = `${settingsPath}.ecc.lock`;
|
||||
try {
|
||||
let caught;
|
||||
try {
|
||||
runWithSettingsLock(settingsPath, () => {
|
||||
fs.rmSync(lockPath, { force: true });
|
||||
throw new Error('primary settings failure');
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert.ok(caught);
|
||||
assert.strictEqual(caught.message, 'primary settings failure');
|
||||
assert.ok(caught.releaseError);
|
||||
assert.strictEqual(caught.releaseError.code, 'ENOENT');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('atomic settings updates refuse a symlinked destination', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
|
||||
return;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-symlink-'));
|
||||
const realPath = path.join(tempDir, 'real.json');
|
||||
const settingsPath = path.join(tempDir, 'settings.json');
|
||||
try {
|
||||
fs.writeFileSync(realPath, '{"theme":"dark"}\n', { mode: 0o600 });
|
||||
fs.symlinkSync(realPath, settingsPath);
|
||||
assert.throws(
|
||||
() => updateSettingsAtomic(
|
||||
settingsPath,
|
||||
settings => ({ settings: { ...settings, managed: true } })
|
||||
),
|
||||
error => error.code === 'ELOOP' || error.code === 'ECC_SETTINGS_CHANGED'
|
||||
);
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(realPath, 'utf8')), { theme: 'dark' });
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => {
|
||||
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] };
|
||||
const settings = {
|
||||
@@ -540,6 +641,19 @@ function runTests() {
|
||||
assert.deepStrictEqual(result.retained, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall accepts structurally valid hooks from an older runtime contract', () => {
|
||||
const recorded = {
|
||||
LegacyEvent: [{
|
||||
id: 'ecc:legacy',
|
||||
hooks: [{ type: 'legacy-handler', payload: { version: 1 } }],
|
||||
}],
|
||||
};
|
||||
const result = uninstallManagedHooks({ hooks: clone(recorded) }, recorded);
|
||||
|
||||
assert.deepStrictEqual(result.settings, {});
|
||||
assert.deepStrictEqual(result.removed, [{ event: 'LegacyEvent', id: 'ecc:legacy' }]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('all settings transforms reject non-array hook events before changing data', () => {
|
||||
const settings = { hooks: { Stop: 'invalid' } };
|
||||
const managed = { Stop: [entry('ecc:stop', 'expected')] };
|
||||
|
||||
@@ -182,14 +182,7 @@ function runTests() {
|
||||
target: 'claude',
|
||||
moduleIds: ['hooks-runtime'],
|
||||
});
|
||||
const plan = {
|
||||
...rawPlan,
|
||||
hookConsent: 'enabled',
|
||||
statePreview: {
|
||||
...rawPlan.statePreview,
|
||||
request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
|
||||
},
|
||||
};
|
||||
const plan = withHookConsent(rawPlan, 'enabled');
|
||||
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
||||
|
||||
applyInstallPlanDirect(plan, {
|
||||
@@ -584,14 +577,7 @@ function runTests() {
|
||||
target: 'claude',
|
||||
moduleIds: ['hooks-runtime'],
|
||||
});
|
||||
const plan = {
|
||||
...rawPlan,
|
||||
hookConsent: 'enabled',
|
||||
statePreview: {
|
||||
...rawPlan.statePreview,
|
||||
request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
|
||||
},
|
||||
};
|
||||
const plan = withHookConsent(rawPlan, 'enabled');
|
||||
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
||||
let settingsCommitCount = 0;
|
||||
fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) {
|
||||
|
||||
@@ -23,7 +23,10 @@ const {
|
||||
readInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings');
|
||||
const {
|
||||
assertClaudeSettingsPath,
|
||||
materializeManagedHooks,
|
||||
} = require('../../scripts/lib/install/claude-settings');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
@@ -3479,7 +3482,10 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair creates missing Claude settings with private permissions', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (POSIX file modes unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const homeDir = createTempDir('install-lifecycle-claude-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
@@ -3710,7 +3716,6 @@ function runTests() {
|
||||
assert.strictEqual(doctor.results[0].status, 'error');
|
||||
assert.ok(doctor.results[0].issues.some(issue => (
|
||||
issue.code === 'unsafe-managed-destination'
|
||||
|| issue.code === 'invalid-install-state'
|
||||
)));
|
||||
assert.strictEqual(repair.results[0].status, 'error');
|
||||
assert.match(repair.results[0].error, /final symlink/);
|
||||
@@ -3726,24 +3731,17 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Claude settings lifecycle refuses a non-canonical settings destination', () => {
|
||||
if (test('Claude settings path validation refuses a non-canonical destination', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-claude-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const destinationPath = path.join(targetRoot, 'settings.local.json');
|
||||
const managedHooks = currentManagedHooks(targetRoot);
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
assert.throws(
|
||||
() => writeClaudeState(homeDir, {
|
||||
operations: [
|
||||
managedOperation('update-claude-settings', destinationPath, {
|
||||
managedHooks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
/canonical Claude settings path/
|
||||
() => assertClaudeSettingsPath(destinationPath, targetRoot),
|
||||
/outside the canonical settings file/
|
||||
);
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
} finally {
|
||||
|
||||
@@ -169,28 +169,18 @@ function runTests() {
|
||||
},
|
||||
}],
|
||||
}),
|
||||
/managedHooks.*non-empty unique id/
|
||||
);
|
||||
assert.throws(
|
||||
() => createInstallState({
|
||||
...baseOptions,
|
||||
operations: [{
|
||||
...operation,
|
||||
managedHooks: {
|
||||
SessionStart: [{
|
||||
id: 'duplicate',
|
||||
matcher: '.*',
|
||||
hooks: [{ type: 'command', command: 'node start.js' }],
|
||||
}],
|
||||
Stop: [{
|
||||
id: 'duplicate',
|
||||
hooks: [{ type: 'command', command: 'node stop.js' }],
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}),
|
||||
/managedHooks.*globally unique id/
|
||||
/managedHooks.*Invalid hook entry/
|
||||
);
|
||||
assert.doesNotThrow(() => createInstallState({
|
||||
...baseOptions,
|
||||
operations: [{
|
||||
...operation,
|
||||
managedHooks: {
|
||||
SessionStart: [{ id: 'shared', hooks: [] }],
|
||||
LegacyEvent: [{ id: 'shared', hooks: [{ type: 'legacy' }] }],
|
||||
},
|
||||
}],
|
||||
}));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes and reads install-state from disk', () => {
|
||||
|
||||
Reference in New Issue
Block a user