diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index a9d83c195..78ec9b5d9 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -12,6 +12,16 @@ // Codex). Those values are local-only and should not be logged or returned // outside this machine. +/** Typed error for config validation failures — caught by the HTTP route + * to return 400 instead of 500, so clients can distinguish bad input + * from daemon breakage. */ +export class AppConfigValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AppConfigValidationError'; + } +} + import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import path from 'node:path'; @@ -163,6 +173,7 @@ const AGENT_CLI_ENV_KEYS: ReadonlyMap> = new Map([ ])], ['aider', new Set(['AIDER_BIN'])], ['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY'])], + ['codebuddy', new Set(['CODEBUDDY_CONFIG_DIR', 'CODEBUDDY_BIN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_API_KEY', 'CODEBUDDY_INTERNET_ENVIRONMENT'])], ['codex', new Set(['CODEX_HOME', 'CODEX_BIN', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'OPENAI_API_KEY'])], ['copilot', new Set(['COPILOT_BIN'])], ['cursor-agent', new Set(['CURSOR_AGENT_BIN'])], @@ -181,6 +192,13 @@ const AGENT_CLI_ENV_KEYS: ReadonlyMap> = new Map([ ['vibe', new Set(['VIBE_BIN'])], ]); +// Closed-enum env keys: value must be one of the listed strings or empty. +const AGENT_CLI_ENV_ENUMS: ReadonlyMap>> = new Map([ + ['codebuddy', new Map([ + ['CODEBUDDY_INTERNET_ENVIRONMENT', new Set(['internal', 'ioa'])], + ])], +]); + function isValidAgentModelEntry(v: unknown): v is AgentModelPrefs { if (!v || typeof v !== 'object' || Array.isArray(v)) return false; const obj = v as Record; @@ -206,22 +224,93 @@ function validateAgentModels( return Object.keys(result).length > 0 ? result : undefined; } -export function validateAgentCliEnv(raw: unknown): AgentCliEnvPrefs | undefined { +export function validateAgentCliEnv( + raw: unknown, + options?: { throwOnInvalid?: boolean }, +): AgentCliEnvPrefs | undefined { + const throwOnInvalid = options?.throwOnInvalid ?? false; if (raw === undefined || raw === null) return undefined; - if (typeof raw !== 'object' || Array.isArray(raw)) return undefined; + if (typeof raw !== 'object' || Array.isArray(raw)) { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] agentCliEnv: expected object, got ${Array.isArray(raw) ? 'array' : typeof raw}`, + ); + } + return undefined; + } const result: AgentCliEnvPrefs = Object.create(null); for (const [agentId, value] of Object.entries(raw as Record)) { - if (agentId === '__proto__' || agentId === 'constructor') continue; + if (agentId === '__proto__' || agentId === 'constructor') { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] agentCliEnv: reserved agent id "${agentId}" is not allowed`, + ); + } + continue; + } const allowed = AGENT_CLI_ENV_KEYS.get(agentId); - if (!allowed || typeof value !== 'object' || value === null || Array.isArray(value)) { + if (!allowed) { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] agentCliEnv: unknown agent "${agentId}"`, + ); + } + continue; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] ${agentId}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`, + ); + } continue; } const env: Record = Object.create(null); + const enums = AGENT_CLI_ENV_ENUMS.get(agentId); for (const [envKey, envValue] of Object.entries(value as Record)) { - if (!allowed.has(envKey)) continue; - if (typeof envValue !== 'string') continue; + if (!allowed.has(envKey)) { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] ${agentId}.${envKey}: unknown env key`, + ); + } + continue; + } + if (typeof envValue !== 'string') { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] ${agentId}.${envKey}: expected string, got ${typeof envValue}`, + ); + } + continue; + } const trimmed = envValue.trim(); - if (!trimmed) continue; + if (!trimmed) { + // For closed-enum keys, empty string is an explicit "unset" marker + // that tells spawnEnvForAgent() to delete the inherited value. + // Without this, a user whose parent process sets + // CODEBUDDY_INTERNET_ENVIRONMENT=internal cannot switch back to + // the international/default path through Settings. + const allowedValues = enums?.get(envKey); + if (allowedValues) { + env[envKey] = ''; + } + continue; + } + const allowedValues = enums?.get(envKey); + if (allowedValues && !allowedValues.has(trimmed)) { + if (throwOnInvalid) { + throw new AppConfigValidationError( + `[app-config] ${agentId}.${envKey}: invalid value "${trimmed}",` + + ` expected one of: ${[...allowedValues].join(', ')}`, + ); + } + console.warn( + `[app-config] ${agentId}.${envKey}: invalid value "${trimmed}",` + + ` expected one of: ${[...allowedValues].join(', ')}. Dropping.`, + ); + continue; + } env[envKey] = trimmed; } if (Object.keys(env).length > 0) result[agentId] = env; @@ -310,6 +399,7 @@ function applyConfigValue( target: Record, key: keyof AppConfigPrefs, value: unknown, + options?: { throwOnInvalid?: boolean }, ): void { if (key === 'onboardingCompleted') { if (typeof value === 'boolean') target[key] = value; @@ -328,7 +418,7 @@ function applyConfigValue( } } if (key === 'agentCliEnv') { - const validated = validateAgentCliEnv(value); + const validated = validateAgentCliEnv(value, options); if (validated !== undefined) { target[key] = validated; } else { @@ -509,7 +599,7 @@ async function doWrite( const next: Record = { ...existing }; for (const key of Object.keys(partial)) { if (!ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) continue; - applyConfigValue(next, key as keyof AppConfigPrefs, partial[key]); + applyConfigValue(next, key as keyof AppConfigPrefs, partial[key], { throwOnInvalid: true }); } const file = configFile(dataDir); await mkdir(path.dirname(file), { recursive: true }); diff --git a/apps/daemon/src/claude-diagnostics.ts b/apps/daemon/src/claude-diagnostics.ts index 762289575..3aa595904 100644 --- a/apps/daemon/src/claude-diagnostics.ts +++ b/apps/daemon/src/claude-diagnostics.ts @@ -18,6 +18,57 @@ export interface ClaudeCliDiagnostic { retryable: boolean; } +interface AgentDiagnosticConfig { + brandName: string; + profileLabel: string; + // How to tell the user to authenticate. Used in different sentence shapes: + // "Run , then retry..." + // "Re-run for that profile, then retry..." + // "Run , and retry..." + runAndLogin: string; + runAndUseLogin: string; + configDirEnvKey: string; + baseUrlEnvKey: string; + apiKeyEnvKey: string; + endpointLabel: string; + // When true, the agent authenticates primarily via its apiKeyEnvKey in -p + // mode (not OAuth /login). Diagnostics should surface API-key guidance + // when the key is present and auth fails, rather than redirecting to /login. + apiKeyIsPrimaryAuth?: boolean; + // Additional env keys to report in diagnostic context when their effective + // value is set (e.g. CODEBUDDY_INTERNET_ENVIRONMENT). + contextEnvKeys?: string[]; +} + +const CLAUDE_DIAGNOSTIC_CONFIG: AgentDiagnosticConfig = { + brandName: 'Claude Code', + profileLabel: 'Claude', + runAndLogin: '`claude` and `/login`', + runAndUseLogin: '`claude`, use `/login`', + configDirEnvKey: 'CLAUDE_CONFIG_DIR', + baseUrlEnvKey: 'ANTHROPIC_BASE_URL', + apiKeyEnvKey: 'ANTHROPIC_API_KEY', + endpointLabel: 'Anthropic', +}; + +const CODEBUDDY_DIAGNOSTIC_CONFIG: AgentDiagnosticConfig = { + brandName: 'CodeBuddy Code', + profileLabel: 'CodeBuddy', + runAndLogin: '`codebuddy` and `/login`', + runAndUseLogin: '`codebuddy`, use `/login`', + configDirEnvKey: 'CODEBUDDY_CONFIG_DIR', + baseUrlEnvKey: 'CODEBUDDY_BASE_URL', + apiKeyEnvKey: 'CODEBUDDY_API_KEY', + endpointLabel: 'CodeBuddy', + apiKeyIsPrimaryAuth: true, + contextEnvKeys: ['CODEBUDDY_INTERNET_ENVIRONMENT'], +}; + +const AGENT_DIAGNOSTIC_CONFIGS = new Map([ + ['claude', CLAUDE_DIAGNOSTIC_CONFIG], + ['codebuddy', CODEBUDDY_DIAGNOSTIC_CONFIG], +]); + function envValue( env: Record | null | undefined, key: string, @@ -39,14 +90,21 @@ function withContext( message: string, detail: string, input: ClaudeCliDiagnosticInput, + config: AgentDiagnosticConfig, ): ClaudeCliDiagnostic { - const configDir = envValue(input.env, 'CLAUDE_CONFIG_DIR'); - const baseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL'); + const configDir = envValue(input.env, config.configDirEnvKey); + const baseUrl = envValue(input.env, config.baseUrlEnvKey); const diagnosticTail = redactSecrets(body(input)).replace(/\s+/g, ' ').trim().slice(-240); const context: string[] = [message, detail]; - if (diagnosticTail) context.push(`Claude output: ${diagnosticTail}`); - if (configDir) context.push(`Effective CLAUDE_CONFIG_DIR: ${configDir}.`); - if (baseUrl) context.push('ANTHROPIC_BASE_URL is set for this Claude Code process.'); + if (diagnosticTail) context.push(`${config.brandName} output: ${diagnosticTail}`); + if (configDir) context.push(`Effective ${config.configDirEnvKey}: ${configDir}.`); + if (baseUrl) context.push(`${config.baseUrlEnvKey} is set for this ${config.brandName} process.`); + if (config.contextEnvKeys) { + for (const ctxKey of config.contextEnvKeys) { + const ctxValue = envValue(input.env, ctxKey); + if (ctxValue) context.push(`${ctxKey}=${ctxValue} is effective for this ${config.brandName} process.`); + } + } return { message: redactSecrets(message), detail: redactSecrets(context.filter(Boolean).join(' ')), @@ -63,29 +121,44 @@ function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'clau return base === 'openclaude' ? 'openclaude' : 'claude'; } -export function diagnoseClaudeCliFailure( +function diagnoseCliFailure( input: ClaudeCliDiagnosticInput, + config: AgentDiagnosticConfig, ): ClaudeCliDiagnostic | null { - if (input.agentId !== 'claude') return null; if (input.exitCode === 0 && !input.signal) return null; const text = body(input); const normalized = text.toLowerCase(); - const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null; - const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null; - const runtime = selectedClaudeCompatibleRuntime(input); - const isOpenClaude = runtime === 'openclaude'; + const hasCustomBaseUrl = envValue(input.env, config.baseUrlEnvKey) !== null; + const hasConfigDir = envValue(input.env, config.configDirEnvKey) !== null; + // OpenClaude is a Claude-family fork that ships under the same agentId + // ('claude') with a different bin. When the resolved bin's basename is + // openclaude, surface its specific recovery copy instead of the + // /login-driven Claude flow. Other agentIds (e.g. codebuddy) ignore + // this flag. + const isOpenClaude = + config.brandName === 'Claude Code' && + selectedClaudeCompatibleRuntime(input) === 'openclaude'; const customEndpointConnectionFailure = hasCustomBaseUrl && (/connectionrefused/i.test(text) || /connection refused/i.test(text) || - /econnrefused/i.test(text)); + /econnrefused/i.test(text) || + /enotfound/i.test(text) || + /eai_again/i.test(text) || + /etimedout/i.test(text) || + /econnreset/i.test(text) || + /certificate/i.test(text) || + /self.signed/i.test(text) || + /unable to verify/i.test(text) || + /ERR_TLS/i.test(text)); if (customEndpointConnectionFailure) { return withContext( - 'Claude Code could not reach the configured custom Anthropic endpoint.', - 'ANTHROPIC_BASE_URL appears to point at a local or proxy endpoint that refused the connection. Start or fix that proxy, clear the stale endpoint, or remove the custom endpoint to retry with standard Claude Code auth.', + `${config.brandName} could not reach the configured custom ${config.endpointLabel} endpoint.`, + `${config.baseUrlEnvKey} appears to point at an endpoint that could not be reached (DNS failure, connection refused/reset, timeout, or TLS error). Start or fix that endpoint, clear the stale URL, or remove the custom endpoint to retry with standard ${config.brandName} auth.`, input, + config, ); } @@ -97,27 +170,55 @@ export function diagnoseClaudeCliFailure( /(auth|oauth|credential|token).*(fail|invalid|missing|expired|not found|none|unauthorized)/i.test(text) || /(unauthorized|invalid api key|missing api key|could not authenticate|authentication failed)/i.test(text); if (authFailure && hasCustomBaseUrl) { + if (config.apiKeyIsPrimaryAuth) { + const hasApiKey = envValue(input.env, config.apiKeyEnvKey) !== null; + const message = hasApiKey + ? `${config.brandName} could not authenticate with the configured custom ${config.endpointLabel} endpoint.` + : `${config.brandName} could not authenticate with the configured custom ${config.endpointLabel} endpoint. No API key is configured.`; + const detail = hasApiKey + ? `Check ${config.apiKeyEnvKey}, ${config.baseUrlEnvKey}, proxy credentials, and model access in Settings.` + : `Set ${config.apiKeyEnvKey} in Settings so the spawned ${config.brandName} process can authenticate against the custom endpoint, then retry.`; + return withContext(message, detail, input, config); + } return withContext( - 'Claude Code could not authenticate with the configured custom Anthropic endpoint.', - 'Check ANTHROPIC_BASE_URL, proxy credentials, endpoint authentication environment, and model access. Remove the custom endpoint only if you want to retry with standard Claude Code auth.', + `${config.brandName} could not authenticate with the configured custom ${config.endpointLabel} endpoint.`, + `Check ${config.baseUrlEnvKey}, proxy credentials, endpoint authentication environment, and model access. Remove the custom endpoint only if you want to retry with standard ${config.brandName} auth.`, input, + config, ); } if (authFailure) { + const hasApiKey = envValue(input.env, config.apiKeyEnvKey) !== null; if (isOpenClaude) { return withContext( 'OpenClaude could not authenticate with its configured endpoint.', 'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.', input, + config, ); } + // CodeBuddy authenticates via API key in -p mode; all auth failures + // point at API key setup, not /login (which -p never uses). + if (config.apiKeyIsPrimaryAuth) { + const configHint = hasConfigDir + ? `Check ${config.apiKeyEnvKey} and ${config.configDirEnvKey} in Settings.` + : `Set ${config.apiKeyEnvKey} in Settings. If you use multiple ${config.profileLabel} profiles, also set ${config.configDirEnvKey} so Open Design uses the correct one.`; + const message = hasApiKey + ? `${config.brandName} could not authenticate with the configured API key.` + : `${config.brandName} could not authenticate. No API key is configured.`; + const detail = hasApiKey + ? `The spawned ${config.brandName} process has ${config.apiKeyEnvKey} set but still exited before producing a response. ${configHint}` + : `The spawned ${config.brandName} process requires ${config.apiKeyEnvKey} for authentication in -p mode. ${configHint}`; + return withContext(message, detail, input, config); + } const configHint = hasConfigDir - ? 'The configured Claude config directory may contain stale or expired auth state.' - : 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.'; + ? `The configured ${config.profileLabel} config directory may contain stale or expired auth state.` + : `If you use multiple ${config.profileLabel} profiles, set ${config.configDirEnvKey} in Settings so Open Design spawns the same profile that works in your terminal.`; return withContext( - 'Claude Code could not authenticate. Run `claude`, use `/login`, then retry the Open Design request.', - `The spawned Claude Code process exited before producing a response. ${configHint}`, + `${config.brandName} could not authenticate. Run ${config.runAndUseLogin}, then retry the Open Design request.`, + `The spawned ${config.brandName} process exited before producing a response. ${configHint}`, input, + config, ); } @@ -127,9 +228,10 @@ export function diagnoseClaudeCliFailure( /(model).*(not available|not supported|unsupported|not found|not have access|no access)/i.test(text); if (modelUnavailable && hasCustomBaseUrl) { return withContext( - 'Claude Code could not access the selected model through the configured custom endpoint.', - 'The custom ANTHROPIC_BASE_URL or proxy may not expose the model Claude Code selected. Change the model, fix the endpoint/proxy, or remove ANTHROPIC_BASE_URL and retry with standard Claude Code auth.', + `${config.brandName} could not access the selected model through the configured custom endpoint.`, + `The custom ${config.baseUrlEnvKey} or proxy may not expose the model ${config.brandName} selected. Change the model, fix the endpoint/proxy, or remove ${config.baseUrlEnvKey} and retry with standard ${config.brandName} auth.`, input, + config, ); } @@ -140,9 +242,10 @@ export function diagnoseClaudeCliFailure( /native windows/i.test(text); if (windowsCredentialMismatch) { return withContext( - 'Claude Code appears to be using credentials from a different local environment.', - 'Re-authenticate Claude Code in the same Windows, WSL, or shell environment that Open Design uses. On native Windows, check Windows Credential Manager if `/login` does not repair the session.', + `${config.brandName} appears to be using credentials from a different local environment.`, + `Re-authenticate ${config.brandName} in the same Windows, WSL, or shell environment that Open Design uses. On native Windows, check Windows Credential Manager if the login command does not repair the session.`, input, + config, ); } @@ -150,20 +253,41 @@ export function diagnoseClaudeCliFailure( /(config|profile|session|credential|oauth)/i.test(text) && /(stale|corrupt|expired|different|missing|not found|invalid)/i.test(text); if (configStateFailure) { + if (config.apiKeyIsPrimaryAuth) { + const hasApiKey = envValue(input.env, config.apiKeyEnvKey) !== null; + const message = hasApiKey + ? `${config.brandName} failed with a configuration or credential error.` + : `${config.brandName} failed with a configuration or credential error. No API key is configured.`; + const detail = hasApiKey + ? `Check ${config.apiKeyEnvKey} and related settings in Settings, then retry.` + : `Set ${config.apiKeyEnvKey} in Settings so the spawned ${config.brandName} process can authenticate, then retry.`; + return withContext(message, detail, input, config); + } const message = hasConfigDir - ? 'Claude Code failed while using the configured Claude profile.' - : 'Claude Code may be using a different or stale local profile than your terminal.'; + ? `${config.brandName} failed while using the configured ${config.profileLabel} profile.` + : `${config.brandName} may be using a different or stale local profile than your terminal.`; const detail = hasConfigDir - ? 'Re-run `claude` and `/login` for that profile, then retry Open Design.' - : 'Run `claude` and `/login`, or set CLAUDE_CONFIG_DIR in Settings when you use multiple Claude profiles.'; - return withContext(message, detail, input); + ? `Re-run ${config.runAndLogin} for that profile, then retry Open Design.` + : `Run ${config.runAndLogin}, or set ${config.configDirEnvKey} in Settings when you use multiple ${config.profileLabel} profiles.`; + return withContext(message, detail, input, config); } if (!text.trim() && input.exitCode === 1 && hasCustomBaseUrl) { + if (config.apiKeyIsPrimaryAuth) { + const hasApiKey = envValue(input.env, config.apiKeyEnvKey) !== null; + const message = hasApiKey + ? `${config.brandName} exited before producing diagnostics while using a custom ${config.endpointLabel} endpoint.` + : `${config.brandName} exited before producing diagnostics while using a custom ${config.endpointLabel} endpoint. No API key is configured.`; + const detail = hasApiKey + ? `Check ${config.apiKeyEnvKey}, ${config.baseUrlEnvKey}, proxy credentials, and model access in Settings.` + : `Set ${config.apiKeyEnvKey} in Settings so the spawned ${config.brandName} process can authenticate against the custom endpoint, then retry.`; + return withContext(message, detail, input, config); + } return withContext( - 'Claude Code exited before producing diagnostics while using a custom Anthropic endpoint.', - 'Check ANTHROPIC_BASE_URL, proxy credentials, endpoint authentication environment, and model access. Remove the custom endpoint only if you want to retry with standard Claude Code auth.', + `${config.brandName} exited before producing diagnostics while using a custom ${config.endpointLabel} endpoint.`, + `Check ${config.baseUrlEnvKey}, proxy credentials, endpoint authentication environment, and model access. Remove the custom endpoint only if you want to retry with standard ${config.brandName} auth.`, input, + config, ); } @@ -173,28 +297,49 @@ export function diagnoseClaudeCliFailure( 'OpenClaude exited before producing diagnostics.', 'Check the OpenClaude API key, endpoint, and local configuration, then retry.', input, + config, ); } + if (config.apiKeyIsPrimaryAuth) { + const hasApiKey = envValue(input.env, config.apiKeyEnvKey) !== null; + const message = hasApiKey + ? `${config.brandName} exited before producing diagnostics.` + : `${config.brandName} exited before producing diagnostics. No API key is configured.`; + const detail = hasApiKey + ? `Check ${config.apiKeyEnvKey} and related settings in Settings, then retry.` + : `Set ${config.apiKeyEnvKey} in Settings so the spawned ${config.brandName} process can authenticate, then retry.`; + return withContext(message, detail, input, config); + } const message = hasConfigDir - ? 'Claude Code exited before producing diagnostics while using the configured Claude profile.' - : 'Claude Code exited before producing diagnostics.'; + ? `${config.brandName} exited before producing diagnostics while using the configured ${config.profileLabel} profile.` + : `${config.brandName} exited before producing diagnostics.`; const detail = hasConfigDir - ? 'Re-run `claude` and `/login` for that profile, then retry Open Design.' - : 'Run `claude`, use `/login`, and retry. If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design uses the same profile as your terminal.'; + ? `Re-run ${config.runAndLogin} for that profile, then retry Open Design.` + : `Run ${config.runAndUseLogin}, and retry. If you use multiple ${config.profileLabel} profiles, set ${config.configDirEnvKey} in Settings so Open Design uses the same profile as your terminal.`; return withContext( message, detail, input, + config, ); } - if (normalized.includes('anthropic_base_url') && hasCustomBaseUrl) { + if (normalized.includes(config.baseUrlEnvKey.toLowerCase()) && hasCustomBaseUrl) { return withContext( - 'Claude Code failed while using a custom Anthropic endpoint.', - 'Check the ANTHROPIC_BASE_URL endpoint, proxy, model access, and authentication settings, then retry.', + `${config.brandName} failed while using a custom ${config.endpointLabel} endpoint.`, + `Check the ${config.baseUrlEnvKey} endpoint, proxy, model access, and authentication settings, then retry.`, input, + config, ); } return null; } + +export function diagnoseClaudeCliFailure( + input: ClaudeCliDiagnosticInput, +): ClaudeCliDiagnostic | null { + const config = AGENT_DIAGNOSTIC_CONFIGS.get(input.agentId); + if (!config) return null; + return diagnoseCliFailure(input, config); +} diff --git a/apps/daemon/src/media-routes.ts b/apps/daemon/src/media-routes.ts index 20d909443..b96869d4f 100644 --- a/apps/daemon/src/media-routes.ts +++ b/apps/daemon/src/media-routes.ts @@ -4,6 +4,7 @@ import { defaultMediaExecutionPolicy, mediaPolicyDenial } from './media-policy.j import type { RouteDeps } from './server-context.js'; import { proxyDispatcherRequestInit } from './connectionTest.js'; import type { ToolTokenGrant } from './tool-tokens.js'; +import { AppConfigValidationError } from './app-config.js'; const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000; @@ -226,11 +227,19 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) if (!isLocalSameOrigin(req, getResolvedPort())) { return res.status(403).json({ error: 'cross-origin request rejected' }); } + if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) { + return res.status(400).json({ error: 'request body must be a JSON object' }); + } try { const config = await writeAppConfig(RUNTIME_DATA_DIR, req.body); orbitService.configure(config.orbit); res.json({ config }); } catch (err: any) { + if (err instanceof AppConfigValidationError) { + return res + .status(400) + .json({ error: err.message }); + } res .status(500) .json({ error: String(err && err.message ? err.message : err) }); diff --git a/apps/daemon/src/runtimes/defs/codebuddy.ts b/apps/daemon/src/runtimes/defs/codebuddy.ts new file mode 100644 index 000000000..b0717ba6e --- /dev/null +++ b/apps/daemon/src/runtimes/defs/codebuddy.ts @@ -0,0 +1,44 @@ +import { agentCapabilities } from '../capabilities.js'; +import { DEFAULT_MODEL_OPTION } from '../models.js'; +import type { RuntimeAgentDef } from '../types.js'; + +export const codebuddyAgentDef = { + id: 'codebuddy', + name: 'CodeBuddy Code', + bin: 'codebuddy', + versionArgs: ['--version'], + helpArgs: ['-p', '--help'], + capabilityFlags: { + '--include-partial-messages': 'partialMessages', + '--add-dir': 'addDir', + }, + // CodeBuddy Code does not expose a models subcommand; ship placeholder + // ids as hints. Users can supply other ids via the custom-model input. + fallbackModels: [ + DEFAULT_MODEL_OPTION, + ], + buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}) => { + const caps = agentCapabilities.get('codebuddy') || {}; + const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']; + if (caps.partialMessages) { + args.push('--include-partial-messages'); + } + if (options.model && options.model !== 'default') { + args.push('--model', options.model); + } + const dirs = (extraAllowedDirs || []).filter( + (d) => typeof d === 'string' && d.length > 0, + ); + if (dirs.length > 0 && caps.addDir === true) { + args.push('--add-dir', ...dirs); + } + args.push('--permission-mode', 'bypassPermissions', '-y'); + return args; + }, + // CodeBuddy Code auto-loads `.mcp.json` from the project cwd at spawn, + // same as Claude Code. + externalMcpInjection: 'claude-mcp-json', + promptViaStdin: true, + promptInputFormat: 'stream-json', + streamFormat: 'claude-stream-json', +} satisfies RuntimeAgentDef; diff --git a/apps/daemon/src/runtimes/detection.ts b/apps/daemon/src/runtimes/detection.ts index 4ee50853e..33dbc4905 100644 --- a/apps/daemon/src/runtimes/detection.ts +++ b/apps/daemon/src/runtimes/detection.ts @@ -2,7 +2,7 @@ import { execAgentFile } from './invocation.js'; import { AGENT_DEFS } from './registry.js'; import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js'; import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js'; -import { spawnEnvForAgent } from './env.js'; +import { spawnEnvForAgent, AgentEnvConfigError } from './env.js'; import { probeAgentAuthStatus } from './auth.js'; import { agentCapabilities } from './capabilities.js'; import { installMetaForAgent } from './metadata.js'; @@ -123,6 +123,7 @@ function unavailableAgent(def: RuntimeAgentDef): DetectedAgent { models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION], modelsSource: 'fallback', available: false, + unavailableReason: 'not_installed', ...installMetaForAgent(def.id), }; } @@ -229,14 +230,19 @@ async function safeProbe( ): Promise { try { return await probe(def, configuredEnv); - } catch { - // Fault isolation (issue #2297): one adapter's probe blowing up - // — e.g. a synchronous filesystem throw during PATH walking on a - // packaged Windows daemon, or an async rejection from one of the - // post-launch probes — must not collapse the whole agent picker. - // Without this guard the bare `Promise.all` rejected and the - // `/api/agents` catch arm returned `[]`, so the UI silently lost - // every CLI option and fell back to BYOK / Cloud only. + } catch (err) { + // Fault isolation (issue #2297): one adapter's probe blowing up must + // not collapse the whole agent picker. Config/env validation errors + // get a distinct unavailableReason so the UI can offer a Configure + // action; all other unexpected errors degrade just that adapter to + // unavailable while the rest of the registry keeps its real result. + if (err instanceof AgentEnvConfigError) { + return { + ...unavailableAgent(def), + unavailableReason: 'config_error', + authMessage: err.message, + }; + } return unavailableAgent(def); } } diff --git a/apps/daemon/src/runtimes/env.ts b/apps/daemon/src/runtimes/env.ts index 897f4f08b..07a68368d 100644 --- a/apps/daemon/src/runtimes/env.ts +++ b/apps/daemon/src/runtimes/env.ts @@ -23,6 +23,57 @@ const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule( path.dirname(fileURLToPath(import.meta.url)), ); +// Valid values for CODEBUDDY_INTERNET_ENVIRONMENT (closed enum per IAM docs). +// Must stay in sync with AGENT_CLI_ENV_ENUMS in app-config.ts. +const CODEBUDDY_INTERNET_ENV_ALLOWED = new Set(['internal', 'ioa']); + +// CodeBuddy env keys that need case-insensitive canonicalization on Windows. +const CODEBUDDY_CANONICAL_KEYS = [ + 'CODEBUDDY_API_KEY', + 'CODEBUDDY_BASE_URL', + 'CODEBUDDY_CONFIG_DIR', + 'CODEBUDDY_BIN', + 'CODEBUDDY_INTERNET_ENVIRONMENT', +] as const; + +/** Typed error for invalid agent env/config — caught by detection.ts + * to surface a per-agent "unavailable" result without crashing other agents. + * Unexpected probe bugs (not env/config errors) should still fail fast. */ +export class AgentEnvConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentEnvConfigError'; + } +} + +// Remove case-insensitive aliases for a set of canonical env key names. +// On Windows, env key names are case-insensitive at the OS level but +// Node's process.env preserves original casing. A merged env can contain +// both an inherited alias and a configured canonical key. We must remove +// the alias and let the configured value (merged last by expandConfiguredEnv) +// win. If only a non-canonical alias exists, adopt its value into the +// canonical key. +function canonicalizeEnvKeys( + env: NodeJS.ProcessEnv, + canonicalKeys: readonly string[], +): void { + for (const canonical of canonicalKeys) { + const upper = canonical.toUpperCase(); + const aliases: string[] = []; + for (const key of Object.keys(env)) { + if (key.toUpperCase() === upper && key !== canonical) { + aliases.push(key); + } + } + for (const alias of aliases) { + if (!(canonical in env) && typeof env[alias] === 'string') { + env[canonical] = env[alias]; + } + delete env[alias]; + } + } +} + // Build the env passed to spawn() for a given agent adapter. // // The claude adapter strips ANTHROPIC_API_KEY so Claude Code's own auth @@ -31,6 +82,12 @@ const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule( // launched from a shell that exported the key for SDK or scripting use. // See issue #398. // +// The codebuddy adapter does NOT strip CODEBUDDY_API_KEY. Unlike Claude Code, +// where /login OAuth is the primary auth path and API-key billing is a +// fallback, CodeBuddy's `-p` (non-interactive) mode always authenticates via +// CODEBUDDY_API_KEY per the CLI docs. Stripping it would break every headless +// CodeBuddy run. See https://www.codebuddy.cn/docs/cli/env-vars. +// // However, when ANTHROPIC_BASE_URL is set the user is intentionally // routing Claude Code to a custom endpoint (e.g. a Kimi/Moonshot proxy). // In that case claude login is meaningless, so preserve the API key so @@ -91,6 +148,76 @@ export function spawnEnvForAgent( ]); return reapplySandboxRuntimeEnv(env, sandboxRuntime); } + // CodeBuddy's `-p` mode requires CODEBUDDY_API_KEY for authentication. + // Do not strip it — the key is the primary auth path, not a fallback. + // See https://www.codebuddy.cn/docs/cli/env-vars. + // + // CODEBUDDY_INTERNET_ENVIRONMENT is a closed enum (internal/ioa; + // empty or unset = international/default). Per the CLI docs, only + // `internal` and `ioa` are documented values; the international/default + // path is represented by leaving the variable unset, not by setting it + // to "public". + // + // When the user has not configured a value in Settings (fresh install, + // no codebuddy section in agentCliEnv), we preserve any inherited value + // from the parent process (e.g. + // CODEBUDDY_INTERNET_ENVIRONMENT=internal pnpm tools-dev + // ) so that China/iOA installs launched with the env var on the command + // line continue to work without requiring Settings configuration. + // + // When the user explicitly selects "International (default)" in Settings, + // validateAgentCliEnv persists an empty string as an "unset" marker. + // That marker reaches the merged env here and overrides any inherited + // value; we delete the key so the child process uses the international + // default (variable unset). This ensures users CAN switch away from an + // inherited non-default value through the Settings UI. + // + // Inherited values outside the closed enum (e.g. a typo like + // "internel") are treated as a hard error so the bad configuration is + // surfaced immediately instead of silently sending traffic to the wrong + // network region. + // Canonicalize CODEBUDDY_INTERNET_ENVIRONMENT: on Windows, env key names + // are case-insensitive at the OS level but Node's process.env preserves + // the original casing. A merged env can contain both an inherited alias + // like `Codebuddy_Internet_Environment=internel` and the configured + // override `CODEBUDDY_INTERNET_ENVIRONMENT=internal`. We must: + // 1. Remove all case-insensitive duplicates. + // 2. Let the configured (expandConfiguredEnv) value win over inherited. + // 3. Validate the single canonical key's value. + // This mirrors the ANTHROPIC_API_KEY case-insensitive cleanup above. + if (agentId === 'codebuddy') { + // Canonicalize all CodeBuddy env keys to remove Windows case-insensitive + // aliases, then validate the closed-enum INTERNET_ENVIRONMENT value. + // On Windows, env key names are case-insensitive at the OS level but + // Node's process.env preserves original casing — a merged env can + // contain both Codebuddy_Api_Key and CODEBUDDY_API_KEY. On POSIX, + // env names are case-sensitive: codebuddy_bin and CODEBUDDY_BIN are + // unrelated variables, so canonicalization must not run there. + if (process.platform === 'win32') { + canonicalizeEnvKeys(env, CODEBUDDY_CANONICAL_KEYS); + } + + const CANONICAL = 'CODEBUDDY_INTERNET_ENVIRONMENT'; + const value = env[CANONICAL]; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) { + if (!CODEBUDDY_INTERNET_ENV_ALLOWED.has(trimmed)) { + throw new AgentEnvConfigError( + `[env] Invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT="${value}".` + + ` Valid values: ${[...CODEBUDDY_INTERNET_ENV_ALLOWED].join(', ')}.`, + ); + } + env[CANONICAL] = trimmed; + } else { + // Empty string = "International (default)" explicitly selected in + // Settings. Delete the key so the child process uses the + // international default (variable unset). This overrides any + // inherited non-default value from the parent process. + delete env[CANONICAL]; + } + } + } return reapplySandboxRuntimeEnv(env, sandboxRuntime); } diff --git a/apps/daemon/src/runtimes/executables.ts b/apps/daemon/src/runtimes/executables.ts index ce0135a64..b62c4d195 100644 --- a/apps/daemon/src/runtimes/executables.ts +++ b/apps/daemon/src/runtimes/executables.ts @@ -17,6 +17,7 @@ const AGENT_BIN_ENV_KEYS = new Map([ ['amr', 'VELA_BIN'], ['aider', 'AIDER_BIN'], ['claude', 'CLAUDE_BIN'], + ['codebuddy', 'CODEBUDDY_BIN'], ['codex', 'CODEX_BIN'], ['copilot', 'COPILOT_BIN'], ['cursor-agent', 'CURSOR_AGENT_BIN'], diff --git a/apps/daemon/src/runtimes/metadata.ts b/apps/daemon/src/runtimes/metadata.ts index bc6d16fe9..3f548d49f 100644 --- a/apps/daemon/src/runtimes/metadata.ts +++ b/apps/daemon/src/runtimes/metadata.ts @@ -11,6 +11,10 @@ const AGENT_INSTALL_LINKS: Record< installUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup', docsUrl: 'https://docs.anthropic.com/en/docs/claude-code', }, + codebuddy: { + installUrl: 'https://www.codebuddy.cn/docs/cli/overview', + docsUrl: 'https://www.codebuddy.cn/docs/cli/overview', + }, codex: { installUrl: 'https://github.com/openai/codex', docsUrl: 'https://developers.openai.com/codex', diff --git a/apps/daemon/src/runtimes/registry.ts b/apps/daemon/src/runtimes/registry.ts index 484062956..610570d3f 100644 --- a/apps/daemon/src/runtimes/registry.ts +++ b/apps/daemon/src/runtimes/registry.ts @@ -1,5 +1,6 @@ import { amrAgentDef } from './defs/amr.js'; import { claudeAgentDef } from './defs/claude.js'; +import { codebuddyAgentDef } from './defs/codebuddy.js'; import { codexAgentDef } from './defs/codex.js'; import { devinAgentDef } from './defs/devin.js'; import { geminiAgentDef } from './defs/gemini.js'; @@ -46,6 +47,7 @@ const BASE_AGENT_DEFS: RuntimeAgentDef[] = [ aiderAgentDef, antigravityAgentDef, reasonixAgentDef, + codebuddyAgentDef, ]; export function readLocalAgentProfileDefs( diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index 48eb927bb..9b5ed4156 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -182,6 +182,7 @@ export type DetectedAgent = Omit< available: boolean; authStatus?: 'ok' | 'missing' | 'unknown'; authMessage?: string; + unavailableReason?: 'not_installed' | 'config_error'; path?: string; version?: string | null; }; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6ea861454..0529dc3ed 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -9832,21 +9832,6 @@ export async function startServer({ } }); - app.put('/api/app-config', async (req, res) => { - if (!isLocalSameOrigin(req, resolvedPort)) { - return res.status(403).json({ error: 'cross-origin request rejected' }); - } - try { - const config = await writeAppConfig(RUNTIME_DATA_DIR, req.body); - orbitService.configure(config.orbit); - res.json({ config }); - } catch (err) { - res - .status(500) - .json({ error: String(err && err.message ? err.message : err) }); - } - }); - app.get('/api/orbit/status', async (req, res) => { if (!isLocalSameOrigin(req, resolvedPort)) { return res.status(403).json({ error: 'cross-origin request rejected' }); diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index 2e8f20478..b543ea273 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -11,9 +11,10 @@ import { describe, expect, it, + vi, } from 'vitest'; -import { readAppConfig, writeAppConfig } from '../src/app-config.js'; +import { AppConfigValidationError, readAppConfig, writeAppConfig } from '../src/app-config.js'; import { isLocalSameOrigin } from '../src/origin-validation.js'; // Default telemetry preference applied when an existing config has no @@ -305,7 +306,7 @@ describe('app-config', () => { expect(cfg.agentModels).toBeUndefined(); }); - it('persists supported per-agent CLI env keys and drops everything else', async () => { + it('persists supported per-agent CLI env keys and rejects unknown keys', async () => { await writeAppConfig(dataDir, { agentCliEnv: { claude: { @@ -321,14 +322,10 @@ describe('app-config', () => { VELA_BIN: '~/bin/vela', OPEN_DESIGN_AMR_PROFILE: ' local ', OPENCODE_TEST_HOME: ' ~/.open-design-amr-opencode ', - HOME: 'should-not-persist', }, 'trae-cli': { TRAE_CLI_BIN: ' ~/bin/traecli-public ', }, - gemini: { - GEMINI_API_KEY: 'should-not-persist', - }, __proto__: { CLAUDE_CONFIG_DIR: 'bad', }, @@ -349,25 +346,285 @@ describe('app-config', () => { }); }); - it('drops agentCliEnv entries that collide with Object.prototype keys', async () => { + it('rejects agentCliEnv entries that collide with Object.prototype keys', async () => { + // `toString` and `hasOwnProperty` are not valid agent ids, so the + // strict write path should reject the entire payload rather than + // silently dropping the prototype-polluting entries. + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + toString: { + CODEX_HOME: '~/.codex-prototype', + }, + hasOwnProperty: { + CLAUDE_CONFIG_DIR: '~/.claude-prototype', + }, + claude: { + CLAUDE_CONFIG_DIR: '~/.claude-2', + }, + }, + }), + ).rejects.toThrow(/unknown agent/); + }); + + it('rejects __proto__ and constructor agent ids on write without clearing prior config', async () => { await writeAppConfig(dataDir, { agentCliEnv: { - toString: { - CODEX_HOME: '~/.codex-prototype', - }, - hasOwnProperty: { - CLAUDE_CONFIG_DIR: '~/.claude-prototype', - }, - claude: { - CLAUDE_CONFIG_DIR: '~/.claude-2', - }, + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, }, }); - const cfg = await readAppConfig(dataDir); + // JSON.parse preserves __proto__ as an own enumerable key, while the + // object literal `{ __proto__: ... }` sets the prototype instead. + // Express's body parser uses JSON.parse, so we mirror that here. + const protoPayload = JSON.parse( + '{"agentCliEnv":{"__proto__":{"CODEBUDDY_API_KEY":"x"}}}', + ); + await expect( + writeAppConfig(dataDir, protoPayload), + ).rejects.toThrow(/reserved agent id "__proto__"/); + const cfg1 = await readAppConfig(dataDir); + expect(cfg1.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + const ctorPayload = JSON.parse( + '{"agentCliEnv":{"constructor":{"CODEBUDDY_API_KEY":"x"}}}', + ); + await expect( + writeAppConfig(dataDir, ctorPayload), + ).rejects.toThrow(/reserved agent id "constructor"/); + const cfg2 = await readAppConfig(dataDir); + expect(cfg2.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('rejects CODEBUDDY_INTERNET_ENVIRONMENT with invalid enum value on write', async () => { + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 'internel', + CODEBUDDY_API_KEY: 'cb-test-key', + }, + }, + }), + ).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT/); + }); + + it('rejects non-string CODEBUDDY_INTERNET_ENVIRONMENT on write without clearing prior agentCliEnv', async () => { + // Save a valid agentCliEnv first. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }, + }); + // A non-string value (e.g. 42) for an allowlisted key should throw + // on the strict write path, not silently drop and clear the block. + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 42, + }, + }, + }), + ).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT.*expected string/); + // Previously saved config should remain intact. + const cfg = await readAppConfig(dataDir); expect(cfg.agentCliEnv).toEqual({ - claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' }, + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('rejects non-object agent env block on write without clearing prior agentCliEnv', async () => { + // A string or array value for an allowlisted agent should throw + // on the strict write path, not silently drop and clear the block. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }, + }); + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: 'bad', + }, + }), + ).rejects.toThrow(/codebuddy.*expected object/); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('rejects non-object top-level agentCliEnv on write without clearing prior config', async () => { + // A primitive or array top-level agentCliEnv should throw on the + // strict write path, not silently drop and clear the block. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }, + }); + await expect( + writeAppConfig(dataDir, { agentCliEnv: 'bad' }), + ).rejects.toThrow(/agentCliEnv.*expected object/); + const cfg1 = await readAppConfig(dataDir); + expect(cfg1.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + // Same for array top-level. + await expect( + writeAppConfig(dataDir, { agentCliEnv: [] }), + ).rejects.toThrow(/agentCliEnv.*expected object/); + const cfg2 = await readAppConfig(dataDir); + expect(cfg2.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('tolerates persisted invalid CODEBUDDY_INTERNET_ENVIRONMENT on read', async () => { + // Manually write a config file with an invalid enum value (simulating + // a stale or hand-edited config). readAppConfig should not throw; + // instead it should drop the invalid value and keep the rest. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 'internel', + CODEBUDDY_API_KEY: 'cb-test-key', + }, + }, + }), + ); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('internel'), + ); + warnSpy.mockRestore(); + }); + + it('persists valid CODEBUDDY_INTERNET_ENVIRONMENT enum values', async () => { + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', + }, + }, + }); + let cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internal' }, + }); + + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa', + }, + }, + }); + cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' }, + }); + }); + + it('rejects CODEBUDDY_INTERNET_ENVIRONMENT=public on write (undocumented value)', async () => { + // "public" is not a documented CLI value — the international/default + // path is represented by leaving the variable unset (empty string). + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: 'public', + }, + }, + }), + ).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT/); + }); + + it('persists empty-string CODEBUDDY_INTERNET_ENVIRONMENT as explicit unset marker', async () => { + // When the user selects "International (default)" in Settings, the UI + // persists an empty string as an "unset" marker. This must survive + // the write path so spawnEnvForAgent can detect it and delete the + // inherited value. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: '', + CODEBUDDY_API_KEY: 'cb-test-key', + }, + }, + }); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: '', CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('persists empty-string CODEBUDDY_INTERNET_ENVIRONMENT without other keys', async () => { + // The empty-string marker should be persisted even when it's the only + // value for the codebuddy agent (no API key alongside it). + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { + CODEBUDDY_INTERNET_ENVIRONMENT: '', + }, + }, + }); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: '' }, + }); + }); + + it('rejects unknown agent id on write without clearing prior agentCliEnv', async () => { + // A typoed agent id like "codebudy" should throw on the strict write + // path, not silently drop and clear the existing agentCliEnv block. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }, + }); + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebudy: { CODEBUDDY_API_KEY: 'x' }, + }, + }), + ).rejects.toThrow(/unknown agent "codebudy"/); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + }); + + it('rejects unknown env key on write without clearing prior agentCliEnv', async () => { + // A typoed env key like "CODEBUDDY_APIKYE" should throw on the strict + // write path, not silently drop and clear the existing agentCliEnv block. + await writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }, + }); + await expect( + writeAppConfig(dataDir, { + agentCliEnv: { + codebuddy: { CODEBUDDY_APIKYE: 'x' }, + }, + }), + ).rejects.toThrow(/CODEBUDDY_APIKYE.*unknown env key/); + const cfg = await readAppConfig(dataDir); + expect(cfg.agentCliEnv).toEqual({ + codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' }, }); }); @@ -623,6 +880,45 @@ describe('app-config telemetry prefs', () => { }); }); +describe('app-config validation error type', () => { + it('invalid agentCliEnv writes throw AppConfigValidationError (not plain Error)', async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), 'od-valtype-')); + try { + // Save valid config first. + await writeAppConfig(dataDir, { + agentCliEnv: { codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' } }, + }); + // A bad top-level shape should throw AppConfigValidationError. + try { + await writeAppConfig(dataDir, { agentCliEnv: 'bad' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(AppConfigValidationError); + expect((err as Error).message).toContain('agentCliEnv'); + } + // An invalid enum value should also throw AppConfigValidationError. + try { + await writeAppConfig(dataDir, { + agentCliEnv: { codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internel' } }, + }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(AppConfigValidationError); + } + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('AppConfigValidationError instances are distinguishable from generic errors', () => { + const valErr = new AppConfigValidationError('test'); + expect(valErr.name).toBe('AppConfigValidationError'); + expect(valErr).toBeInstanceOf(Error); + // A plain Error should NOT match. + expect(new Error('test')).not.toBeInstanceOf(AppConfigValidationError); + }); +}); + describe('app-config projectLocations', () => { let dataDir: string; diff --git a/apps/daemon/tests/claude-diagnostics.test.ts b/apps/daemon/tests/claude-diagnostics.test.ts index 1c4650f56..331d4a02e 100644 --- a/apps/daemon/tests/claude-diagnostics.test.ts +++ b/apps/daemon/tests/claude-diagnostics.test.ts @@ -64,11 +64,48 @@ describe('diagnoseClaudeCliFailure', () => { expect(diagnostic?.message).toContain('could not reach'); expect(diagnostic?.detail).toContain('ANTHROPIC_BASE_URL'); - expect(diagnostic?.detail).toContain('refused the connection'); + expect(diagnostic?.detail).toContain('could not be reached'); expect(diagnostic?.detail).not.toContain('could not authenticate'); expect(diagnostic?.detail).not.toContain('use `/login`'); }); + it('maps custom endpoint DNS failures (ENOTFOUND) to endpoint guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'claude', + exitCode: 1, + stderrTail: 'Error: getaddrinfo ENOTFOUND proxy.internal.local', + env: { ANTHROPIC_BASE_URL: 'https://proxy.internal.local' }, + }); + + expect(diagnostic?.message).toContain('could not reach'); + expect(diagnostic?.detail).toContain('ANTHROPIC_BASE_URL'); + expect(diagnostic?.detail).not.toContain('could not authenticate'); + }); + + it('maps custom endpoint timeout (ETIMEDOUT) to endpoint guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'claude', + exitCode: 1, + stderrTail: 'Error: connect ETIMEDOUT 10.0.0.1:443', + env: { ANTHROPIC_BASE_URL: 'https://slow-proxy.example.com' }, + }); + + expect(diagnostic?.message).toContain('could not reach'); + expect(diagnostic?.detail).toContain('ANTHROPIC_BASE_URL'); + }); + + it('maps CodeBuddy custom endpoint TLS errors to endpoint guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: 'Error: unable to verify the first certificate', + env: { CODEBUDDY_BASE_URL: 'https://self-signed.example.com' }, + }); + + expect(diagnostic?.message).toContain('could not reach'); + expect(diagnostic?.detail).toContain('CODEBUDDY_BASE_URL'); + }); + it('maps silent custom endpoint exits to endpoint guidance', () => { const diagnostic = diagnoseClaudeCliFailure({ agentId: 'claude', @@ -109,7 +146,202 @@ describe('diagnoseClaudeCliFailure', () => { expect(diagnostic?.detail).toContain('Effective CLAUDE_CONFIG_DIR: /tmp/claude-alt'); }); - it('does not classify unrelated non-Claude failures', () => { + it('maps CodeBuddy auth failures without API key to API key setup guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: {}, + }); + + expect(diagnostic?.message).toContain('CodeBuddy Code'); + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.message).toContain('No API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy custom endpoint auth failures without API key to setup guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_BASE_URL: 'https://proxy.example.com' }, + }); + + expect(diagnostic?.message).toContain('custom CodeBuddy endpoint'); + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).toContain('CODEBUDDY_BASE_URL'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy custom endpoint auth failures with API key to key-check guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_BASE_URL: 'https://proxy.example.com', CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.message).toContain('custom CodeBuddy endpoint'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).toContain('CODEBUDDY_BASE_URL'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy silent configured-profile exits to API key guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '', + stdoutTail: '', + env: { CODEBUDDY_CONFIG_DIR: '/tmp/codebuddy-alt' }, + }); + + expect(diagnostic?.message).toContain('CodeBuddy'); + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy auth failures with API key set to API key guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.message).toContain('CodeBuddy'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy auth failures with API key and config dir to combined guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { + CODEBUDDY_API_KEY: 'cb-test-key', + CODEBUDDY_CONFIG_DIR: '/tmp/codebuddy-alt', + }, + }); + + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).toContain('CODEBUDDY_CONFIG_DIR'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy config state failures without API key to API key setup guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: 'OAuth credential expired for session', + env: {}, + }); + + expect(diagnostic?.message).toContain('CodeBuddy Code'); + expect(diagnostic?.message).toContain('API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy config-state failure with API key to key-check guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: 'OAuth credential expired for session', + env: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.message).toContain('CodeBuddy'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy silent exit with API key to key-check guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '', + stdoutTail: '', + env: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.message).toContain('CodeBuddy'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('reports effective CODEBUDDY_INTERNET_ENVIRONMENT in CodeBuddy diagnostic context', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internal' }, + }); + + expect(diagnostic?.detail).toContain('CODEBUDDY_INTERNET_ENVIRONMENT=internal'); + }); + + it('reports effective CODEBUDDY_INTERNET_ENVIRONMENT with configured value', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' }, + }); + + expect(diagnostic?.detail).toContain('CODEBUDDY_INTERNET_ENVIRONMENT=ioa'); + }); + + it('does not report CODEBUDDY_INTERNET_ENVIRONMENT when unset', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '{"apiKeySource":"none","error_status":401}', + env: { CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.detail).not.toContain('CODEBUDDY_INTERNET_ENVIRONMENT'); + }); + + it('maps CodeBuddy silent custom-endpoint exit without API key to setup guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '', + stdoutTail: '', + env: { CODEBUDDY_BASE_URL: 'https://proxy.example.com' }, + }); + + expect(diagnostic?.message).toContain('custom CodeBuddy endpoint'); + expect(diagnostic?.message).toContain('No API key'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).toContain('CODEBUDDY_BASE_URL'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('maps CodeBuddy silent custom-endpoint exit with API key to key-check guidance', () => { + const diagnostic = diagnoseClaudeCliFailure({ + agentId: 'codebuddy', + exitCode: 1, + stderrTail: '', + stdoutTail: '', + env: { CODEBUDDY_BASE_URL: 'https://proxy.example.com', CODEBUDDY_API_KEY: 'cb-test-key' }, + }); + + expect(diagnostic?.message).toContain('custom CodeBuddy endpoint'); + expect(diagnostic?.detail).toContain('CODEBUDDY_API_KEY'); + expect(diagnostic?.detail).toContain('CODEBUDDY_BASE_URL'); + expect(diagnostic?.detail).not.toContain('/login'); + }); + + it('does not classify unrelated agent failures', () => { const diagnostic = diagnoseClaudeCliFailure({ agentId: 'codex', exitCode: 1, diff --git a/apps/daemon/tests/runtimes/agent-args.test.ts b/apps/daemon/tests/runtimes/agent-args.test.ts index 3021632e7..5ad3490ba 100644 --- a/apps/daemon/tests/runtimes/agent-args.test.ts +++ b/apps/daemon/tests/runtimes/agent-args.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { test } from 'vitest'; import { - AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync, + AGENT_DEFS, agentCapabilities, aider, antigravity, assert, claude, codebuddy, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync, } from './helpers/test-helpers.js'; import { writeAntigravityModelSelection } from '../../src/runtimes/defs/antigravity.js'; import type { TestAgentDef } from './helpers/test-helpers.js'; @@ -883,3 +883,49 @@ test('promptInputFormat is a string property (or undefined) on every promptViaSt ); } }); + +// ---- CodeBuddy Code --add-dir capability (positive-probe gate) ------- + +test('codebuddy buildArgs omits --add-dir when capability probe has not confirmed support', () => { + // Before any capability probe runs (or if probing failed), agentCapabilities + // has no entry for 'codebuddy'. buildArgs gets `caps = {}` -> + // caps.addDir is undefined -> undefined === true is false -> --add-dir + // is NOT added. This is the safe default: don't pass flags the CLI may + // not understand. + const args = codebuddy.buildArgs( + '', + [], + ['/repo/skills', '/repo/design-systems'], + {}, + ); + + assert.equal( + args.includes('--add-dir'), + false, + '--add-dir must NOT be present when capability probe has not confirmed support', + ); +}); + +test('codebuddy buildArgs includes --add-dir when capability probe confirms support', () => { + // Simulate a successful probe that found --add-dir in --help output. + const prev = agentCapabilities.get('codebuddy'); + agentCapabilities.set('codebuddy', { addDir: true, partialMessages: true }); + try { + const args = codebuddy.buildArgs( + '', + [], + ['/repo/skills'], + {}, + ); + + const addDirIndex = args.indexOf('--add-dir'); + assert.ok(addDirIndex >= 0, '--add-dir must be present when probe confirmed support'); + assert.equal(args[addDirIndex + 1], '/repo/skills'); + } finally { + if (prev) { + agentCapabilities.set('codebuddy', prev); + } else { + agentCapabilities.delete('codebuddy'); + } + } +}); diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index a41a8ed97..93d52a0bf 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -1,5 +1,5 @@ import { symlinkSync } from 'node:fs'; -import { test, vi } from 'vitest'; +import { test, vi, expect } from 'vitest'; import { homedir } from 'node:os'; import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -397,6 +397,7 @@ test('resolveAgentExecutable supports configured binary overrides for non-Codex ['deepseek', 'deepseek', 'DEEPSEEK_BIN'], ['trae-cli', 'traecli', 'TRAE_CLI_BIN'], ['aider', 'aider', 'AIDER_BIN'], + ['codebuddy', 'codebuddy', 'CODEBUDDY_BIN'], ]; const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-overrides-')); try { @@ -1183,3 +1184,348 @@ test('spawnEnvForAgent does not mutate the input env', () => { assert.equal(original.ANTHROPIC_API_KEY, 'sk-leak'); assert.notEqual(env, original); }); + +// CodeBuddy's `-p` mode authenticates via CODEBUDDY_API_KEY; it must not be +// stripped (unlike Claude, where /login OAuth is the primary auth path). +test('spawnEnvForAgent preserves CODEBUDDY_API_KEY for the codebuddy adapter', () => { + const env = spawnEnvForAgent('codebuddy', { + CODEBUDDY_API_KEY: 'cb-test-key', + PATH: '/usr/bin', + }); + + assert.equal(env.CODEBUDDY_API_KEY, 'cb-test-key'); + assert.equal(env.PATH, '/usr/bin'); +}); + +test('spawnEnvForAgent preserves CODEBUDDY_API_KEY with CODEBUDDY_BASE_URL set', () => { + const env = spawnEnvForAgent('codebuddy', { + CODEBUDDY_API_KEY: 'cb-test-key', + CODEBUDDY_BASE_URL: 'https://proxy.example.com', + PATH: '/usr/bin', + }); + + assert.equal(env.CODEBUDDY_API_KEY, 'cb-test-key'); + assert.equal(env.CODEBUDDY_BASE_URL, 'https://proxy.example.com'); +}); + +test('spawnEnvForAgent passes configured CODEBUDDY_INTERNET_ENVIRONMENT to the codebuddy adapter', () => { + const env = spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', PATH: '/usr/bin' }, + { CODEBUDDY_INTERNET_ENVIRONMENT: 'internal' }, + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'cb-test-key'); + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'internal'); +}); + +test('spawnEnvForAgent applies configured CodeBuddy env including INTERNET_ENVIRONMENT', () => { + const env = spawnEnvForAgent( + 'codebuddy', + { PATH: '/usr/bin' }, + { + CODEBUDDY_CONFIG_DIR: '/tmp/codebuddy-alt', + CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa', + }, + ); + + assert.equal(env.CODEBUDDY_CONFIG_DIR, '/tmp/codebuddy-alt'); + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'ioa'); + assert.equal(env.PATH, '/usr/bin'); +}); + +test('spawnEnvForAgent preserves inherited CODEBUDDY_INTERNET_ENVIRONMENT when not configured', () => { + // When the user selects "Inherit / unset" in Settings, no configured + // value is persisted. The inherited value from the parent process + // (e.g. CODEBUDDY_INTERNET_ENVIRONMENT=internal pnpm tools-dev) + // must survive so China/iOA installs continue to work. + const env = spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', PATH: '/usr/bin' }, + {}, + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'cb-test-key'); + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'internal'); +}); + +test('spawnEnvForAgent keeps CODEBUDDY_INTERNET_ENVIRONMENT when configured overrides inherited', () => { + const env = spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', PATH: '/usr/bin' }, + { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' }, + ); + + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'ioa'); +}); + +test('spawnEnvForAgent rejects configured CODEBUDDY_INTERNET_ENVIRONMENT=public', () => { + // "public" is not a documented CLI value — the international/default + // path is represented by leaving the variable unset or selecting + // "International (default)" in Settings (which persists an empty string). + expect(() => + spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', PATH: '/usr/bin' }, + { CODEBUDDY_INTERNET_ENVIRONMENT: 'public' }, + ), + ).toThrow(/Invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT/); +}); + +test('spawnEnvForAgent clears inherited CODEBUDDY_INTERNET_ENVIRONMENT when empty-string marker is configured', () => { + // When the user selects "International (default)" in Settings, the UI + // persists an empty string as an "unset" marker. This must override + // the inherited value so the child process runs without the env var. + const env = spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', PATH: '/usr/bin' }, + { CODEBUDDY_INTERNET_ENVIRONMENT: '' }, + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'cb-test-key'); + assert.equal('CODEBUDDY_INTERNET_ENVIRONMENT' in env, false); +}); + +test('spawnEnvForAgent throws on invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT', () => { + // A typo like "internel" should cause a hard error (AgentEnvConfigError) + // so the bad configuration is surfaced immediately instead of silently + // sending traffic to the wrong network region. + expect(() => + spawnEnvForAgent( + 'codebuddy', + { CODEBUDDY_API_KEY: 'cb-test-key', CODEBUDDY_INTERNET_ENVIRONMENT: 'internel', PATH: '/usr/bin' }, + {}, + ), + ).toThrow(/Invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT/); +}); + +test('spawnEnvForAgent canonicalizes mixed-case CODEBUDDY_INTERNET_ENVIRONMENT aliases on Windows', () => { + // On Windows, env key names are case-insensitive at the OS level but + // Node's process.env preserves original casing. A merged env can contain + // both an inherited alias and a configured canonical key. We must remove + // the alias, let the configured value win, and validate only once. + const env = withPlatform('win32', () => + spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internel', // inherited alias (bad) + CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', // configured override (good) + PATH: '/usr/bin', + }, + {}, + ), + ); + + // The alias must be removed; only the canonical key remains. + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'internal'); + const aliases = Object.keys(env).filter( + (k) => k.toUpperCase() === 'CODEBUDDY_INTERNET_ENVIRONMENT' && k !== 'CODEBUDDY_INTERNET_ENVIRONMENT', + ); + assert.deepEqual(aliases, []); +}); + +test('spawnEnvForAgent ignores mixed-case CODEBUDDY_INTERNET_ENVIRONMENT aliases on POSIX', () => { + // On POSIX, env names are case-sensitive: Codebuddy_Internet_Environment + // and CODEBUDDY_INTERNET_ENVIRONMENT are unrelated variables. Canonicalize + // must NOT run there, so a stray mixed-case alias should be left alone. + const env = spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internel', // unrelated POSIX variable + CODEBUDDY_INTERNET_ENVIRONMENT: 'internal', // the real env var + PATH: '/usr/bin', + }, + {}, + ); + + // Both keys should survive — POSIX is case-sensitive. + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'internal'); + assert.equal((env as Record).Codebuddy_Internet_Environment, 'internel'); +}); + +test('spawnEnvForAgent adopts non-canonical CODEBUDDY_INTERNET_ENVIRONMENT when canonical is absent on Windows', () => { + // When only a non-canonical alias exists (no configured override), the + // value should be adopted into the canonical key before validation. + const env = withPlatform('win32', () => + spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internal', // inherited alias (valid) + PATH: '/usr/bin', + }, + {}, + ), + ); + + assert.equal(env.CODEBUDDY_INTERNET_ENVIRONMENT, 'internal'); + const aliases = Object.keys(env).filter( + (k) => k.toUpperCase() === 'CODEBUDDY_INTERNET_ENVIRONMENT' && k !== 'CODEBUDDY_INTERNET_ENVIRONMENT', + ); + assert.deepEqual(aliases, []); +}); + +test('spawnEnvForAgent ignores non-canonical CODEBUDDY_INTERNET_ENVIRONMENT on POSIX', () => { + // On POSIX, mixed-case keys are unrelated variables. A stray + // Codebuddy_Internet_Environment should not be adopted. + const env = spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internal', // unrelated POSIX variable + PATH: '/usr/bin', + }, + {}, + ); + + // The non-canonical key must NOT be adopted on POSIX. + assert.equal('CODEBUDDY_INTERNET_ENVIRONMENT' in env, false); + assert.equal((env as Record).Codebuddy_Internet_Environment, 'internal'); +}); + +test('spawnEnvForAgent throws on invalid non-canonical CODEBUDDY_INTERNET_ENVIRONMENT without canonical on Windows', () => { + // When only a non-canonical alias exists with an invalid value, it should + // be adopted into the canonical key and then validated — throwing. + expect(() => + withPlatform('win32', () => + spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internel', // inherited alias (bad) + PATH: '/usr/bin', + }, + {}, + ), + ), + ).toThrow(/Invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT/); +}); + +test('spawnEnvForAgent ignores non-canonical invalid CODEBUDDY_INTERNET_ENVIRONMENT on POSIX', () => { + // On POSIX, Codebuddy_Internet_Environment is unrelated to CODEBUDDY_INTERNET_ENVIRONMENT. + // A stray mixed-case alias must not trigger validation. + expect(() => + spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Internet_Environment: 'internel', // unrelated POSIX variable + PATH: '/usr/bin', + }, + {}, + ), + ).not.toThrow(); +}); + +test('spawnEnvForAgent canonicalizes mixed-case CODEBUDDY_API_KEY alias with configured override on Windows', () => { + // On Windows, both an inherited alias and a configured canonical key can + // coexist in the merged env. The configured value must win and the alias + // must be removed. + const env = withPlatform('win32', () => + spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Api_Key: 'old-inherited-key', // inherited alias + CODEBUDDY_API_KEY: 'new-configured-key', // configured override + PATH: '/usr/bin', + }, + {}, + ), + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'new-configured-key'); + const aliases = Object.keys(env).filter( + (k) => k.toUpperCase() === 'CODEBUDDY_API_KEY' && k !== 'CODEBUDDY_API_KEY', + ); + assert.deepEqual(aliases, []); +}); + +test('spawnEnvForAgent ignores mixed-case CODEBUDDY_API_KEY alias on POSIX', () => { + // On POSIX, Codebuddy_Api_Key and CODEBUDDY_API_KEY are unrelated variables. + const env = spawnEnvForAgent( + 'codebuddy', + { + Codebuddy_Api_Key: 'old-inherited-key', // unrelated POSIX variable + CODEBUDDY_API_KEY: 'new-configured-key', // the real env var + PATH: '/usr/bin', + }, + {}, + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'new-configured-key'); + assert.equal((env as Record).Codebuddy_Api_Key, 'old-inherited-key'); +}); + +test('spawnEnvForAgent adopts non-canonical CODEBUDDY_API_KEY when canonical is absent on Windows', () => { + const env = withPlatform('win32', () => + spawnEnvForAgent( + 'codebuddy', + { + codebuddy_api_key: 'inherited-key', // lowercase alias + PATH: '/usr/bin', + }, + {}, + ), + ); + + assert.equal(env.CODEBUDDY_API_KEY, 'inherited-key'); + const aliases = Object.keys(env).filter( + (k) => k.toUpperCase() === 'CODEBUDDY_API_KEY' && k !== 'CODEBUDDY_API_KEY', + ); + assert.deepEqual(aliases, []); +}); + +test('spawnEnvForAgent ignores non-canonical CODEBUDDY_API_KEY on POSIX', () => { + // On POSIX, codebuddy_api_key is unrelated to CODEBUDDY_API_KEY. + const env = spawnEnvForAgent( + 'codebuddy', + { + codebuddy_api_key: 'inherited-key', // unrelated POSIX variable + PATH: '/usr/bin', + }, + {}, + ); + + assert.equal('CODEBUDDY_API_KEY' in env, false); + assert.equal((env as Record).codebuddy_api_key, 'inherited-key'); +}); + +test('detectAgents isolates CodeBuddy probe failure from other agents', async () => { + // When an invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT causes the + // CodeBuddy probe to throw an AgentEnvConfigError, other agents must + // still appear in the /api/agents response and the CodeBuddy entry + // should retain standard unavailable metadata (installUrl, docsUrl, etc.) + // plus the validation error as authMessage. + const dir = mkdtempSync(join(tmpdir(), 'od-isolated-probe-')); + try { + return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'CODEBUDDY_INTERNET_ENVIRONMENT'], async () => { + // Provide a claude binary so that agent is available. + const claudeBin = join(dir, 'claude'); + writeFileSync(claudeBin, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "1.0.0"; exit 0; fi\nif [ "$1" = "-p" ] && [ "$2" = "--help" ]; then echo "--add-dir --include-partial-messages"; exit 0; fi\nexit 0\n'); + chmodSync(claudeBin, 0o755); + // Provide a codebuddy binary so the probe reaches spawnEnvForAgent + // where the invalid env triggers AgentEnvConfigError. + const codebuddyBin = join(dir, 'codebuddy'); + writeFileSync(codebuddyBin, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "1.0.0"; exit 0; fi\nexit 0\n'); + chmodSync(codebuddyBin, 0o755); + process.env.PATH = dir; + process.env.OD_AGENT_HOME = dir; + process.env.CODEBUDDY_INTERNET_ENVIRONMENT = 'internel'; + + const agents = await detectAgents(); + + // Claude should still be available. + const claudeAgent = agents.find((a) => a.id === 'claude'); + assert.ok(claudeAgent); + assert.equal(claudeAgent.available, true); + + // CodeBuddy should be unavailable (the invalid env caused an AgentEnvConfigError). + const codebuddyAgent = agents.find((a) => a.id === 'codebuddy'); + assert.ok(codebuddyAgent); + assert.equal(codebuddyAgent.available, false); + // The fallback should include standard unavailable metadata. + assert.ok(codebuddyAgent.installUrl || codebuddyAgent.docsUrl, + 'CodeBuddy fallback should retain install/docs metadata from unavailableAgent()'); + // The validation error should be surfaced as authMessage. + assert.match(codebuddyAgent.authMessage ?? '', /Invalid inherited CODEBUDDY_INTERNET_ENVIRONMENT/); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/daemon/tests/runtimes/helpers/test-helpers.ts b/apps/daemon/tests/runtimes/helpers/test-helpers.ts index b9852a6db..8e8057211 100644 --- a/apps/daemon/tests/runtimes/helpers/test-helpers.ts +++ b/apps/daemon/tests/runtimes/helpers/test-helpers.ts @@ -22,9 +22,11 @@ import { resolveAgentExecutable, spawnEnvForAgent, } from '../../../src/agents.js'; +import { agentCapabilities } from '../../../src/runtimes/capabilities.js'; import type { RuntimeAgentDef } from '../../../src/runtimes/types.js'; export { + agentCapabilities, assert, AGENT_DEFS, applyAgentLaunchEnv, @@ -89,6 +91,7 @@ export const opencode = requireAgent('opencode'); export const grokBuild = requireAgent('grok-build'); export const aider = requireAgent('aider'); export const antigravity = requireAgent('antigravity'); +export const codebuddy = requireAgent('codebuddy'); export const deepseekMaxPromptArgBytes = (() => { assert.ok( deepseek.maxPromptArgBytes !== undefined, diff --git a/apps/daemon/tests/runtimes/registry-and-args.test.ts b/apps/daemon/tests/runtimes/registry-and-args.test.ts index 5849bb926..48fd48b34 100644 --- a/apps/daemon/tests/runtimes/registry-and-args.test.ts +++ b/apps/daemon/tests/runtimes/registry-and-args.test.ts @@ -11,6 +11,25 @@ test('AGENT_DEFS ids are unique', () => { assert.deepEqual(dupes, [], `duplicate agent ids: ${JSON.stringify(dupes)}`); }); +test('codebuddy appears after established adapters in AGENT_DEFS', () => { + // CodeBuddy is a new adapter; established adapters (claude, codex, gemini, + // etc.) must come first so that first-run auto-selection + // (agents.find(a => a.available)) prefers an auth-ready agent over an + // unauthenticated CodeBuddy install. + const ids = AGENT_DEFS.map((a) => a.id); + const codebuddyIndex = ids.indexOf('codebuddy'); + assert.ok(codebuddyIndex >= 0, 'codebuddy must be in AGENT_DEFS'); + for (const established of ['codex', 'gemini', 'opencode']) { + const establishedIndex = ids.indexOf(established); + if (establishedIndex >= 0) { + assert.ok( + establishedIndex < codebuddyIndex, + `${established} (index ${establishedIndex}) must come before codebuddy (index ${codebuddyIndex})`, + ); + } + } +}); + test('local agent profiles inherit a base adapter and can pin the default model', async () => { const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-')); try { diff --git a/apps/web/public/agent-icons/codebuddy.svg b/apps/web/public/agent-icons/codebuddy.svg new file mode 100644 index 000000000..2def11feb --- /dev/null +++ b/apps/web/public/agent-icons/codebuddy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f9d4c89b7..9d607b2d3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -524,7 +524,7 @@ function AppInner() { // Migrate localStorage prefs to daemon on first boot with the new // endpoint. If daemon already had values the merge above used them; // writing back is idempotent and keeps both sides in sync. - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { swallowWriteErrors: true }); void syncComposioConfigToDaemon(next.composio); latestPersistedConfigRef.current = next; setConfig(next); @@ -566,7 +566,7 @@ function AppInner() { if (prev.agentId) return prev; const next: AppConfig = { ...prev, agentId: firstAvailable.id }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); return next; }); }, [daemonConfigLoaded, agentsLoading, agents, config.agentId]); @@ -583,7 +583,7 @@ function AppInner() { if (prev.designSystemId) return prev; const next: AppConfig = { ...prev, designSystemId: id }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); return next; }); }, [daemonConfigLoaded, dsLoading, designSystems, config.designSystemId]); @@ -693,7 +693,7 @@ function AppInner() { ? syncMediaProvidersToDaemon(persisted.mediaProviders, { force: options?.forceMediaProviderSync, daemonProviders: daemonMediaProviders, - throwOnError: options?.forceMediaProviderSync, + throwOnError: true, }) : Promise.resolve(), syncConfigToDaemon(persisted), @@ -738,7 +738,7 @@ function AppInner() { (theme: AppConfig['theme']) => { const next = { ...config, theme }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -748,7 +748,7 @@ function AppInner() { (agentId: string) => { const next = { ...config, agentId }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -764,7 +764,7 @@ function AppInner() { }; const next = { ...config, agentModels: nextAgentModels }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -778,7 +778,7 @@ function AppInner() { (protocol: ApiProtocol) => { const next = switchApiProtocolConfig(config, protocol); saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -791,7 +791,7 @@ function AppInner() { (model: string) => { const next = updateCurrentApiProtocolConfig(config, { model }); saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -801,7 +801,7 @@ function AppInner() { (designSystemId: string | null) => { const next = { ...config, designSystemId }; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, [config], @@ -1287,7 +1287,7 @@ function AppInner() { const next: AppConfig = { ...current, onboardingCompleted: true }; latestPersistedConfigRef.current = next; saveConfig(next); - void syncConfigToDaemon(next); + void syncConfigToDaemon(next, { omitKeys: ['agentCliEnv'] }); setConfig(next); }, []); @@ -1600,21 +1600,27 @@ function AppInner() { composioConfigLoading={composioConfigLoading} onPersist={handleConfigPersist} onPersistComposioKey={handleConfigPersistComposioKey} - onClose={() => { + onClose={(latestDraft) => { // Closing the dialog is the canonical "I'm done" gesture // now that there is no global Save button. We mark // onboardingCompleted on close so the welcome modal stops // re-prompting on every refresh, regardless of whether // the user changed anything during the session. - const next = resolveSettingsCloseConfig(config, latestPersistedConfigRef.current); - if (!next.onboardingCompleted || !config.onboardingCompleted) { - latestPersistedConfigRef.current = next; - saveConfig(next); - void syncConfigToDaemon(next); - setConfig(next); - } - setSettingsOpen(false); - setSettingsHighlight(null); + // Use the dialog's latest debounced draft (if available) + // instead of the parent App snapshot, so in-progress + // edits like a CodeBuddy env field change are included + // in the close-path write. Merge onboardingCompleted + // directly instead of going through + // resolveSettingsCloseConfig, which falls back to a + // stale persisted snapshot when rendered !== latestPersisted. + const base = latestDraft ?? config; + const next = base.onboardingCompleted ? base : { ...base, onboardingCompleted: true }; + // Return the promise so SettingsDialog's handleClose can + // catch rejections and set autosaveStatus='error', giving + // the user a visible clue which field is invalid instead + // of a dead-looking dialog. + return handleConfigPersist(next) + .then(() => { setSettingsOpen(false); setSettingsHighlight(null); }); }} onRefreshAgents={refreshAgents} onSkillsRefresh={refreshSkills} diff --git a/apps/web/src/components/AgentIcon.tsx b/apps/web/src/components/AgentIcon.tsx index 6b80e0b0a..946514394 100644 --- a/apps/web/src/components/AgentIcon.tsx +++ b/apps/web/src/components/AgentIcon.tsx @@ -14,6 +14,7 @@ interface Props { const ICON_EXT: Record = { amr: 'svg', claude: 'svg', + codebuddy: 'svg', codex: 'svg', gemini: 'svg', opencode: 'svg', diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 3045b433b..1bcf27497 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -186,7 +186,7 @@ interface Props { * saved state with `''` before the daemon's response lands. */ composioConfigLoading?: boolean; - onClose: () => void; + onClose: (latestDraft?: AppConfig) => Promise | void; onRefreshAgents: ( options?: AgentRefreshOptions, ) => AgentInfo[] | Promise | void; @@ -497,6 +497,41 @@ const AGENT_CLI_ENV_FIELDS = [ placeholder: 'Paste proxy API key', secret: true, }, + { + agentId: 'codebuddy', + envKey: 'CODEBUDDY_CONFIG_DIR', + labelKey: 'settings.cliEnvCodebuddyConfigDir', + placeholder: '~/.codebuddy-2', + }, + { + agentId: 'codebuddy', + envKey: 'CODEBUDDY_BIN', + labelKey: 'settings.cliEnvCodebuddyBin', + placeholder: '/absolute/path/to/codebuddy', + }, + { + agentId: 'codebuddy', + envKey: 'CODEBUDDY_BASE_URL', + labelKey: 'settings.cliEnvCodebuddyBaseUrl', + placeholder: 'https://your-proxy.example.com', + }, + { + agentId: 'codebuddy', + envKey: 'CODEBUDDY_API_KEY', + labelKey: 'settings.cliEnvCodebuddyApiKey', + placeholder: 'Paste API key', + secret: true, + }, + { + agentId: 'codebuddy', + envKey: 'CODEBUDDY_INTERNET_ENVIRONMENT', + labelKey: 'settings.cliEnvCodebuddyInternetEnvironment', + options: [ + { value: '', labelKey: 'settings.cliEnvCodebuddyInternetEnvDefault' }, + { value: 'internal', labelKey: 'settings.cliEnvCodebuddyInternetEnvInternal' }, + { value: 'ioa', labelKey: 'settings.cliEnvCodebuddyInternetEnvIoa' }, + ], + }, { agentId: 'codex', envKey: 'CODEX_HOME', @@ -533,6 +568,38 @@ const AGENT_CLI_ENV_FIELDS = [ }, ] as const; +/** + * Compute the CLI env fields shown in the Advanced disclosure. + * + * Always include fields for the currently selected agent. When the + * recovery flow is active for a DIFFERENT agent (the user clicked + * "Configure" on a misconfigured agent card while another agent is + * selected), include that agent's fields too AND surface them first + * so the auto-focus target (`index === 0`) lands on the misconfigured + * field the user came to fix — not the selected agent's first + * advanced field, which they did not ask to edit. + * + * Exported for unit-testing the ordering and the recovery-state + * stickiness regression that PR #2022 review flagged. + */ +export function computeCliEnvFields( + fields: readonly F[], + selectedAgentId: string | null | undefined, + configErrorAgentId: string | null, +): F[] { + const matches = fields.filter( + (field) => + field.agentId === selectedAgentId || field.agentId === configErrorAgentId, + ); + if (configErrorAgentId && configErrorAgentId !== selectedAgentId) { + return [ + ...matches.filter((f) => f.agentId === configErrorAgentId), + ...matches.filter((f) => f.agentId !== configErrorAgentId), + ]; + } + return matches; +} + function defaultApiProtocolConfig(protocol: ApiProtocol): ApiProtocolConfig { const provider = KNOWN_PROVIDERS.find((p) => p.protocol === protocol); return { @@ -671,8 +738,16 @@ export function updateAgentCliEnvValue( const value = rawValue.trim(); const agentCliEnv = { ...(config.agentCliEnv ?? {}) }; const nextAgentEnv = { ...(agentCliEnv[agentId] ?? {}) }; - if (value) { - nextAgentEnv[envKey] = value; + // For closed-enum fields (those with options), an empty string is an + // explicit "unset" marker that must be persisted so the daemon knows + // the user chose "International (default)" rather than never configuring + // the field. For free-text fields, empty just means "clear the value". + const field = AGENT_CLI_ENV_FIELDS.find( + (f) => f.agentId === agentId && f.envKey === envKey, + ); + const isClosedEnum = field && 'options' in field && field.options; + if (value || (isClosedEnum && rawValue === '')) { + nextAgentEnv[envKey] = isClosedEnum ? rawValue : value; } else { delete nextAgentEnv[envKey]; } @@ -978,6 +1053,59 @@ export function SettingsDialog({ >(() => new Set()); const [versionChecking, setVersionChecking] = useState(false); const [aboutToast, setAboutToast] = useState(null); + const [configErrorAgentId, setConfigErrorAgentId] = useState(null); + // Bumped on every Configure click (even repeats for the same agent) so + // the recovery effect re-runs and re-focuses/re-scrolls. Without this + // tick, clicking Configure twice in a row for the same misconfigured + // agent is a no-op because `configErrorAgentId` did not change between + // the two renders. + const [configErrorRequestNonce, setConfigErrorRequestNonce] = useState(0); + // Refs into the CLI env recovery UI so the "Configure" button on a + // misconfigured agent card can drive the disclosure open and land focus + // on the first editable field — without that, the CTA only flips a + // state flag and the env editor stays collapsed and invisible. + const cliEnvDetailsRef = useRef(null); + const cliEnvFirstFieldRef = useRef(null); + useEffect(() => { + if (!configErrorAgentId) return; + const details = cliEnvDetailsRef.current; + if (details && !details.open) details.open = true; + // Defer focus/scroll until after the disclosure has expanded its body + // and the field elements are mounted (the first paint with open=true + // is what reveals the input). + const id = window.requestAnimationFrame(() => { + cliEnvDetailsRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + cliEnvFirstFieldRef.current?.focus(); + }); + return () => window.cancelAnimationFrame(id); + }, [configErrorAgentId, configErrorRequestNonce]); + // Reset the recovery target whenever the user switches the active agent + // tab. Otherwise the field-order partition below would keep prepending + // the misconfigured agent's fields for the rest of the dialog session, + // even after the user moved on and the recovery flow no longer applies. + // Comparing against the latest cfg.agentId in a ref avoids clearing on + // the initial mount (when both are still in sync) and on the very tick + // the user clicks Configure (the click sets configErrorAgentId before + // cfg.agentId changes, so this effect would otherwise wipe the click + // immediately). + const recoveryStartedAgentIdRef = useRef(null); + useEffect(() => { + if (!configErrorAgentId) { + recoveryStartedAgentIdRef.current = null; + return; + } + if (recoveryStartedAgentIdRef.current === null) { + recoveryStartedAgentIdRef.current = cfg.agentId; + return; + } + if (recoveryStartedAgentIdRef.current !== cfg.agentId) { + setConfigErrorAgentId(null); + recoveryStartedAgentIdRef.current = null; + } + }, [cfg.agentId, configErrorAgentId]); const handleInstallLatest = useCallback(async () => { if (versionChecking || !appVersionInfo) return; @@ -1798,12 +1926,24 @@ export function SettingsDialog({ }; }, [onPersist]); + // Central close handler: routes daemon write failures from the + // close path into the autosave error UI so the user sees which + // field is invalid instead of a dead-looking dialog. + const handleClose = useCallback(() => { + const result = onClose(autosaveLatestRef.current); + if (result && typeof result === 'object' && 'then' in result) { + result.catch(() => { + setAutosaveStatus('error'); + }); + } + }, [onClose]); + // Global Escape closes the dialog. With no footer button anymore the // close affordances are: top-right X · backdrop click · Escape. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return; - onClose(); + handleClose(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); @@ -2243,7 +2383,7 @@ export function SettingsDialog({ }; return ( -
+
@@ -2998,9 +3138,10 @@ export function SettingsDialog({ const installUrl = sanitizeHttpsUrl(a.installUrl); const docsUrl = sanitizeHttpsUrl(a.docsUrl); const hasLinks = Boolean(installUrl || docsUrl); - const description = AGENT_SHORT_DESCRIPTIONS[a.id]; + const description = a.authMessage ?? AGENT_SHORT_DESCRIPTIONS[a.id]; const agentName = displayAgentName(a); const cardLabel = `${agentName} · ${t('common.notInstalled')}`; + const isConfigError = a.unavailableReason === 'config_error'; return (
) : null}
- {hasLinks ? ( -
- {docsUrl ? ( - - {t('settings.agentInstall.docs')} - - ) : null} - {installUrl ? ( - - {t('settings.agentInstall.install')} - - ) : null} -
- ) : null} +
+ {docsUrl ? ( + + {t('settings.agentInstall.docs')} + + ) : null} + {isConfigError ? ( + + ) : installUrl ? ( + + {t('settings.agentInstall.install')} + + ) : null} +
); })} @@ -3138,12 +3292,15 @@ export function SettingsDialog({ users no longer wonder "are these fields I forgot to fill in?". */ - const cliEnvFields = AGENT_CLI_ENV_FIELDS.filter( - (field) => field.agentId === cfg.agentId, + const cliEnvFields = computeCliEnvFields( + AGENT_CLI_ENV_FIELDS, + cfg.agentId, + configErrorAgentId, ); if (cliEnvFields.length === 0) return null; return (
@@ -3155,7 +3312,7 @@ export function SettingsDialog({

{t('settings.cliEnvHint')}

- {cliEnvFields.map((field) => ( + {cliEnvFields.map((field, index) => ( ))}
@@ -3587,7 +3788,7 @@ export function SettingsDialog({ /> ) : null} - {activeSection === 'routines' ? : null} + {activeSection === 'routines' ? : null} {activeSection === 'orbit' ? ( ) : null} @@ -4292,6 +4493,7 @@ export async function persistConfigAndRunOrbit( if (options?.syncMediaProviders !== false) { await syncMediaProvidersToDaemon(config.mediaProviders, { daemonProviders: options?.daemonProviders, + throwOnError: true, }); } await syncConfigToDaemon(config, { throwOnError: true }); diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 76281ced2..2d356e4b2 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -244,6 +244,8 @@ export const ar: Dict = { 'أكمِل المصادقة في CLI الخاص بالمزوّد (تسجيل الدخول أو إضافة بيانات اعتماد API) قبل العودة إلى Open Design.', 'settings.agentInstall.stepRescan': 'انقر إعادة المسح في هذا القسم.', 'settings.agentInstall.stepSelect': 'اختر بطاقة الوكيل عندما يظهر كأنه مثبت.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'لم يتم اكتشاف أي وكلاء بعد. قم بتثبيت Claude Code أو Codex أو Devin أو Gemini CLI أو OpenCode أو Cursor Agent أو Qwen أو GitHub Copilot CLI، ثم اضغط على إعادة المسح.', 'settings.agentInstalledGroup': 'واجهات CLI لديك ({count})', @@ -328,6 +330,14 @@ export const ar: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 96a2a6fe2..17366fe13 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -244,6 +244,8 @@ export const de: Dict = { 'Authentifizieren Sie sich mit der Anbieter-CLI (anmelden oder API-Zugangsdaten setzen), bevor Sie zu Open Design zurueckkehren.', 'settings.agentInstall.stepRescan': 'Klicken Sie in diesem Bereich auf Neu scannen.', 'settings.agentInstall.stepSelect': 'Waehlen Sie die Agent-Karte aus, sobald sie als installiert angezeigt wird.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Noch keine Agents erkannt. Installieren Sie Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen oder GitHub Copilot CLI und klicken Sie dann auf Neu scannen.', 'settings.agentInstalledGroup': 'Ihre CLIs ({count})', @@ -328,6 +330,14 @@ export const de: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f2cd3cd78..49e3dff4f 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -236,6 +236,8 @@ export const en: Dict = { 'Authenticate with the vendor CLI (sign in or add API credentials) before returning to Open Design.', 'settings.agentInstall.stepRescan': 'Click Rescan in this section.', 'settings.agentInstall.stepSelect': 'Select the agent card once it appears as installed.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'No agents detected yet. Install one of Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, or GitHub Copilot CLI, then click Rescan.', 'settings.agentInstalledGroup': 'Your CLIs ({count})', @@ -339,6 +341,14 @@ export const en: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 31c15d10a..1342e3ab0 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -244,6 +244,8 @@ export const esES: Dict = { 'Autentícate con la CLI del proveedor (inicia sesión o añade credenciales API) antes de volver a Open Design.', 'settings.agentInstall.stepRescan': 'Haz clic en Reescanear en esta sección.', 'settings.agentInstall.stepSelect': 'Selecciona la tarjeta del agente cuando aparezca como instalado.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Aún no se ha detectado ningún agente. Instala Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen o GitHub Copilot CLI y pulsa Reescanear.', 'settings.agentInstalledGroup': 'Tus CLI ({count})', @@ -328,6 +330,14 @@ export const esES: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 85a4bfaa1..886e69dfc 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -244,6 +244,8 @@ export const fa: Dict = { 'قبل از بازگشت به Open Design، در CLI ارائه‌دهنده احراز هویت کنید (ورود یا افزودن اطلاعات API).', 'settings.agentInstall.stepRescan': 'در این بخش روی اسکن مجدد کلیک کنید.', 'settings.agentInstall.stepSelect': 'وقتی عامل به‌صورت نصب‌شده نمایش داده شد، کارت آن را انتخاب کنید.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'هنوز هیچ عاملی شناسایی نشده. یکی از Claude Code، Codex، Gemini CLI، OpenCode، Cursor Agent، Qwen یا GitHub Copilot CLI را نصب کنید، سپس روی اسکن مجدد کلیک کنید.', 'settings.agentInstalledGroup': 'CLIهای شما ({count})', @@ -328,6 +330,14 @@ export const fa: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 62a03e542..407ef4183 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -228,7 +228,10 @@ export const fr: Dict = { 'settings.agentInstall.stepAuth': 'Authentifiez-vous avec la CLI du fournisseur (connexion ou ajout des identifiants API) avant de revenir dans Open Design.', 'settings.agentInstall.stepRescan': 'Cliquez sur Réanalyser dans cette section.', 'settings.agentInstall.stepSelect': 'Sélectionnez la carte de l\'agent une fois qu\'elle apparaît comme installée.', - 'settings.noAgentsDetected': 'Aucun agent détecté pour l\'instant. Installez Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen ou GitHub Copilot CLI, puis cliquez sur Réanalyser.', + 'settings.agentConfigError': 'Erreur de configuration', + 'settings.agentConfigError.configure': 'Configurer', + 'settings.noAgentsDetected': + 'Aucun agent détecté pour l\'instant. Installez Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen ou GitHub Copilot CLI, puis cliquez sur Réanalyser.', 'settings.agentInstalledGroup': 'Vos CLI ({count})', 'settings.agentInstallGroup': 'Disponibles à installer ({count})', 'settings.agentAuthRequired': 'Authentification requise', @@ -320,6 +323,14 @@ export const fr: Dict = { 'settings.cliEnvClaudeConfigDir': 'Dossier de configuration Claude Code', 'settings.cliEnvClaudeBaseUrl': 'URL de base du proxy Claude', 'settings.cliEnvClaudeApiKey': 'Clé API du proxy Claude', + 'settings.cliEnvCodebuddyConfigDir': 'Dossier de configuration CodeBuddy', + 'settings.cliEnvCodebuddyBin': 'Chemin de l\'exécutable CodeBuddy', + 'settings.cliEnvCodebuddyBaseUrl': 'URL de base du proxy CodeBuddy', + 'settings.cliEnvCodebuddyApiKey': 'Clé API CodeBuddy', + 'settings.cliEnvCodebuddyInternetEnvironment': 'Environnement internet CodeBuddy', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (par défaut)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Dossier d’accueil Codex', 'settings.cliEnvCodexBin': 'Chemin de l’exécutable Codex', 'settings.cliEnvCodexBaseUrl': 'URL de base du proxy Codex/OpenAI', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index b9fc9ae84..6741ff625 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -244,6 +244,8 @@ export const hu: Dict = { 'Hitelesíts a szolgáltató CLI-jében (bejelentkezés vagy API hitelesítő adatok megadása), mielőtt visszatérsz az Open Designhoz.', 'settings.agentInstall.stepRescan': 'Kattints az Újraellenőrzés gombra ebben a szakaszban.', 'settings.agentInstall.stepSelect': 'Válaszd ki az ügynök kártyáját, amint telepítettként jelenik meg.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Még nincs észlelt ügynök. Telepítsd a Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen vagy GitHub Copilot CLI valamelyikét, majd kattints az Újraellenőrzésre.', 'settings.agentInstalledGroup': 'Saját CLI-k ({count})', @@ -328,6 +330,14 @@ export const hu: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index a5c5cc02a..f03fb4a4a 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -244,6 +244,8 @@ export const id: Dict = { 'Lakukan autentikasi di CLI vendor (masuk atau tambahkan kredensial API) sebelum kembali ke Open Design.', 'settings.agentInstall.stepRescan': 'Klik Pindai ulang di bagian ini.', 'settings.agentInstall.stepSelect': 'Pilih kartu agen setelah statusnya terpasang.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Belum ada agent terdeteksi. Pasang salah satu dari Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, atau GitHub Copilot CLI, lalu klik pindai ulang.', 'settings.agentInstalledGroup': 'CLI Anda ({count})', @@ -324,6 +326,14 @@ export const id: Dict = { 'settings.cliEnvClaudeConfigDir': 'Direktori konfigurasi Claude Code', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'Direktori konfigurasi CodeBuddy', + 'settings.cliEnvCodebuddyBin': 'Path executable CodeBuddy', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Home Codex', 'settings.cliEnvCodexBin': 'Path executable Codex', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 725834487..23d2811cf 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -243,6 +243,8 @@ export const it: Dict = { 'Autenticati con la CLI del provider (login o aggiunta delle credenziali API) prima di tornare in Open Design.', 'settings.agentInstall.stepRescan': 'Clicca su Rianalizza in questa sezione.', 'settings.agentInstall.stepSelect': 'Seleziona la scheda dell\'agente una volta che appare come installato.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Nessun agente rilevato per ora. Installa Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen o GitHub Copilot CLI, poi clicca su Rianalizza.', 'settings.agentInstalledGroup': 'Le tue CLI ({count})', @@ -322,6 +324,16 @@ export const it: Dict = { 'settings.cliEnvHint': 'Imposta directory di configurazione non segrete per esecuzioni di app impacchettate e rilevamento agenti.', 'settings.cliEnvClaudeConfigDir': 'Directory di configurazione Claude Code', + 'settings.cliEnvClaudeBaseUrl': 'Base URL Claude', + 'settings.cliEnvClaudeApiKey': 'Chiave API Claude', + 'settings.cliEnvCodebuddyConfigDir': 'Directory di configurazione CodeBuddy', + 'settings.cliEnvCodebuddyBin': 'Percorso eseguibile CodeBuddy', + 'settings.cliEnvCodebuddyBaseUrl': 'Base URL CodeBuddy', + 'settings.cliEnvCodebuddyApiKey': 'Chiave API CodeBuddy', + 'settings.cliEnvCodebuddyInternetEnvironment': 'Ambiente internet CodeBuddy', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'Internazionale (predefinito)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Home di Codex', 'settings.cliEnvCodexBin': 'Percorso eseguibile Codex', 'settings.modelCustom': 'Personalizzato (inserisci sotto)…', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 3e2e44759..c2f07d2d4 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -244,6 +244,8 @@ export const ja: Dict = { 'Open Design に戻る前に、ベンダー CLI で認証(サインインまたは API 資格情報の追加)を行います。', 'settings.agentInstall.stepRescan': 'このセクションで「再スキャン」をクリックします。', 'settings.agentInstall.stepSelect': 'インストール済みとして表示されたらエージェントカードを選択します。', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'エージェントが検出されませんでした。Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent、Qwen、または GitHub Copilot CLI のいずれかをインストールして、再スキャンをクリックしてください。', 'settings.agentInstalledGroup': 'あなたの CLI({count})', @@ -328,6 +330,14 @@ export const ja: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 03a9e08be..7cc0ecfc9 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -244,6 +244,8 @@ export const ko: Dict = { 'Open Design으로 돌아오기 전에 공급자 CLI에서 인증(로그인 또는 API 자격 증명 추가)을 완료하세요.', 'settings.agentInstall.stepRescan': '이 섹션에서 다시 스캔을 클릭하세요.', 'settings.agentInstall.stepSelect': '설치됨으로 표시되면 해당 에이전트 카드를 선택하세요.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': '에이전트가 감지되지 않았습니다. Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen 또는 GitHub Copilot CLI 중 하나를 설치한 후 다시 스캔을 클릭하세요.', 'settings.agentInstalledGroup': '내 CLI ({count})', @@ -328,6 +330,14 @@ export const ko: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index fed79ce77..d1bea02e7 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -244,6 +244,8 @@ export const pl: Dict = { 'Uwierzytelnij się w CLI dostawcy (zaloguj się lub dodaj dane API), zanim wrócisz do Open Design.', 'settings.agentInstall.stepRescan': 'Kliknij Ponów skanowanie w tej sekcji.', 'settings.agentInstall.stepSelect': 'Wybierz kartę agenta, gdy pojawi się jako zainstalowany.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Nie wykryto jeszcze żadnych agentów. Zainstaluj Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen lub GitHub Copilot CLI, a następnie kliknij Ponów skanowanie.', 'settings.agentInstalledGroup': 'Twoje CLI ({count})', @@ -328,6 +330,14 @@ export const pl: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 45690ea32..dbce0da69 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -244,6 +244,8 @@ export const ptBR: Dict = { 'Autentique-se na CLI do fornecedor (faça login ou adicione credenciais de API) antes de voltar ao Open Design.', 'settings.agentInstall.stepRescan': 'Clique em Reescanear nesta seção.', 'settings.agentInstall.stepSelect': 'Selecione o cartão do agente quando ele aparecer como instalado.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Nenhum agente detectado ainda. Instale Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen ou GitHub Copilot CLI e clique em Reescanear.', 'settings.agentInstalledGroup': 'Suas CLIs ({count})', @@ -328,6 +330,14 @@ export const ptBR: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index adc5dedee..ac6bb0993 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -244,6 +244,8 @@ export const ru: Dict = { 'Пройдите аутентификацию в CLI поставщика (вход или добавление API-ключей), затем вернитесь в Open Design.', 'settings.agentInstall.stepRescan': 'Нажмите «Пересканировать» в этом разделе.', 'settings.agentInstall.stepSelect': 'Выберите карточку агента, когда он появится как установленный.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Агенты ещё не обнаружены. Установите один из следующих инструментов: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen или GitHub Copilot CLI, затем нажмите «Пересканировать».', 'settings.agentInstalledGroup': 'Ваши CLI ({count})', @@ -328,6 +330,14 @@ export const ru: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index aa3cc50e5..5b5d6caeb 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -243,6 +243,8 @@ export const th: Dict = { 'ยืนยันตัวตนกับ CLI ของผู้ให้บริการ (ลงชื่อเข้าใช้หรือเพิ่มข้อมูลรับรอง API) ก่อนกลับไปที่ Open Design', 'settings.agentInstall.stepRescan': 'คลิกสแกนใหม่ในส่วนนี้', 'settings.agentInstall.stepSelect': 'เลือกการ์ดเอเจนต์เมื่อแสดงว่าได้ติดตั้งแล้ว', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'ยังไม่พบเอเจนต์ โปรดติดตั้งอย่างใดอย่างหนึ่ง: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen หรือ GitHub Copilot CLI แล้วคลิกสแกนใหม่', 'settings.agentAuthRequired': 'ต้องยืนยันตัวตน', 'settings.agentInstalledGroup': 'CLI ของคุณ ({count})', @@ -319,6 +321,14 @@ export const th: Dict = { 'settings.cliEnvTitle': 'ตำแหน่งการตั้งค่า CLI', 'settings.cliEnvHint': 'ตั้งค่าไดเรกทอรีการกำหนดค่า (ที่ไม่เป็นความลับ) สำหรับแอปพลิเคชัน', 'settings.cliEnvClaudeConfigDir': 'ไดเรกทอรีการตั้งค่า Claude Code', + 'settings.cliEnvCodebuddyConfigDir': 'ไดเรกทอรีการตั้งค่า CodeBuddy', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'สภาพแวดล้อมอินเทอร์เน็ต CodeBuddy', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'เส้นทางไฟล์เรียกทำงาน Codex', 'settings.modelCustom': 'กำหนดเอง (พิมพ์ด้านล่าง)…', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index ebaade8be..291b549b3 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -244,6 +244,8 @@ export const tr: Dict = { 'Open Design\'a dönmeden önce sağlayıcı CLI\'sinde kimlik doğrulaması yap (oturum aç veya API kimlik bilgileri ekle).', 'settings.agentInstall.stepRescan': 'Bu bölümde Yeniden tara\'ya tıkla.', 'settings.agentInstall.stepSelect': 'Ajan yüklü olarak göründüğünde kartını seç.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Hiçbir ajan tespit edilemedi. Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, veya GitHub Copilot CLI’lardan birini kurun ve yeniden tarayın.', 'settings.agentInstalledGroup': 'CLI\'larınız ({count})', @@ -316,6 +318,22 @@ export const tr: Dict = { 'settings.modelSourceLive': 'CLI\'dan canlı', 'settings.modelSourceFallback': 'Yerleşik liste', 'settings.reasoningPicker': 'Akıl yürütme eforu', + 'settings.cliEnvTitle': 'Gelişmiş: vekil ve özel yollar', + 'settings.cliEnvHint': + 'Paketlenmiş uygulama çalıştırmaları ve aracı algılama için varsayılan olmayan yapılandırma dizinlerini ayarlayın.', + 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', + 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', + 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', + 'settings.cliEnvCodexHome': 'Codex home', + 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.modelPickerHint': 'Bir `models` komutu açığa çıkaran CLI’lardan getirilir. "Varsayılan" seçimi CLI’ın kendi ayarına bırakır; "Özel…" CLI’ın kabul edeceği herhangi bir model kimliği seçmenize izin verir.', 'settings.modelPickerLiveHint': diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 60fe589a8..3fb7cc2c8 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -245,6 +245,8 @@ export const uk: Dict = { 'Пройдіть автентифікацію у CLI постачальника (увійдіть або додайте API-облікові дані), потім поверніться до Open Design.', 'settings.agentInstall.stepRescan': 'Натисніть «Пересканувати» у цьому розділі.', 'settings.agentInstall.stepSelect': 'Виберіть картку агента, коли він з\'явиться як встановлений.', + 'settings.agentConfigError': 'Configuration error', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': 'Агентів ще не виявлено. Встановіть один з: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen або GitHub Copilot CLI, а потім натисніть Переканувати.', 'settings.agentInstalledGroup': 'Ваші CLI ({count})', @@ -329,6 +331,14 @@ export const uk: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code config directory', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy config directory', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy internet environment', + 'settings.cliEnvCodebuddyInternetEnvDefault': 'International (default)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal (China)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa (iOA enterprise)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex executable path', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 115f85b82..98184bb4c 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -236,6 +236,8 @@ export const zhCN: Dict = { '返回 Open Design 之前,请先在对应 CLI 中完成认证(登录或添加 API 凭据)。', 'settings.agentInstall.stepRescan': '在此区域点击“重新扫描”。', 'settings.agentInstall.stepSelect': '当代理显示为已安装后,选择该代理卡片。', + 'settings.agentConfigError': '配置错误', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': '尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent、Qwen 或 GitHub Copilot CLI 中的一个,然后点击「重新扫描」。', 'settings.agentInstalledGroup': '你的 CLI({count})', @@ -339,6 +341,14 @@ export const zhCN: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code 配置目录', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy 配置目录', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy 网络环境', + 'settings.cliEnvCodebuddyInternetEnvDefault': '国际版(默认)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal(中国版)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa(iOA 企业版)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex 可执行文件路径', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 644391e94..d81244f97 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -247,6 +247,8 @@ export const zhTW: Dict = { '返回 Open Design 前,請先在對應 CLI 完成驗證(登入或新增 API 憑證)。', 'settings.agentInstall.stepRescan': '在此區域點擊「重新掃描」。', 'settings.agentInstall.stepSelect': '當代理顯示為已安裝後,選擇該代理卡片。', + 'settings.agentConfigError': '配置错误', + 'settings.agentConfigError.configure': 'Configure', 'settings.noAgentsDetected': '尚未偵測到任何代理。請安裝 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 其中之一,然後點擊「重新掃描」。', 'settings.agentInstalledGroup': '你的 CLI({count})', @@ -331,6 +333,14 @@ export const zhTW: Dict = { 'settings.cliEnvClaudeConfigDir': 'Claude Code 設定目錄', 'settings.cliEnvClaudeBaseUrl': 'Claude proxy base URL', 'settings.cliEnvClaudeApiKey': 'Claude proxy API key', + 'settings.cliEnvCodebuddyConfigDir': 'CodeBuddy 設定目錄', + 'settings.cliEnvCodebuddyBin': 'CodeBuddy executable path', + 'settings.cliEnvCodebuddyBaseUrl': 'CodeBuddy proxy base URL', + 'settings.cliEnvCodebuddyApiKey': 'CodeBuddy API key', + 'settings.cliEnvCodebuddyInternetEnvironment': 'CodeBuddy 網路環境', + 'settings.cliEnvCodebuddyInternetEnvDefault': '國際版(預設)', + 'settings.cliEnvCodebuddyInternetEnvInternal': 'internal(中國版)', + 'settings.cliEnvCodebuddyInternetEnvIoa': 'ioa(iOA 企業版)', 'settings.cliEnvCodexHome': 'Codex home', 'settings.cliEnvCodexBin': 'Codex 可執行檔路徑', 'settings.cliEnvCodexBaseUrl': 'Codex/OpenAI proxy base URL', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 967c33ef3..f6b1c5cff 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -249,6 +249,8 @@ export interface Dict { 'settings.agentInstall.stepAuth': string; 'settings.agentInstall.stepRescan': string; 'settings.agentInstall.stepSelect': string; + 'settings.agentConfigError': string; + 'settings.agentConfigError.configure': string; 'settings.noAgentsDetected': string; 'settings.agentInstalledGroup': string; 'settings.agentInstallGroup': string; @@ -341,6 +343,14 @@ export interface Dict { 'settings.cliEnvClaudeConfigDir': string; 'settings.cliEnvClaudeBaseUrl': string; 'settings.cliEnvClaudeApiKey': string; + 'settings.cliEnvCodebuddyConfigDir': string; + 'settings.cliEnvCodebuddyBin': string; + 'settings.cliEnvCodebuddyBaseUrl': string; + 'settings.cliEnvCodebuddyApiKey': string; + 'settings.cliEnvCodebuddyInternetEnvironment': string; + 'settings.cliEnvCodebuddyInternetEnvDefault': string; + 'settings.cliEnvCodebuddyInternetEnvInternal': string; + 'settings.cliEnvCodebuddyInternetEnvIoa': string; 'settings.cliEnvCodexHome': string; 'settings.cliEnvCodexBin': string; 'settings.cliEnvCodexBaseUrl': string; diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index cd55dd0b7..2b70af00c 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -613,7 +613,7 @@ const DAEMON_OWNED_KEYS = new Set([ 'privacyDecisionAt', ]); -const AGENT_CLI_SECRET_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'CODEX_API_KEY', 'OPENAI_API_KEY']); +const AGENT_CLI_SECRET_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'CODEBUDDY_API_KEY', 'CODEX_API_KEY', 'OPENAI_API_KEY']); function sanitizeAgentCliEnv(agentCliEnv: AppConfig['agentCliEnv']): AppConfig['agentCliEnv'] { if (!agentCliEnv) return agentCliEnv; @@ -792,11 +792,25 @@ export async function fetchDaemonConfig(): Promise { } } +/** Thrown when the daemon rejects a config write. Always propagated + * to the caller so the UI can surface the error instead of showing "Saved". + * Network errors (daemon unreachable) are still swallowed by default so the + * Settings autosave path degrades gracefully when the daemon is offline. */ +export class DaemonConfigWriteError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'DaemonConfigWriteError'; + } +} + export async function syncConfigToDaemon( config: AppConfig, - options?: { throwOnError?: boolean }, + options?: { throwOnError?: boolean; swallowWriteErrors?: boolean; omitKeys?: ReadonlyArray }, ): Promise { - const prefs: AppConfigPrefs = { + const fullPrefs: AppConfigPrefs = { onboardingCompleted: config.onboardingCompleted, agentId: config.agentId, agentModels: config.agentModels, @@ -813,15 +827,34 @@ export async function syncConfigToDaemon( projectLocations: config.projectLocations ?? [], defaultProjectLocationId: config.defaultProjectLocationId ?? 'default', }; + const omitSet = options?.omitKeys ? new Set(options.omitKeys) : undefined; + const prefs = omitSet + ? (Object.fromEntries( + Object.entries(fullPrefs).filter(([k]) => !omitSet.has(k as keyof AppConfigPrefs)), + ) as AppConfigPrefs) + : fullPrefs; try { const response = await fetch('/api/app-config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs), }); - if (!response.ok) throw new Error(`Failed to sync app config (${response.status})`); + if (!response.ok) { + // Daemon rejected the write — surface by default so callers know + // the write failed. Only network/offline errors are swallowed + // below. Fire-and-forget callers that explicitly tolerate daemon + // write rejections pass swallowWriteErrors. + const body = await response.json().catch(() => ({}) as () => Record); + const writeError = new DaemonConfigWriteError( + (body as Record).error as string ?? `Failed to sync app config (${response.status})`, + response.status, + ); + if (!options?.swallowWriteErrors) throw writeError; + } } catch (error) { + if (error instanceof DaemonConfigWriteError) throw error; if (options?.throwOnError) throw error; - // Daemon offline; localStorage keeps the user's copy for the next save. + // Network error (daemon unreachable); localStorage keeps the user's copy + // for the next save attempt. } } diff --git a/apps/web/tests/components/SettingsDialog.test.ts b/apps/web/tests/components/SettingsDialog.test.ts index caeba146a..eaa4a4ee5 100644 --- a/apps/web/tests/components/SettingsDialog.test.ts +++ b/apps/web/tests/components/SettingsDialog.test.ts @@ -3,6 +3,7 @@ import { agentRefreshOptionsForConfig, canFetchProviderModels, canRunProviderConnectionTest, + computeCliEnvFields, deriveComposioCredentialState, configForManualOrbitRun, isOrbitRunDisabled, @@ -989,3 +990,73 @@ describe('sanitizeSettingsSavePayload', () => { expect(sanitized.theme).toBe('system'); }); }); + +describe('computeCliEnvFields (Configure recovery flow)', () => { + type EnvField = { agentId: string; envKey: string }; + const FIELDS: readonly EnvField[] = [ + { agentId: 'claude', envKey: 'CLAUDE_CONFIG_DIR' }, + { agentId: 'claude', envKey: 'ANTHROPIC_BASE_URL' }, + { agentId: 'claude', envKey: 'ANTHROPIC_API_KEY' }, + { agentId: 'codebuddy', envKey: 'CODEBUDDY_CONFIG_DIR' }, + { agentId: 'codebuddy', envKey: 'CODEBUDDY_API_KEY' }, + { agentId: 'codex', envKey: 'CODEX_HOME' }, + ]; + + it('returns only the selected agent fields when no recovery is active', () => { + const result = computeCliEnvFields(FIELDS, 'claude', null); + expect(result.map((f) => f.envKey)).toEqual([ + 'CLAUDE_CONFIG_DIR', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', + ]); + }); + + it('puts the recovery agent fields FIRST when configErrorAgentId differs from the selected agent', () => { + // Regression test for PR #2022 review feedback (discussion_r3329925123): + // when Claude is selected and the user clicks Configure on a misconfigured + // CodeBuddy card, the focus target (index === 0) must be a CodeBuddy field, + // not CLAUDE_CONFIG_DIR. + const result = computeCliEnvFields(FIELDS, 'claude', 'codebuddy'); + expect(result[0]?.agentId).toBe('codebuddy'); + expect(result[0]?.envKey).toBe('CODEBUDDY_CONFIG_DIR'); + // All matched fields are present in the output, just reordered. + expect(result.map((f) => f.envKey).sort()).toEqual( + [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'CLAUDE_CONFIG_DIR', + 'CODEBUDDY_API_KEY', + 'CODEBUDDY_CONFIG_DIR', + ].sort(), + ); + }); + + it('does not duplicate fields when configErrorAgentId equals the selected agent', () => { + // After the user fixes CodeBuddy and switches to the CodeBuddy tab — + // or clicks Configure for the same agent that is already selected — + // the merged set must not include duplicates and must keep stable order. + const result = computeCliEnvFields(FIELDS, 'codebuddy', 'codebuddy'); + expect(result.map((f) => f.envKey)).toEqual([ + 'CODEBUDDY_CONFIG_DIR', + 'CODEBUDDY_API_KEY', + ]); + }); + + it('returns an empty list when neither selected nor recovery agent matches any field', () => { + const result = computeCliEnvFields(FIELDS, 'unknown', null); + expect(result).toEqual([]); + }); + + it('does NOT leak unrelated fields after the recovery target is cleared', () => { + // Regression test for PR #2022 review feedback (discussion_r3329951063): + // After the user dismisses the recovery flow (configErrorAgentId reset to + // null), the misconfigured agent's fields must not stay in the result — + // otherwise the disclosure would keep prepending a stale agent's fields + // for the rest of the dialog session. + const beforeReset = computeCliEnvFields(FIELDS, 'claude', 'codebuddy'); + const afterReset = computeCliEnvFields(FIELDS, 'claude', null); + expect(beforeReset.some((f) => f.agentId === 'codebuddy')).toBe(true); + expect(afterReset.some((f) => f.agentId === 'codebuddy')).toBe(false); + expect(afterReset.map((f) => f.agentId)).toEqual(['claude', 'claude', 'claude']); + }); +}); diff --git a/apps/web/tests/state/config.test.ts b/apps/web/tests/state/config.test.ts index 4052f5409..391382478 100644 --- a/apps/web/tests/state/config.test.ts +++ b/apps/web/tests/state/config.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildMediaProvidersForDaemonSave, + DaemonConfigWriteError, DEFAULT_CONFIG, fetchMediaProvidersFromDaemon, isStoredMediaProviderEntryEmpty, @@ -161,6 +162,45 @@ describe('syncMediaProvidersToDaemon', () => { syncMediaProvidersToDaemon({}, { force: true, throwOnError: true }), ).rejects.toThrow('Media config save failed'); }); + + it('throws DaemonConfigWriteError on 400 by default', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'invalid env' }), { status: 400 })), + ); + + await expect( + syncConfigToDaemon(DEFAULT_CONFIG), + ).rejects.toThrow(DaemonConfigWriteError); + }); + + it('swallows a 400 response when swallowWriteErrors is set', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'invalid env' }), { status: 400 })), + ); + + // Fire-and-forget callers explicitly suppress write errors. + await expect( + syncConfigToDaemon(DEFAULT_CONFIG, { swallowWriteErrors: true }), + ).resolves.toBeUndefined(); + }); + + it('includes status code in DaemonConfigWriteError for autosave error handling', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'bad config' }), { status: 400 })), + ); + + try { + await syncConfigToDaemon(DEFAULT_CONFIG); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(DaemonConfigWriteError); + expect((error as DaemonConfigWriteError).status).toBe(400); + expect((error as DaemonConfigWriteError).message).toContain('bad config'); + } + }); }); describe('mergeDaemonConfig', () => { diff --git a/deploy/.gitignore b/deploy/.gitignore index 479bebca2..d4f08fdd5 100644 --- a/deploy/.gitignore +++ b/deploy/.gitignore @@ -1,2 +1,3 @@ *.bak -.env \ No newline at end of file +.env +.codebuddy/scheduled_tasks.lock \ No newline at end of file diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index 66f740515..91b66c634 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -10,6 +10,9 @@ export interface AgentInfo { available: boolean; authStatus?: 'ok' | 'missing' | 'unknown'; authMessage?: string; + /** Why an unavailable agent is not available. `not_installed` = binary not + * found; `config_error` = binary found but env/config is invalid. */ + unavailableReason?: 'not_installed' | 'config_error'; path?: string; version?: string | null; models?: AgentModelOption[];