fix(install): harden Claude settings lifecycle

This commit is contained in:
wellkilo
2026-09-07 01:57:04 +08:00
parent 26d3e0038b
commit f59cfd57c2
19 changed files with 540 additions and 350 deletions
+31
View File
@@ -2694,6 +2694,37 @@ function runTests() {
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry missing a required matcher', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
SessionStart: [{
id: 'test:missing-matcher',
hooks: [{ type: 'command', command: 'echo start' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('matcher'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with an empty handlers array', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: { Stop: [{ id: 'test:empty-handlers', hooks: [] }] }
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('hooks'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with whitespace-only id', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
+118 -4
View File
@@ -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')] };
+2 -16
View File
@@ -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) {
+11 -13
View File
@@ -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 {
+11 -21
View File
@@ -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', () => {
@@ -8,6 +8,12 @@ const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md');
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
const HOOK_REGISTRATION_PHRASE =
'registers the resolved hook entries in `~/.claude/settings.json`';
function normalizeWhitespace(text) {
return text.replace(/\s+/g, ' ');
}
function test(name, fn) {
try {
@@ -44,11 +50,11 @@ function runTests() {
'README should document the supported PowerShell hook install path'
);
assert.ok(
readme.includes('%USERPROFILE%\\\\.claude'),
readme.includes('%USERPROFILE%\\.claude'),
'README should call out the correct Windows Claude config root'
);
assert.ok(
readme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
normalizeWhitespace(readme).includes(HOOK_REGISTRATION_PHRASE),
'README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;
@@ -67,7 +73,7 @@ function runTests() {
'hooks/README should document the supported PowerShell hook install path'
);
assert.ok(
hooksReadme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
normalizeWhitespace(hooksReadme).includes(HOOK_REGISTRATION_PHRASE),
'hooks/README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;