fix(websearch): harden settings-profile hook setup

- fail enabled settings-profile create and launch flows when hook preparation is still unusable

- refresh stale shared hook binaries without rewriting unchanged installs or failing benign races

- add rollback and regression coverage for disabled, stale, invalid, copy, import, and launch paths
This commit is contained in:
Tam Nhu Tran committed 2026-03-28 09:51:13 -04:00
1 parent fbbdd80830
commit 0e6965d205
12 files changed
+549 -46

No files matched your search

+14 -4
View File
@@ -9,7 +9,7 @@ import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
@@ -216,8 +216,8 @@ export function registerApiProfileOrphans(options?: {
}
try {
ensureProfileHooksOrThrow(orphan.name);
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
ensureProfileHooks(orphan.name);
result.registered.push(orphan.name);
} catch (error) {
result.skipped.push({ name: orphan.name, reason: (error as Error).message });
@@ -264,7 +264,12 @@ export function copyApiProfile(
: null;
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
ensureProfileHooks(destination);
try {
ensureProfileHooksOrThrow(destination);
} catch (hookError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw hookError;
}
try {
registerApiProfileInConfig(
destination,
@@ -384,7 +389,12 @@ export function importApiProfileBundle(
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
writeJsonObjectAtomically(settingsPath, settings);
ensureProfileHooks(name);
try {
ensureProfileHooksOrThrow(name);
} catch (hookError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw hookError;
}
try {
registerApiProfileInConfig(name, options?.target || bundleTarget || 'claude', options?.force);
} catch (registrationError) {
+34 -5
View File
@@ -8,7 +8,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man
import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
@@ -69,6 +69,21 @@ function getDeniedModelReason(baseUrl: string, models: ModelMapping): string | n
return null;
}
function rollbackSettingsFile(
filePath: string,
previousContent: string | null,
existedBefore: boolean
): void {
if (existedBefore && previousContent !== null) {
fs.writeFileSync(filePath, previousContent, 'utf8');
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
/** Create settings.json file for API profile (legacy format) */
function createSettingsFile(
name: string,
@@ -105,11 +120,18 @@ function createSettingsFile(
},
};
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Inject WebSearch hooks into profile settings
ensureProfileHooks(name);
try {
// Inject WebSearch hooks into profile settings
ensureProfileHooksOrThrow(name);
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
return settingsPath;
}
@@ -189,10 +211,17 @@ function createApiProfileUnified(
fs.mkdirSync(ccsDir, { recursive: true });
}
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Inject WebSearch hooks into profile settings
ensureProfileHooks(name);
try {
// Inject WebSearch hooks into profile settings
ensureProfileHooksOrThrow(name);
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
mutateUnifiedConfig((config) => {
config.profiles[name] = {
+4 -4
View File
@@ -27,7 +27,7 @@ import {
ensureMcpWebSearch,
displayWebSearchStatus,
getWebSearchHookEnv,
ensureProfileHooks,
ensureProfileHooksOrThrow,
} from './utils/websearch-manager';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
@@ -506,7 +506,7 @@ async function main(): Promise<void> {
if (profileInfo.type === 'cliproxy') {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
ensureProfileHooksOrThrow(profileInfo.name);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
@@ -660,7 +660,7 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
ensureProfileHooksOrThrow(profileInfo.name);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
@@ -693,7 +693,7 @@ async function main(): Promise<void> {
// Settings-based profiles (glm, glmt) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
ensureProfileHooksOrThrow(profileInfo.name);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks(profileInfo.name);
+42 -6
View File
@@ -14,7 +14,7 @@ import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
@@ -95,6 +95,21 @@ function writeSettings(filePath: string, settings: SettingsFile): void {
fs.renameSync(tempPath, filePath);
}
function rollbackSettingsFile(
filePath: string,
previousContent: string | null,
existedBefore: boolean
): void {
if (existedBefore && previousContent !== null) {
fs.writeFileSync(filePath, previousContent, 'utf8');
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
/**
* Get settings file path for a variant
*/
@@ -133,11 +148,18 @@ export function createSettingsFile(
env: buildSettingsEnv(provider, model, port),
};
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
try {
// Inject WebSearch hooks into variant settings
ensureProfileHooksOrThrow(`${provider}-${name}`);
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
@@ -161,11 +183,18 @@ export function createSettingsFileUnified(
env: buildSettingsEnv(provider, model, port),
};
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
try {
// Inject WebSearch hooks into variant settings
ensureProfileHooksOrThrow(`${provider}-${name}`);
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
@@ -252,12 +281,19 @@ export function createCompositeSettingsFile(
}
}
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
ensureDir(settingsDir);
writeSettings(settingsPath, settings);
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
ensureProfileHooks(`composite-${name}`);
try {
ensureProfileHooksOrThrow(`composite-${name}`);
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
ensureImageAnalyzerHooks(`composite-${name}`);
}
+1 -1
View File
@@ -63,7 +63,7 @@ export {
} from './websearch/status';
// Re-export profile hook injection
export { ensureProfileHooks } from './websearch/profile-hook-injector';
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector';
// Import for local use
import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch';
+44 -3
View File
@@ -19,6 +19,21 @@ export { getHookPath, getWebSearchHookConfig } from './hook-config';
// Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
function hasMatchingHookContents(sourcePath: string, destinationPath: string): boolean {
if (!fs.existsSync(destinationPath)) {
return false;
}
const source = fs.readFileSync(sourcePath);
const destination = fs.readFileSync(destinationPath);
return source.equals(destination);
}
function getTempHookPath(hookPath: string): string {
const uniqueSuffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${hookPath}.${uniqueSuffix}.tmp`;
}
export function getMigrationMarkerPath(): string {
return path.join(getCcsDir(), '.hook-migrated');
}
@@ -94,9 +109,35 @@ export function installWebSearchHook(): boolean {
return false;
}
// Copy hook to ~/.ccs/hooks/
fs.copyFileSync(sourcePath, hookPath);
fs.chmodSync(hookPath, 0o755);
// Avoid rewriting the shared hook binary when the bundled script is unchanged.
if (hasMatchingHookContents(sourcePath, hookPath)) {
return true;
}
// Copy hook to ~/.ccs/hooks/ via a unique temp path so concurrent installers
// do not contend on the same file.
const tempHookPath = getTempHookPath(hookPath);
try {
fs.copyFileSync(sourcePath, tempHookPath);
fs.chmodSync(tempHookPath, 0o755);
try {
fs.renameSync(tempHookPath, hookPath);
} catch (renameError) {
const errorCode = (renameError as NodeJS.ErrnoException).code;
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
throw renameError;
}
fs.copyFileSync(tempHookPath, hookPath);
fs.chmodSync(hookPath, 0o755);
fs.unlinkSync(tempHookPath);
}
} finally {
if (fs.existsSync(tempHookPath)) {
fs.unlinkSync(tempHookPath);
}
}
if (process.env.CCS_DEBUG) {
console.error(info(`Installed WebSearch hook: ${hookPath}`));
+1 -1
View File
@@ -62,4 +62,4 @@ export {
export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets';
// Profile Hook Injection
export { ensureProfileHooks } from './profile-hook-injector';
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './profile-hook-injector';
+47 -17
View File
@@ -20,6 +20,21 @@ import { getMigrationMarkerPath, installWebSearchHook } from './hook-installer';
// Valid profile name pattern (alphanumeric, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
function hasUsableHookBinary(): boolean {
try {
const hookPath = getHookPath();
const stat = fs.statSync(hookPath);
if (!stat.isFile()) {
return false;
}
fs.accessSync(hookPath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
/**
* Check if CCS WebSearch hook exists in settings
*/
@@ -83,25 +98,8 @@ export function ensureProfileHooks(profileName: string): boolean {
return false;
}
// Keep the injected command target valid for all profile types, not just CLIProxy.
if (!installWebSearchHook() && !fs.existsSync(getHookPath())) {
if (process.env.CCS_DEBUG) {
console.error(warn('WebSearch hook binary is missing and could not be installed'));
}
return false;
}
// One-time migration from global settings
migrateGlobalHook();
// Get CCS directory (respects CCS_HOME for test isolation)
const ccsDir = getCcsDir();
// Ensure CCS dir exists
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
// Read existing settings or create empty
@@ -121,6 +119,25 @@ export function ensureProfileHooks(profileName: string): boolean {
}
}
// Keep the injected command target valid for all profile types, not just CLIProxy.
// The installer already skips byte-identical copies, so we can always attempt a refresh
// without rewriting unchanged hooks. Re-check existence after a failed install to tolerate
// concurrent first-run installs that may have completed in another process.
if (!installWebSearchHook() && !hasUsableHookBinary()) {
if (process.env.CCS_DEBUG) {
console.error(warn('WebSearch hook binary is missing and could not be installed'));
}
return false;
}
// One-time migration from global settings
migrateGlobalHook();
// Ensure CCS dir exists before writing settings updates.
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// Check if CCS hook already present
if (hasCcsHook(settings)) {
// Clean up any duplicates that may have accumulated (Windows path bug fix)
@@ -176,6 +193,19 @@ export function ensureProfileHooks(profileName: string): boolean {
}
}
export function ensureProfileHooksOrThrow(profileName: string): void {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return;
}
if (!ensureProfileHooks(profileName)) {
throw new Error(
`WebSearch is enabled, but CCS could not prepare the profile hook for "${profileName}".`
);
}
}
/**
* Update hook timeout if it differs from current config
*/