From 56ba37911a2fc1465c1f793614ac19e4d7bcf004 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 13:46:15 -0400 Subject: [PATCH] fix(config): guard Claude extension metadata --- src/commands/env-command.ts | 17 ++-- src/commands/persist-command.ts | 8 +- src/shared/claude-extension-hosts.ts | 4 +- src/shared/claude-extension-setup.ts | 96 +++++++++++++++---- .../routes/claude-extension-routes.ts | 4 +- 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 6a1407a4..888f075d 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -7,10 +7,7 @@ import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; -import { - CLAUDE_EXTENSION_HOSTS, - type ClaudeExtensionHost, -} from '../shared/claude-extension-hosts'; +import { CLAUDE_EXTENSION_HOSTS, type ClaudeExtensionHost } from '../shared/claude-extension-hosts'; import { renderClaudeExtensionSettingsJson, resolveClaudeExtensionSetup, @@ -157,8 +154,12 @@ function showHelp(): void { ); console.log(''); console.log(subheader('Notes:')); - console.log(` ${dim('- Use ccs persist for shared ~/.claude/settings.json setup when possible.')}`); - console.log(` ${dim('- claude-extension output prints JSON only; replace the full environmentVariables setting.')}`); + console.log( + ` ${dim('- Use ccs persist for shared ~/.claude/settings.json setup when possible.')}` + ); + console.log( + ` ${dim('- claude-extension output prints JSON only; replace the full environmentVariables setting.')}` + ); console.log(''); } @@ -178,7 +179,9 @@ export async function handleEnvCommand(args: string[]): Promise { const flagsWithValues = ['format', 'shell', 'ide']; const profile = findProfile(args, flagsWithValues); if (!profile) { - console.error(fail('Usage: ccs env [--format openai|anthropic|raw|claude-extension]')); + console.error( + fail('Usage: ccs env [--format openai|anthropic|raw|claude-extension]') + ); process.exit(1); } diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 014f7f79..a1d061ff 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -720,7 +720,9 @@ async function showHelp(): Promise { console.log(subheader('Notes')); console.log(' [i] CLIProxy profiles require the proxy to be running.'); console.log(' [i] Copilot profiles require copilot-api daemon.'); - console.log(' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'); + console.log( + ' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.' + ); console.log( ' [i] For IDE-local settings.json snippets, use: ccs env --format claude-extension' ); @@ -832,7 +834,9 @@ export async function handlePersistCommand(args: string[]): Promise { // Warning about modification console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); console.log(dim(' Existing hooks and other settings will be preserved.')); - console.log(dim(' Existing managed profile env keys will be replaced to avoid stale routing.')); + console.log( + dim(' Existing managed profile env keys will be replaced to avoid stale routing.') + ); console.log(''); // Check if settings.json exists for backup const settingsPath = getClaudeSettingsPath(); diff --git a/src/shared/claude-extension-hosts.ts b/src/shared/claude-extension-hosts.ts index ea8e21b5..e9f3aca1 100644 --- a/src/shared/claude-extension-hosts.ts +++ b/src/shared/claude-extension-hosts.ts @@ -38,5 +38,7 @@ export const CLAUDE_EXTENSION_HOSTS: ClaudeExtensionHostDefinition[] = [ export function getClaudeExtensionHostDefinition( host: ClaudeExtensionHost = 'vscode' ): ClaudeExtensionHostDefinition { - return CLAUDE_EXTENSION_HOSTS.find((candidate) => candidate.id === host) ?? CLAUDE_EXTENSION_HOSTS[0]; + return ( + CLAUDE_EXTENSION_HOSTS.find((candidate) => candidate.id === host) ?? CLAUDE_EXTENSION_HOSTS[0] + ); } diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index c248ca7f..fd504e26 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -3,7 +3,11 @@ import ProfileDetector from '../auth/profile-detector'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context'; import type { ProfileDetectionResult } from '../auth/profile-detector'; -import { getEffectiveEnvVars, getRemoteEnvVars, getCompositeEnvVars } from '../cliproxy/config/env-builder'; +import { + getEffectiveEnvVars, + getRemoteEnvVars, + getCompositeEnvVars, +} from '../cliproxy/config/env-builder'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { generateCopilotEnv } from '../copilot/copilot-executor'; @@ -70,14 +74,19 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s ? 'Use Claude Code defaults with no CCS-specific transport override.' : `Use the current default profile resolution for "${result.name}".`; } - if (result.type === 'cliproxy') return 'OAuth or CLIProxy-backed profile for Anthropic-compatible routing.'; + if (result.type === 'cliproxy') + return 'OAuth or CLIProxy-backed profile for Anthropic-compatible routing.'; if (result.type === 'settings') return 'API profile backed by a CCS settings file.'; - if (result.type === 'account') return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.'; + if (result.type === 'account') + return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.'; if (result.type === 'copilot') return 'GitHub Copilot profile routed through copilot-api.'; return 'Native Claude profile resolution.'; } -function createProfileOption(profileName: string, result: ProfileDetectionResult): ClaudeExtensionProfileOption { +function createProfileOption( + profileName: string, + result: ProfileDetectionResult +): ClaudeExtensionProfileOption { return { name: profileName, profileType: result.type, @@ -89,7 +98,13 @@ function createProfileOption(profileName: string, result: ProfileDetectionResult export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { const detector = new ProfileDetector(); const all = detector.getAllProfiles(); - const orderedNames = ['default', ...all.accounts, ...all.settings, ...all.cliproxy, ...all.cliproxyVariants]; + const orderedNames = [ + 'default', + ...all.accounts, + ...all.settings, + ...all.cliproxy, + ...all.cliproxyVariants, + ]; const deduped = [...new Set(orderedNames)]; try { detector.detectProfileType('copilot'); @@ -106,7 +121,9 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { async function resolveExtensionEnv( requestedProfile: string, result: ProfileDetectionResult -): Promise> { +): Promise< + Pick +> { const warnings: string[] = []; const notes: string[] = []; const requestedIsDefault = requestedProfile === 'default'; @@ -144,7 +161,9 @@ async function resolveExtensionEnv( }; } - notes.push('Default profile clears CCS-managed transport overrides and uses native Claude defaults.'); + notes.push( + 'Default profile clears CCS-managed transport overrides and uses native Claude defaults.' + ); return { extensionEnv: {}, warnings, notes, disableLoginPrompt: false }; } @@ -155,30 +174,60 @@ async function resolveExtensionEnv( }); const env = result.type === 'settings' - ? result.env ?? (result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {}) + ? (result.env ?? + (result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {})) : result.type === 'copilot' - ? generateCopilotEnv(result.copilotConfig!, continuity.claudeConfigDir) + ? (() => { + if (!result.copilotConfig) { + throw new Error(`Profile "${requestedProfile}" is missing copilot configuration.`); + } + return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir); + })() : (() => { + if (!result.provider) { + throw new Error( + `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` + ); + } const proxyTarget = getProxyTarget(); const port = result.port || CLIPROXY_DEFAULT_PORT; if (proxyTarget.isRemote) { - warnings.push(`CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.`); + warnings.push( + `CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.` + ); return result.isComposite && result.compositeTiers && result.compositeDefaultTier - ? getCompositeEnvVars(result.compositeTiers, result.compositeDefaultTier, port, result.settingsPath, proxyTarget) - : getRemoteEnvVars(result.provider!, proxyTarget, result.settingsPath); + ? getCompositeEnvVars( + result.compositeTiers, + result.compositeDefaultTier, + port, + result.settingsPath, + proxyTarget + ) + : getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath); } - warnings.push('CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.'); + warnings.push( + 'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.' + ); return result.isComposite && result.compositeTiers && result.compositeDefaultTier - ? getCompositeEnvVars(result.compositeTiers, result.compositeDefaultTier, port, result.settingsPath) - : getEffectiveEnvVars(result.provider!, port, result.settingsPath); + ? getCompositeEnvVars( + result.compositeTiers, + result.compositeDefaultTier, + port, + result.settingsPath + ) + : getEffectiveEnvVars(result.provider, port, result.settingsPath); })(); if (!requestedIsDefault && continuity.claudeConfigDir && !env.CLAUDE_CONFIG_DIR) { env.CLAUDE_CONFIG_DIR = continuity.claudeConfigDir; - notes.push(`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`); + notes.push( + `Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".` + ); } if (result.type === 'copilot') { - warnings.push('copilot-api must stay reachable for this profile to work inside the IDE extension.'); + warnings.push( + 'copilot-api must stay reachable for this profile to work inside the IDE extension.' + ); } if (Object.keys(env).length === 0) { throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`); @@ -187,7 +236,9 @@ async function resolveExtensionEnv( return { extensionEnv: sortEnvRecord(env), warnings, notes, disableLoginPrompt: true }; } -export async function resolveClaudeExtensionSetup(requestedProfile: string): Promise { +export async function resolveClaudeExtensionSetup( + requestedProfile: string +): Promise { const detector = new ProfileDetector(); const result = detector.detectProfileType(requestedProfile); const resolved = await resolveExtensionEnv(requestedProfile, result); @@ -212,7 +263,10 @@ export function renderClaudeExtensionSettingsJson( ): string { const definition = getClaudeExtensionHostDefinition(host); const payload: Record = { - [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ name, value })), + [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ + name, + value, + })), }; if (definition.disableLoginPromptKey && setup.disableLoginPrompt) { payload[definition.disableLoginPromptKey] = true; @@ -224,7 +278,9 @@ export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): str return JSON.stringify({ env: setup.extensionEnv }, null, 2); } -export function getClaudeExtensionHostMetadata(host: ClaudeExtensionHost): ClaudeExtensionHostDefinition { +export function getClaudeExtensionHostMetadata( + host: ClaudeExtensionHost +): ClaudeExtensionHostDefinition { return getClaudeExtensionHostDefinition(host); } diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index d67fe465..41e081dd 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -18,7 +18,9 @@ const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id)); function getHostFromRequest(req: Request): ClaudeExtensionHost { const rawHost = String(req.query.host || 'vscode'); if (!VALID_HOSTS.has(rawHost as ClaudeExtensionHost)) { - throw new Error(`Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}`); + throw new Error( + `Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}` + ); } return rawHost as ClaudeExtensionHost; }