diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 0265e614..815d253e 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -216,7 +216,9 @@ export function registerApiProfileOrphans(options?: { } try { - ensureProfileHooksOrThrow(orphan.name); + if (orphan.validation.valid) { + ensureProfileHooksOrThrow(orphan.name); + } registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false); result.registered.push(orphan.name); } catch (error) { diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 51eff272..a2c7149a 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -25,8 +25,17 @@ function hasMatchingHookContents(sourcePath: string, destinationPath: string): b } const source = fs.readFileSync(sourcePath); - const destination = fs.readFileSync(destinationPath); - return source.equals(destination); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Existing WebSearch hook is unreadable; reinstalling: ${(error as Error).message}`) + ); + } + return false; + } } function getTempHookPath(hookPath: string): string { diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 3ed07c6e..758c9aec 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -182,6 +182,26 @@ describe('profile lifecycle service', () => { expect(result.skipped).toEqual([]); }); + it('registers malformed orphan settings when force bypasses validation', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const malformedPath = path.join(ccsDir, 'bad.settings.json'); + fs.writeFileSync(malformedPath, '{ invalid json', 'utf8'); + fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + + const result = await runInScopedCcsDir(() => + registerApiProfileOrphans({ names: ['bad'], force: true }) + ); + const config = await runInScopedCcsDir(() => loadConfigSafe()); + + expect(result.registered).toEqual(['bad']); + expect(result.skipped).toEqual([]); + expect(config.profiles.bad).toBe('~/.ccs/bad.settings.json'); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'websearch-transformer.cjs'))).toBe(false); + expect(fs.readFileSync(malformedPath, 'utf8')).toBe('{ invalid json'); + }); + it('redacts all sensitive env values during export when includeSecrets=false', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/utils/websearch/profile-hook-injector.test.ts b/tests/unit/utils/websearch/profile-hook-injector.test.ts index 2ab1faa1..3268d6c6 100644 --- a/tests/unit/utils/websearch/profile-hook-injector.test.ts +++ b/tests/unit/utils/websearch/profile-hook-injector.test.ts @@ -119,6 +119,33 @@ describe('ensureProfileHooks', () => { expect(installedHook).toContain('CCS WebSearch Hook'); }); + it('repairs an unreadable existing hook binary instead of failing the profile setup', () => { + if (process.platform === 'win32') return; + + setupTempHome(); + + const hookPath = getHookPath(); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, '// unreadable stale hook', 'utf8'); + fs.chmodSync(hookPath, 0o200); + + try { + const ensured = ensureProfileHooks('glm'); + const settingsPath = path.join(getCcsDir(), 'glm.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const installedHook = fs.readFileSync(hookPath, 'utf8'); + + expect(ensured).toBe(true); + expect(installedHook).not.toBe('// unreadable stale hook'); + expect(installedHook).toContain('CCS WebSearch Hook'); + expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(`node "${hookPath}"`); + } finally { + if (fs.existsSync(hookPath)) { + fs.chmodSync(hookPath, 0o644); + } + } + }); + it('succeeds when another process installs the hook during a failed local install', () => { setupTempHome();