From 54a2b4759b7bf831ba6801cb71f97b6aef24481c Mon Sep 17 00:00:00 2001 From: minhbi245 <8097269+minhbi245@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:02:12 +0700 Subject: [PATCH 1/3] feat(cliproxy): add xAI Grok provider --- README.md | 3 +- config/base-xai.settings.json | 10 +++ docs/project-overview-pdr.md | 6 +- docs/system-architecture/provider-flows.md | 52 +++++++++----- src/auth/profile-detector.ts | 12 ++-- .../__tests__/model-catalog-compat.test.ts | 19 +++++ src/cliproxy/__tests__/model-catalog.test.js | 26 +++++++ .../__tests__/provider-capabilities.test.ts | 25 +++++++ .../account-manager-discover.test.js | 30 ++++++++ src/cliproxy/accounts/types.ts | 2 +- .../auth-types-management-path.test.ts | 1 + .../__tests__/oauth-handler-xai-args.test.ts | 20 ++++++ .../oauth-process-error-parser.test.ts | 17 +++++ src/cliproxy/auth/auth-types.ts | 8 +++ src/cliproxy/auth/oauth-handler.ts | 4 +- src/cliproxy/auth/oauth-process.ts | 29 +++++--- .../auth/provider-refreshers/index.ts | 4 +- .../__tests__/base-config-loader.test.ts | 1 + src/cliproxy/config/generator.ts | 2 +- src/cliproxy/model-catalog.ts | 69 +++++++++++++++++++ src/cliproxy/provider-capabilities.ts | 46 +++++++++++++ src/cliproxy/services/catalog-cache.ts | 1 + src/cliproxy/types/provider-types.ts | 1 + src/commands/command-catalog.ts | 16 ++++- src/commands/completion-backend.ts | 9 ++- src/config/loader/yaml-serializer.ts | 2 +- src/dispatcher/pre-dispatch.ts | 13 ++-- tests/unit/auth/profile-detector.test.ts | 8 +++ .../unit/commands/completion-backend.test.ts | 7 ++ .../unit/commands/help-command-parity.test.ts | 2 + ui/public/assets/providers/xai.svg | 4 ++ .../components/account/add-account-dialog.tsx | 2 +- ui/src/components/setup/wizard/constants.ts | 1 + ui/src/lib/model-catalogs.ts | 62 +++++++++++++++++ ui/src/lib/provider-config.ts | 4 ++ .../unit/ui/lib/model-catalogs-xai.test.ts | 30 ++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 16 +++++ 37 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 config/base-xai.settings.json create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts create mode 100644 ui/public/assets/providers/xai.svg create mode 100644 ui/tests/unit/ui/lib/model-catalogs-xai.test.ts diff --git a/README.md b/README.md index 9eea7e95..bb57a1b4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Then launch whatever runtime fits the task: ```bash ccs ccs codex +ccs xai # Alias: ccs grok ccs --target droid glm ccs glm ccs ollama @@ -61,7 +62,7 @@ CCS gives you one stable command surface while letting you switch between: - multiple runtimes such as Claude Code, Factory Droid, and Codex CLI - multiple Claude subscriptions and isolated account contexts -- OAuth providers like Codex, Kiro, Claude, Kimi, and more, with legacy +- OAuth providers like Codex, xAI/Grok, Kiro, Claude, Kimi, and more, with legacy Copilot compatibility for existing setups - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, Novita, Fireworks AI, and Alibaba Coding Plan diff --git a/config/base-xai.settings.json b/config/base-xai.settings.json new file mode 100644 index 00000000..0a908b8e --- /dev/null +++ b/config/base-xai.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/xai", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "grok-build-0.1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "grok-4.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "grok-build-0.1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "grok-composer-2.5-fast" + } +} diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d4e6449c..de9d4f86 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -31,7 +31,7 @@ Developers using Claude Code face these challenges: CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` -2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups +2. **OAuth Providers**: Zero-config Gemini, Codex, xAI/Grok, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management @@ -68,8 +68,8 @@ CCS provides: - Share commands, skills, agents across accounts ### FR-003: OAuth Provider Integration -- Support Gemini, Codex, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows -- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp compatibility) +- Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows +- Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows - Token caching and refresh ### FR-004: API Profile Management diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 98c2e49d..39b516c3 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -1,6 +1,6 @@ # Provider Integration Flows -Last Updated: 2026-05-07 +Last Updated: 2026-07-16 Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. @@ -35,13 +35,13 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> OAuth Authentication | | | +---> Authorization Code Flow (port-based) - | | - Gemini, Codex, Antigravity, Kiro (port 9876) + | | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab | | - Opens browser for user auth | | - Callback to localhost:PORT | | | +---> Device Code Flow (no port needed) - | - GitHub Copilot (ghcp, deprecated compatibility) - | - User enters code at github.com/login/device + | - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder + | - User enters a code on the provider verification page | - Polls for token completion | | | +---> Browser URL Polling (no callback port) @@ -70,23 +70,37 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> Provider APIs | +---> Google (Gemini) - +---> GitHub (Codex) + +---> OpenAI (Codex) + +---> xAI (Grok) +---> Antigravity (AGY) +---> AWS Kiro (Claude-powered) +---> GitHub Copilot (ghcp, deprecated compatibility) +---> OpenAI-compatible endpoints ``` -### Supported Hardcoded Providers +### Supported Built-In Providers -| Provider | ID | Auth Method | Port | Binary | +| Provider | ID | Auth Method | Callback Port | Backend | |----------|----|----|------|--------| -| Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI | -| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | -| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | -| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | -| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | -| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | +| Gemini | `gemini` | Authorization Code | 8085 | Original | +| Codex | `codex` | Authorization Code | 1455 | Original | +| xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original | +| Antigravity | `agy` | Authorization Code | 51121 | Original | +| Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original | +| iFlow | `iflow` | Authorization Code | 11451 | Original | +| Claude | `claude` | Authorization Code | 54545 | Original | +| Kimi | `kimi` | Device Code | none | Original | +| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | none by default | Plus | +| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | Plus | +| Cursor | `cursor` | Browser URL polling | none | Plus | +| GitLab Duo | `gitlab` | Authorization Code or PAT | 17171 | Plus | +| CodeBuddy | `codebuddy` | Device-style polling | none | Plus | +| Kilo AI | `kilo` | Device Code | none | Plus | +| Qoder | `qoder` | Device Code | none | Plus | + +xAI uses CLIProxyAPI's `--xai-login` device flow. CCS stores and discovers the resulting +`xai-*.json` credentials under the canonical `xai` provider. `ccs grok` is a command alias only, +so it shares the same accounts, settings, live model catalog, and routing as `ccs xai`. ### Codex Duplicate-Email Account Identity @@ -100,16 +114,16 @@ Codex can legitimately produce multiple auth files for the same email when the u This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model. -### Hardcoded Provider Detection +### Built-In Provider Detection -CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`. +CCS derives canonical providers from `provider-capabilities.ts`. `profile-detector.ts` resolves only +explicit CLI aliases before routing through the CLIProxy execution flow. ```typescript -// Profile name matching -const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp']; +const provider = resolveCLIProxyProviderShortcut(profileName); -if (hardcodedProviders.includes(profileName)) { - return execClaudeWithCLIProxy(claudeCli, profileName, args); +if (provider) { + return execClaudeWithCLIProxy(claudeCli, provider, args); } ``` diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 48948a8e..dbac96b3 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -24,7 +24,10 @@ import { import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; -import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { + CLIPROXY_PROVIDER_IDS, + resolveCLIProxyProviderShortcut, +} from '../cliproxy/provider-capabilities'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -332,11 +335,12 @@ class ProfileDetector { } // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) - if (isCLIProxyProvider(profileName)) { + const builtinProvider = resolveCLIProxyProviderShortcut(profileName); + if (builtinProvider) { return { type: 'cliproxy', - name: profileName, - provider: profileName, + name: builtinProvider, + provider: builtinProvider, }; } diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index 9fe98a30..d7e4dfda 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -37,11 +37,30 @@ describe('model-catalog compatibility lookups', () => { }); it('maps all dashboard providers to upstream catalog channels', () => { + expect(SYNCABLE_PROVIDERS).toContain('xai'); expect(SYNCABLE_PROVIDERS).toContain('qwen'); expect(SYNCABLE_PROVIDERS).toContain('iflow'); expect(SYNCABLE_PROVIDERS).toContain('kiro'); expect(SYNCABLE_PROVIDERS).toContain('ghcp'); expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot'); + expect(PROVIDER_TO_CHANNEL.xai).toBe('xai'); + }); + + it('merges live xAI model metadata while preserving the static coding default', () => { + const catalog = mergeCatalog('xai', [ + { id: 'grok-build-0.1', display_name: 'Grok Build 0.1' }, + { + id: 'grok-4.5', + display_name: 'Grok 4.5', + thinking: { levels: ['low', 'medium', 'high'] }, + }, + ]); + + expect(catalog?.defaultModel).toBe('grok-build-0.1'); + expect(catalog?.models[1]?.thinking).toMatchObject({ + type: 'levels', + levels: ['low', 'medium', 'high'], + }); }); it('does not re-add stale static-only models when live catalog data is present', () => { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 34d0a133..29ac5d5d 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -63,6 +63,32 @@ describe('Model Catalog', () => { }); }); + describe('xAI models', () => { + it('contains the xAI provider catalog with the coding default', () => { + const { MODEL_CATALOG } = modelCatalog; + assert(MODEL_CATALOG.xai, 'Should have xai provider'); + assert.strictEqual(MODEL_CATALOG.xai.provider, 'xai'); + assert.strictEqual(MODEL_CATALOG.xai.displayName, 'xAI (Grok)'); + assert.strictEqual(MODEL_CATALOG.xai.defaultModel, 'grok-build-0.1'); + }); + + it('matches the CLIProxyAPI text-model fallback set', () => { + const { MODEL_CATALOG } = modelCatalog; + const ids = MODEL_CATALOG.xai.models.map((model) => model.id); + assert.deepStrictEqual(ids, [ + 'grok-build-0.1', + 'grok-4.5', + 'grok-4.3', + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-3-mini', + 'grok-3-mini-fast', + 'grok-composer-2.5-fast', + ]); + }); + }); + describe('AGY models', () => { it('has correct default model', () => { const { MODEL_CATALOG } = modelCatalog; diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index b9665bd2..1b480026 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -11,6 +11,7 @@ import { getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, + resolveCLIProxyProviderShortcut, QUOTA_SUPPORTED_PROVIDER_IDS, isQuotaSupportedProvider, QUOTA_PROVIDER_OPTION_VALUES, @@ -25,6 +26,7 @@ import { getKiroCallbackPort, getKiroCLIAuthArgs, getKiroCLIAuthFlag, + getOAuthConfig, normalizeKiroIDCFlow, normalizeKiroAuthMethod, OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, @@ -36,6 +38,7 @@ describe('provider-capabilities', () => { expect(CLIPROXY_PROVIDER_IDS).toEqual([ 'gemini', 'codex', + 'xai', 'agy', 'qwen', 'iflow', @@ -61,6 +64,7 @@ describe('provider-capabilities', () => { it('returns providers by OAuth flow capability', () => { expect(getProvidersByOAuthFlow('device_code')).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -82,6 +86,7 @@ describe('provider-capabilities', () => { it('separates browser URL auth providers from verification-code device flows', () => { expect(getDeviceCodeVerificationProviders()).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -101,11 +106,20 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('anthropic')).toBe('claude'); expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); expect(mapExternalProviderName('tencent')).toBe('codebuddy'); + expect(mapExternalProviderName('grok')).toBe('xai'); + expect(mapExternalProviderName('x.ai')).toBe('xai'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); }); + it('keeps CLI aliases explicit and canonical', () => { + expect(resolveCLIProxyProviderShortcut('xai')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut('grok')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut(' GROK ')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut('anthropic')).toBeNull(); + }); + it('exposes quota-supported providers and guards correctly', () => { expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']); expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ @@ -130,6 +144,7 @@ describe('provider-capabilities', () => { it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('xai')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('gitlab')).toBe(17171); @@ -138,6 +153,7 @@ describe('provider-capabilities', () => { expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); + expect(getProviderDisplayName('xai')).toBe('xAI (Grok)'); }); it('exposes auth start support separately from OAuth flow type', () => { @@ -147,6 +163,15 @@ describe('provider-capabilities', () => { ); expect(getUnsupportedAuthStartReason('kiro')).toBeNull(); expect(getUnsupportedAuthStartReason('qoder')).toBeNull(); + expect(getUnsupportedAuthStartReason('xai')).toBeNull(); + }); + + it('uses the CLIProxyAPI xAI OAuth command contract', () => { + expect(getOAuthConfig('xai')).toMatchObject({ + provider: 'xai', + authFlag: '--xai-login', + }); + expect(AUTH_CALLBACK_PORTS.xai).toBeUndefined(); }); it('throws when provider aliases collide across providers', () => { diff --git a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js index 31cc52a1..9899ad7a 100644 --- a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js +++ b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js @@ -128,6 +128,36 @@ describe('Account Manager - discoverExistingAccounts', () => { assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider'); }); + it('discovers xAI accounts with type="xai"', () => { + createAuthFile('xai-user@example.com.json', { + type: 'xai', + email: 'user@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.xai, 'xai type should map to xai provider'); + assert(accounts.providers.xai.accounts['user@example.com']); + }); + + it('keeps multiple xAI accounts distinct when token metadata has no email', () => { + createAuthFile('xai-subject-one.json', { + type: 'xai', + }); + createAuthFile('xai-subject-two.json', { + type: 'xai', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert.deepStrictEqual(Object.keys(accounts.providers.xai.accounts).sort(), [ + 'subject-one', + 'subject-two', + ]); + }); + it('handles case-insensitive type values', () => { createAuthFile('kiro-test.json', { type: 'KIRO', diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index f64b2cd6..839510ee 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -11,7 +11,7 @@ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; * Providers that typically have empty email in OAuth token files. * For these providers, nickname is used as accountId instead of email. */ -export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; +export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp', 'xai']; /** Account information */ export interface AccountInfo { diff --git a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts index 221f0f57..d81b97a3 100644 --- a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts +++ b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts @@ -11,6 +11,7 @@ describe('auth-types paste-callback start path', () => { '/v0/management/gemini-cli-auth-url?is_webui=true' ); expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); + expect(getPasteCallbackStartPath('xai')).toBe('/v0/management/xai-auth-url?is_webui=true'); expect(getPasteCallbackStartPath('agy')).toBe( '/v0/management/antigravity-auth-url?is_webui=true' ); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts new file mode 100644 index 00000000..6a91c889 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; +import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics'; +import { buildOAuthArgs } from '../oauth-handler'; + +describe('xAI OAuth process arguments', () => { + it('starts CLIProxy device auth without a callback port argument', () => { + expect(OAUTH_CALLBACK_PORTS.xai).toBeNull(); + expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([ + '--config', + '/tmp/cliproxy-config.yaml', + '--xai-login', + ]); + }); + + it('uses no-browser mode in headless environments', () => { + expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', true, false)).toContain( + '--no-browser' + ); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 87756f16..9ae13c83 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -4,11 +4,28 @@ import { extractLikelyAuthFailureFromLogs, extractLikelyAuthFailureFromStderr, extractLikelyOAuthAuthorizationUrl, + extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, validateManualCallbackUrl, } from '../oauth-process'; +describe('oauth-process device code parsing', () => { + it('parses the xAI device authorization output contract', () => { + const output = ` +To authenticate, please visit: +https://accounts.x.ai/oauth2/device?user_code=ABCD-1234 + +Then enter this code: ABCD-1234 +`; + + expect(extractDeviceCodePrompt(output)).toEqual({ + userCode: 'ABCD-1234', + verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234', + }); + }); +}); + describe('oauth-process stderr parsing', () => { it('does not match provider-specific patterns for other providers', () => { const stderr = diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index ee44afa1..3e321a8b 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -149,6 +149,7 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * OAuth flow types per provider: * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 + * - xAI: Device Code Flow (polling-based, NO callback port needed) * - Agy: Authorization Code Flow with local callback server on port 51121 * - iFlow: Authorization Code Flow with local callback server on port 11451 * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) @@ -228,6 +229,13 @@ export const OAUTH_CONFIGS: Record = { scopes: ['openid', 'profile'], authFlag: '--codex-login', }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + authUrl: 'https://accounts.x.ai/oauth2/device', + scopes: ['openid', 'profile', 'email', 'offline_access', 'grok-cli:access', 'api:access'], + authFlag: '--xai-login', + }, agy: { provider: 'agy', displayName: 'Antigravity', diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2cde9b35..14264460 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -1,7 +1,7 @@ /** * OAuth Handler for CLIProxyAPI * - * Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity, Kiro, Copilot). + * Manages OAuth authentication flow for CLIProxy providers such as Gemini, Codex, xAI, and Kiro. * CLIProxyAPI handles OAuth internally - we just need to: * 1. Check if auth exists (token files in CCS auth directory) * 2. Trigger OAuth flow by spawning binary with auth flag @@ -605,7 +605,7 @@ async function prepareBinary( } } -function buildOAuthArgs( +export function buildOAuthArgs( provider: CLIProxyProvider, configPath: string, headless: boolean, diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 10c9a806..f94b77f7 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -127,6 +127,22 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; } +export function extractDeviceCodePrompt(output: string): { + userCode: string; + verificationUrl: string | null; +} | null { + const codeMatch = output.match( + /(?:enter\s+)?(?:this\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + if (!codeMatch) return null; + + const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + return { + userCode: codeMatch[1].toUpperCase(), + verificationUrl: urlMatch?.[1] ?? null, + }; +} + export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -392,19 +408,14 @@ async function handleStdout( // Handle Device Code Flow: parse and display user code if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { - // Parse device/user code from various formats: - // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY" - const codeMatch = state.accumulatedOutput.match( - /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i - ); - const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + const devicePrompt = extractDeviceCodePrompt(state.accumulatedOutput); - if (codeMatch) { - state.userCode = codeMatch[1].toUpperCase(); + if (devicePrompt) { + state.userCode = devicePrompt.userCode; state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); - const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; + const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device'; // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index aa9925f5..3615f806 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,8 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi - * (CLIProxyAPIPlus handles refresh automatically in background) + * - CLIProxy-delegated: gemini, codex, xai, agy, kiro, ghcp, iflow, kimi + * (the selected CLIProxy backend handles refresh automatically in background) * - Unsupported account linking: qwen * - Not implemented: claude */ diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 4a5bd42a..f1a7dde3 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -9,6 +9,7 @@ describe('base-config-loader new providers', () => { ['codebuddy', '/api/provider/codebuddy', 'auto'], ['kilo', '/api/provider/kilo', 'kilo/auto'], ['qoder', '/api/provider/qoder', 'qoder/auto'], + ['xai', '/api/provider/xai', 'grok-build-0.1'], ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { const config = loadBaseConfig(provider); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index a12906fb..4bddff8a 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -698,7 +698,7 @@ routing: // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} -# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) +# Supports: gemini, codex, xai, agy, qwen, iflow, and other registered providers (concurrent usage) # Generated: ${new Date().toISOString()} # # This config is auto-managed by CCS. Manual edits may be overwritten. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 94cf22b6..f698b590 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -301,6 +301,75 @@ export const MODEL_CATALOG: Partial> = }, ], }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { + id: 'grok-build-0.1', + name: 'Grok Build 0.1', + description: 'Fast coding model for agentic software engineering workflows', + }, + { + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Frontier model for coding, engineering, and agentic workflows', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + zeroAllowed: false, + }, + }, + { + id: 'grok-4.3', + name: 'Grok 4.3', + description: 'General-purpose Grok model with a one-million-token context window', + extendedContext: true, + thinking: { + type: 'levels', + levels: ['none', 'low', 'medium', 'high'], + zeroAllowed: true, + }, + }, + { + id: 'grok-4.20-0309-reasoning', + name: 'Grok 4.20 0309 Reasoning', + description: 'Reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-non-reasoning', + name: 'Grok 4.20 0309 Non Reasoning', + description: 'Non-reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-multi-agent-0309', + name: 'Grok 4.20 Multi Agent 0309', + description: 'Multi-agent model with a two-million-token context window', + extendedContext: true, + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-3-mini', + name: 'Grok 3 Mini', + description: 'Compact reasoning model', + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-3-mini-fast', + name: 'Grok 3 Mini Fast', + description: 'Faster compact reasoning model', + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-composer-2.5-fast', + name: 'Grok Composer 2.5 Fast', + description: 'Fast Composer model for lightweight coding tasks', + }, + ], + }, qoder: { provider: 'qoder', displayName: 'Qoder', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index b72bf07f..b9436271 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -24,6 +24,8 @@ export interface ProviderCapabilities { authFilePrefixes: readonly string[]; /** Token JSON "type" values accepted for this provider. */ tokenTypeValues: readonly string[]; + /** Alternative names accepted as first-class CCS CLI shortcuts. */ + cliAliases?: readonly string[]; /** * Alternative provider names used by CLIProxyAPI or stats endpoints. * These aliases normalize external names to canonical CCS provider IDs. @@ -58,6 +60,20 @@ export const PROVIDER_CAPABILITIES: Record = buildProviderAliasMap(); +const PROVIDER_CLI_SHORTCUT_MAP: ReadonlyMap = (() => { + const shortcutMap = new Map(); + for (const provider of CLIPROXY_PROVIDER_IDS) { + shortcutMap.set(provider, provider); + for (const alias of PROVIDER_CAPABILITIES[provider].cliAliases ?? []) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + const existingProvider = shortcutMap.get(normalized); + if (existingProvider && existingProvider !== provider) { + throw new ConfigError( + `Provider CLI alias collision for "${normalized}": ${existingProvider} and ${provider}` + ); + } + shortcutMap.set(normalized, provider); + } + } + return shortcutMap; +})(); + export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { return PROVIDER_ID_SET.has(provider as CLIProxyProvider); } +/** Resolve canonical provider IDs and explicitly supported CLI shortcut aliases. */ +export function resolveCLIProxyProviderShortcut(providerName: string): CLIProxyProvider | null { + const normalized = providerName.trim().toLowerCase(); + if (!normalized) return null; + return PROVIDER_CLI_SHORTCUT_MAP.get(normalized) ?? null; +} + +export function getProviderCLIAliases(provider: CLIProxyProvider): readonly string[] { + return PROVIDER_CAPABILITIES[provider].cliAliases ?? []; +} + export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { return PROVIDER_CAPABILITIES[provider]; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 75fe40d6..429a41e0 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -33,6 +33,7 @@ const CHANNEL_TO_PROVIDER: Record = { claude: 'claude', gemini: 'gemini', codex: 'codex', + xai: 'xai', qwen: 'qwen', iflow: 'iflow', kimi: 'kimi', diff --git a/src/cliproxy/types/provider-types.ts b/src/cliproxy/types/provider-types.ts index e96fade7..e241d259 100644 --- a/src/cliproxy/types/provider-types.ts +++ b/src/cliproxy/types/provider-types.ts @@ -6,6 +6,7 @@ export type CLIProxyProvider = | 'gemini' | 'codex' + | 'xai' | 'agy' | 'qwen' | 'iflow' diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index e5e269b9..1623b9b2 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,5 +1,9 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; -import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderCLIAliases, + getProviderDisplayName, +} from '../cliproxy/provider-capabilities'; export type HelpTopicName = | 'profiles' @@ -200,6 +204,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO { gemini: 'Google Gemini via CLIProxy OAuth', codex: 'OpenAI Codex via CLIProxy OAuth', + xai: 'xAI Grok via CLIProxy OAuth (alias: grok)', agy: 'Antigravity via CLIProxy OAuth', qwen: 'Qwen Code via CLIProxy; account linking unsupported', iflow: 'iFlow via CLIProxy OAuth', @@ -216,6 +221,15 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO }) ); +/** Additional top-level CLI aliases; canonical provider lists remain alias-free. */ +export const BUILTIN_PROVIDER_ALIAS_SHORTCUTS: readonly ShortcutEntry[] = + CLIPROXY_PROVIDER_IDS.flatMap((provider) => + getProviderCLIAliases(provider).map((name) => ({ + name, + summary: `Alias for ${getProviderDisplayName(provider)} provider shortcut`, + })) + ); + export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, { diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 1838db9c..6d6816cd 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -2,6 +2,7 @@ import ProfileDetector from '../auth/profile-detector'; import { API_SUBCOMMANDS, AUTH_SUBCOMMANDS, + BUILTIN_PROVIDER_ALIAS_SHORTCUTS, BUILTIN_PROVIDER_SHORTCUTS, CLEANUP_FLAGS, COMMAND_FLAG_SUGGESTIONS, @@ -65,6 +66,7 @@ function getTopLevelSuggestions(): CompletionSuggestion[] { ...getPublicRootCommandTokens().map((value) => suggestion(value)), ...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), ...getDynamicProfileSuggestions(), ]; } @@ -262,6 +264,7 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ...getProfileNames('settings'), ...getProfileNames('accounts'), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => entry.name), ]), COMMAND_FLAG_SUGGESTIONS.env ); @@ -280,7 +283,11 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'] ); default: - if (BUILTIN_PROVIDER_SHORTCUTS.some((entry) => entry.name === command)) { + if ( + [...BUILTIN_PROVIDER_SHORTCUTS, ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS].some( + (entry) => entry.name === command + ) + ) { if (command === 'agy') return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']); if (command === 'kiro') { diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index bd511b53..d93ca244 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -63,7 +63,7 @@ export function generateYamlWithComments(config: UnifiedConfig): string { // CLIProxy section lines.push('# ----------------------------------------------------------------------------'); - lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); + lines.push('# CLIProxy: OAuth-based providers (gemini, codex, xai/grok, agy, qwen, iflow)'); lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); lines.push('# Edit the settings file directly to customize model or other settings.'); lines.push( diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index 32b4c897..a47bed12 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -9,7 +9,7 @@ */ import { info } from '../utils/ui'; -import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { resolveCLIProxyProviderShortcut } from '../cliproxy/provider-capabilities'; import { isCopilotSubcommandToken } from '../copilot/constants'; import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { isCacheStale } from '../utils/update-checker'; @@ -111,14 +111,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1 && - (args.includes('--help') || args.includes('-h')) - ) { + const shortcutProvider = + typeof firstArg === 'string' ? resolveCLIProxyProviderShortcut(firstArg) : null; + if (shortcutProvider && args.length > 1 && (args.includes('--help') || args.includes('-h'))) { const { showProviderShortcutHelp } = await import('../commands/help-command'); - await showProviderShortcutHelp(firstArg); + await showProviderShortcutHelp(shortcutProvider); return true; } diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 8769615b..c028453a 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -94,12 +94,20 @@ describe('ProfileDetector', () => { }); it('should detect newly added cliproxy providers', () => { + expect(detector.detectProfileType('xai').provider).toBe('xai'); expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('kilo').provider).toBe('kilo'); expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); + it('should resolve the grok CLI alias to canonical xai routing', () => { + const result = detector.detectProfileType('grok'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('xai'); + expect(result.provider).toBe('xai'); + }); + it('should detect settings-based profile from unified config', () => { const settingsPath = path.join(tempDir, 'glm.settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b98ae57a..a5ff4eff 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -82,6 +82,8 @@ describe('completion backend', () => { expect(values).toContain('cursor'); expect(values).toContain('copilot'); expect(values).toContain('gemini'); + expect(values).toContain('xai'); + expect(values).toContain('grok'); expect(values).toContain('gitlab'); expect(values).toContain('codebuddy'); expect(values).toContain('kilo'); @@ -94,6 +96,11 @@ describe('completion backend', () => { expect(values).not.toContain('--uninstall'); }); + test('offers provider flags for both xai and grok shortcuts', () => { + expect(suggestionValues(['xai'])).toEqual(expect.arrayContaining(['--auth', '--accounts'])); + expect(suggestionValues(['grok'])).toEqual(expect.arrayContaining(['--auth', '--accounts'])); + }); + test('suggests help topics from the help command', () => { const values = suggestionValues(['help']); expect(values).toContain('profiles'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 739f4488..ba8ee389 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -57,6 +57,8 @@ describe('help command parity', () => { expect(rendered.includes('ccs help kiro')).toBe(true); expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('codex')).toBe(true); + expect(rendered.includes('xai')).toBe(true); + expect(rendered.includes('alias: grok')).toBe(true); expect(rendered.includes('ghcp')).toBe(true); expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); diff --git a/ui/public/assets/providers/xai.svg b/ui/public/assets/providers/xai.svg new file mode 100644 index 00000000..f5a97161 --- /dev/null +++ b/ui/public/assets/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI Grok + + diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 77e8a2fb..837e239a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,7 @@ /** * Add Account Dialog Component * Uses /start-url to get OAuth URL + polls for completion via management API. - * For Device Code flows (ghcp, qwen, kiro): Uses /start endpoint which spawns CLIProxy + * For verification-code flows: Uses /start endpoint which spawns CLIProxy * binary and emits WebSocket events. DeviceCodeDialog handles user code display. * Shows auth URL + callback paste field. Polling auto-closes on success. * For Kiro: Also shows "Import from IDE" option. diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 1fca25f4..8c2141d4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'claude', 'gemini', 'codex', + 'xai', 'cursor', 'gitlab', 'codebuddy', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 2a6475ee..97df35d4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -382,6 +382,68 @@ export const MODEL_CATALOGS: Record = { }, ], }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { + id: 'grok-build-0.1', + name: 'Grok Build 0.1', + description: 'Fast coding model for agentic software engineering workflows', + presetMapping: { + default: 'grok-build-0.1', + opus: 'grok-4.5', + sonnet: 'grok-build-0.1', + haiku: 'grok-composer-2.5-fast', + }, + }, + { + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Frontier model for coding, engineering, and agentic workflows', + }, + { + id: 'grok-4.3', + name: 'Grok 4.3', + description: 'General-purpose Grok model with a one-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-reasoning', + name: 'Grok 4.20 0309 Reasoning', + description: 'Reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-non-reasoning', + name: 'Grok 4.20 0309 Non Reasoning', + description: 'Non-reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-multi-agent-0309', + name: 'Grok 4.20 Multi Agent 0309', + description: 'Multi-agent model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-3-mini', + name: 'Grok 3 Mini', + description: 'Compact reasoning model', + }, + { + id: 'grok-3-mini-fast', + name: 'Grok 3 Mini Fast', + description: 'Faster compact reasoning model', + }, + { + id: 'grok-composer-2.5-fast', + name: 'Grok Composer 2.5 Fast', + description: 'Fast Composer model for lightweight coding tasks', + }, + ], + }, qwen: { provider: 'qwen', displayName: 'Qwen', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index cda84a9b..d2adf7c6 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -98,6 +98,7 @@ export const PROVIDER_ASSETS: Partial> = { gemini: '/assets/providers/gemini-color.svg', agy: '/assets/providers/agy.png', codex: '/assets/providers/codex-color.svg', + xai: '/assets/providers/xai.svg', qwen: '/assets/providers/qwen-color.svg', iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', @@ -128,6 +129,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gemini: '#4285F4', agy: '#f3722c', codex: '#10a37f', + xai: '#111827', openai: '#111827', vertex: '#4285F4', iflow: '#f94144', @@ -370,6 +373,7 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly { + it('mirrors the CLIProxyAPI text catalog and default tier routing', () => { + const catalog = MODEL_CATALOGS.xai; + const ids = catalog.models.map((model) => model.id); + const defaultModel = catalog.models.find((model) => model.id === catalog.defaultModel); + + expect(catalog.displayName).toBe('xAI (Grok)'); + expect(catalog.defaultModel).toBe('grok-build-0.1'); + expect(ids).toEqual([ + 'grok-build-0.1', + 'grok-4.5', + 'grok-4.3', + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-3-mini', + 'grok-3-mini-fast', + 'grok-composer-2.5-fast', + ]); + expect(defaultModel?.presetMapping).toEqual({ + default: 'grok-build-0.1', + opus: 'grok-4.5', + sonnet: 'grok-build-0.1', + haiku: 'grok-composer-2.5-fast', + }); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 8d11c520..8bcec84a 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CLIPROXY_PROVIDER_SECTIONS, + CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS, formatRequestedUpstreamModelRules, getProviderDescription, @@ -19,6 +20,7 @@ import { PROVIDER_COLORS, variantUsesPlusExtraProvider, } from '@/lib/provider-config'; +import { PROVIDERS as WIZARD_PROVIDERS } from '@/components/setup/wizard/constants'; describe('provider model mapping helpers', () => { it('parses requested=upstream rules into stored upstream+alias pairs', () => { @@ -61,6 +63,7 @@ describe('provider presentation metadata', () => { expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']); expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini'); expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi'); + expect(CORE_CLIPROXY_PROVIDERS).toContain('xai'); expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([ 'kiro', 'ghcp', @@ -76,6 +79,12 @@ describe('provider presentation metadata', () => { expect(isPlusExtraProvider('gemini')).toBe(false); }); + it('keeps every non-deprecated canonical provider available in the setup wizard', () => { + expect(WIZARD_PROVIDERS.map((provider) => provider.id).sort()).toEqual( + CLIPROXY_PROVIDERS.filter((provider) => provider !== 'ghcp').sort() + ); + }); + it('groups provider-backed data by shared section metadata', () => { const grouped = groupProvidersBySection( [ @@ -96,6 +105,7 @@ describe('provider presentation metadata', () => { expect(isDeviceCodeProvider('cursor')).toBe(false); expect(isDeviceCodeProvider('ghcp')).toBe(true); expect(isDeviceCodeProvider('qwen')).toBe(true); + expect(isDeviceCodeProvider('xai')).toBe(true); }); it('detects plus-extra providers inside composite variants', () => { @@ -120,6 +130,7 @@ describe('provider presentation metadata', () => { }); it.each([ + ['xai', 'xAI (Grok)', 'Grok coding and reasoning models', '/assets/providers/xai.svg'], ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'], [ @@ -153,10 +164,15 @@ describe('provider presentation metadata', () => { textClass: 'text-rose-600', letter: 'K', }); + expect(getProviderFallbackVisual('xai')).toEqual({ + textClass: 'text-slate-900', + letter: 'X', + }); expect(PROVIDER_COLORS.cursor).toBe('#111827'); expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); + expect(PROVIDER_COLORS.xai).toBe('#111827'); }); }); From 4e4c949967f3aa112795e1bf0cc3ee8ebdbd5b3b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 19:04:07 -0400 Subject: [PATCH 2/3] fix(cliproxy): harden xAI provider contracts --- .../__tests__/model-catalog-compat.test.ts | 12 ++++++++ src/cliproxy/__tests__/model-catalog.test.js | 17 +++++++++++ .../oauth-process-error-parser.test.ts | 20 +++++++++++++ src/cliproxy/auth/oauth-process.ts | 21 +++++++++++++- src/cliproxy/model-catalog.ts | 5 +--- src/cliproxy/services/catalog-cache.ts | 8 +++-- ui/src/lib/model-catalogs.ts | 13 +++++---- .../unit/ui/lib/model-catalogs-xai.test.ts | 29 ++++++++++++++++++- 8 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index d7e4dfda..ff8bce9f 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -63,6 +63,18 @@ describe('model-catalog compatibility lookups', () => { }); }); + it('does not export extended-context capability from live xAI context length', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + context_length: 2_000_000, + }, + ]); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + }); + it('does not re-add stale static-only models when live catalog data is present', () => { const catalog = mergeCatalog('gemini', [ { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 29ac5d5d..4377ed32 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -87,6 +87,23 @@ describe('Model Catalog', () => { 'grok-composer-2.5-fast', ]); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog; + + for (const model of MODEL_CATALOG.xai.models) { + assert.notStrictEqual( + model.extendedContext, + true, + `${model.id} must not advertise extended-context metadata` + ); + assert.strictEqual( + supportsExtendedContext('xai', model.id), + false, + `${model.id} must not advertise [1m] suffix support` + ); + } + }); }); describe('AGY models', () => { diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 9ae13c83..f89398c3 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -7,6 +7,7 @@ import { extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, + resolveDeviceCodeVerificationUrl, validateManualCallbackUrl, } from '../oauth-process'; @@ -24,6 +25,18 @@ Then enter this code: ABCD-1234 verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234', }); }); + + it('preserves a parsed xAI verification URL exactly', () => { + const parsedUrl = 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234&source=cliproxy'; + + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', parsedUrl)).toBe(parsedUrl); + }); + + it('uses an xAI-specific device URL when upstream output omits the URL', () => { + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', null)).toBe( + 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234' + ); + }); }); describe('oauth-process stderr parsing', () => { @@ -49,6 +62,13 @@ describe('oauth-process stderr parsing', () => { expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch'); }); + it('extracts the current upstream xAI authentication failure format', () => { + const stderr = + 'time="2026-07-18T00:00:00Z" level=error msg="xAI authentication failed: xai device code expired"'; + + expect(extractLikelyAuthFailureFromStderr('xai', stderr)).toBe('xai device code expired'); + }); + it('caps extracted message length to prevent noisy broadcasts', () => { const longSuffix = 'x'.repeat(400); const stderr = `level=error msg="Authentication failed: ${longSuffix}"`; diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f94b77f7..edb3023b 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -143,6 +143,20 @@ export function extractDeviceCodePrompt(output: string): { }; } +export function resolveDeviceCodeVerificationUrl( + provider: CLIProxyProvider, + userCode: string, + parsedUrl: string | null +): string { + if (parsedUrl) { + return parsedUrl; + } + if (provider === 'xai') { + return `https://accounts.x.ai/oauth2/device?user_code=${encodeURIComponent(userCode)}`; + } + return 'https://github.com/login/device'; +} + export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -415,7 +429,11 @@ async function handleStdout( state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); - const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device'; + const verificationUrl = resolveDeviceCodeVerificationUrl( + options.provider, + state.userCode, + devicePrompt.verificationUrl + ); // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { @@ -525,6 +543,7 @@ export function extractLikelyAuthFailureFromLogs( }); const providerPatterns: Partial> = { + xai: [/xai authentication failed:\s*(.+)/i], ghcp: [ /github copilot authentication failed:\s*(.+)/i, /failed to verify copilot access[^:]*:\s*(.+)/i, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f698b590..f300fe13 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -325,7 +325,6 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['none', 'low', 'medium', 'high'], @@ -336,19 +335,16 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { @@ -765,6 +761,7 @@ export function supportsThinking(provider: CLIProxyProvider, modelId: string): b * Returns true if model has extendedContext: true in catalog. */ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: string): boolean { + if (provider === 'xai') return false; const model = findModel(provider, modelId); return model?.extendedContext === true; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 429a41e0..0c6511cd 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -215,13 +215,15 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine } /** Map RemoteModelInfo to ModelEntry */ -function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry { +function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelInfo): ModelEntry { const entry: ModelEntry = { id: remote.id, name: remote.display_name || remote.id, }; if (remote.description) entry.description = remote.description; - if (remote.context_length && remote.context_length >= 1_000_000) { + // xAI context length is inherent to the model ID; its API does not accept + // Claude's [1m] model suffix. + if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) { entry.extendedContext = true; } const thinking = mapThinking(remote.thinking); @@ -264,7 +266,7 @@ export function mergeCatalog( const mergedModels: ModelEntry[] = []; for (const remote of filteredRemoteModels) { - const remoteEntry = mapRemoteToModelEntry(remote); + const remoteEntry = mapRemoteToModelEntry(provider, remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); if (staticEntry) { const mergedThinking = remoteEntry.thinking diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 97df35d4..34c82ca4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -407,25 +407,21 @@ export const MODEL_CATALOGS: Record = { id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-3-mini', @@ -1124,7 +1120,8 @@ export function buildUiCatalog( provider: string, liveCatalog: ProviderCatalog | undefined ): ProviderCatalog | undefined { - const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()]; + const normalizedProvider = provider.toLowerCase(); + const staticCatalog = MODEL_CATALOGS[normalizedProvider]; if (!liveCatalog || liveCatalog.models.length === 0) { return staticCatalog; } @@ -1145,7 +1142,10 @@ export function buildUiCatalog( issueUrl: staticModel?.issueUrl, deprecated: staticModel?.deprecated, deprecationReason: staticModel?.deprecationReason, - extendedContext: model.extendedContext ?? staticModel?.extendedContext, + extendedContext: + normalizedProvider === 'xai' + ? undefined + : (model.extendedContext ?? staticModel?.extendedContext), presetMapping: staticModel?.presetMapping, }; }); @@ -1344,5 +1344,6 @@ export function supportsExtendedContext( modelId: string, catalogOverride?: ProviderCatalog ): boolean { + if (provider.toLowerCase() === 'xai') return false; return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true; } diff --git a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts index 64a4062b..bb88a8c5 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { buildUiCatalog, MODEL_CATALOGS, supportsExtendedContext } from '@/lib/model-catalogs'; describe('xAI model catalog defaults', () => { it('mirrors the CLIProxyAPI text catalog and default tier routing', () => { @@ -27,4 +27,31 @@ describe('xAI model catalog defaults', () => { haiku: 'grok-composer-2.5-fast', }); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const catalog = MODEL_CATALOGS.xai; + + for (const model of catalog.models) { + expect(model.extendedContext).not.toBe(true); + expect(supportsExtendedContext('xai', model.id)).toBe(false); + } + }); + + it('strips generic extended-context metadata from live xAI catalogs', () => { + const catalog = buildUiCatalog('xai', { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-4.3', + models: [ + { + id: 'grok-4.3', + name: 'Grok 4.3', + extendedContext: true, + }, + ], + }); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + expect(supportsExtendedContext('xai', 'grok-4.3', catalog)).toBe(false); + }); }); From 44718e98ef2c3f9ec2cbe7434edb33cd9eecb390 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 19:04:37 -0400 Subject: [PATCH 3/3] fix(auth): preserve xAI profile compatibility --- src/api/services/index.ts | 8 +- src/api/services/profile-lifecycle-service.ts | 45 ++- src/api/services/profile-writer.ts | 32 +- src/api/services/validation-service.ts | 25 +- src/auth/account-context.ts | 8 +- src/auth/commands/create-command.ts | 17 +- src/auth/profile-detector.ts | 223 ++++++++++--- src/commands/api-command/create-command.ts | 30 +- src/commands/cliproxy/variant-subcommand.ts | 21 +- src/config/reserved-names.ts | 35 +- src/web-server/routes/profile-routes.ts | 8 +- tests/unit/account-context.test.ts | 5 +- .../api/profile-lifecycle-service.test.ts | 299 +++++++++++++++++- tests/unit/auth-resources-command.test.ts | 98 ++++++ tests/unit/auth/profile-detector.test.ts | 186 ++++++++++- .../api-create-grandfathered-force.test.ts | 113 +++++++ .../commands/cliproxy-variant-args.test.ts | 18 +- tests/unit/unified-config.test.ts | 8 + .../profile-routes-lifecycle.test.ts | 93 +++++- 19 files changed, 1166 insertions(+), 106 deletions(-) create mode 100644 tests/unit/commands/api-create-grandfathered-force.test.ts diff --git a/src/api/services/index.ts b/src/api/services/index.ts index 8b213e17..f4e00c66 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -5,7 +5,13 @@ */ // Validation services -export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service'; +export { + validateApiName, + validateApiNameSyntax, + validateUrl, + getUrlWarning, + sanitizeBaseUrl, +} from './validation-service'; // Profile types export { diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 39c6cf82..a9cf061d 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -9,14 +9,19 @@ import * as path from 'path'; import type { Config, Settings } from '../../types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; +import { ConfigError, ProfileError } from '../../errors/error-types'; import { getConfigPath } from '../../utils/config-manager'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { isSensitiveKey } from '../../utils/sensitive-keys'; -import { isReservedName } from '../../config/reserved-names'; +import { + canOverwriteGrandfatheredReservedProfileName, + isGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; -import { validateApiName } from './validation-service'; -import { listApiProfiles } from './profile-reader'; +import { validateApiName, validateApiNameSyntax } from './validation-service'; +import { apiProfileExists, listApiProfiles } from './profile-reader'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; import type { ApiProfileExportBundle, @@ -44,7 +49,7 @@ function parseTargetValue(value: unknown): TargetType | null { } function validateProfileNameForPath(name: string, label: string): string | null { - const validationError = validateApiName(name); + const validationError = validateApiNameSyntax(name); if (validationError) { return `Invalid ${label} profile name "${name}": ${validationError}`; } @@ -70,7 +75,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa if (isUnifiedMode()) { mutateConfig((config) => { if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = { @@ -85,7 +90,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa const configPath = getConfigPath(); const config = loadConfigSafe() as Config; if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`; @@ -124,7 +129,7 @@ function readJsonObject(filePath: string): Record { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Settings file must contain a JSON object.'); + throw new ConfigError('Settings file must contain a JSON object.', filePath); } return parsed as Record; } @@ -158,7 +163,10 @@ export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult { .filter((file) => !registeredSettings.has(file)) .filter((file) => !file.startsWith('base-')) .filter((file) => !ignoredNames.has(file)) - .filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length))) + .filter((file) => { + const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); + return !isReservedName(name) || isGrandfatheredReservedProfileName(name); + }) .map((file) => { const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); const settingsPath = path.join(ccsDir, file); @@ -206,7 +214,7 @@ export function registerApiProfileOrphans(options?: { const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] }; for (const orphan of selected) { - const nameError = validateApiName(orphan.name); + const nameError = validateApiNameSyntax(orphan.name); if (nameError) { result.skipped.push({ name: orphan.name, @@ -247,7 +255,16 @@ export function copyApiProfile( if (sourceError) return { success: false, error: sourceError }; const destinationError = validateApiName(destination); - if (destinationError) return { success: false, error: destinationError }; + const canOverwriteGrandfatheredDestination = canOverwriteGrandfatheredReservedProfileName( + destination, + { + force: options?.force === true, + exists: apiProfileExists(destination), + } + ); + if (destinationError && !canOverwriteGrandfatheredDestination) { + return { success: false, error: destinationError }; + } const sourceSettingsPath = getProfileSettingsPath(source); if (!fs.existsSync(sourceSettingsPath)) { @@ -363,7 +380,13 @@ export function importApiProfileBundle( const name = options?.name || input.profile.name; const nameError = validateApiName(name); - if (nameError) return { success: false, error: nameError }; + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, error: nameError }; + } const bundleTarget = parseTargetValue(input.profile.target); if (input.profile.target !== undefined && bundleTarget === null) { diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index dc366eae..2683d170 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,10 @@ import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; -import { isReservedName } from '../../config/reserved-names'; +import { + canOverwriteGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -266,8 +269,18 @@ export function createApiProfile( models: ModelMapping, target: TargetType = 'claude', provider?: string, - extraModels?: string[] + extraModels?: string[], + options?: { force?: boolean } ): CreateApiProfileResult { + const nameError = validateApiName(name); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, settingsFile: '', error: nameError }; + } + try { const deniedReason = getDeniedModelReason(baseUrl, models); if (deniedReason) { @@ -304,10 +317,17 @@ export function createCliproxyBridgeProfile( const providedName = options.name?.trim(); if (providedName) { const nameError = validateApiName(providedName); - if (nameError) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force: options.force === true, + exists: apiProfileExists(providedName), + } + ); + if (nameError && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', error: nameError }; } - if (isReservedName(providedName)) { + if (isReservedName(providedName) && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', @@ -332,7 +352,9 @@ export function createCliproxyBridgeProfile( resolved.apiKey, resolved.models, resolved.target, - provider + provider, + undefined, + { force: options.force } ); const detectedBridge = resolveCliproxyBridgeMetadata({ env: { diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts index e9b830ce..1487a85c 100644 --- a/src/api/services/validation-service.ts +++ b/src/api/services/validation-service.ts @@ -8,10 +8,13 @@ import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; /** - * Validate API profile name + * Validate API profile name syntax for an existing profile reference. + * + * Existing profiles may use a name that became reserved after they were + * created, so this validator intentionally excludes built-in-name policy. * @returns Error message if invalid, null if valid */ -export function validateApiName(name: string): string | null { +export function validateApiNameSyntax(name: string): string | null { if (!name) { return 'API name is required'; } @@ -21,15 +24,27 @@ export function validateApiName(name: string): string | null { if (name.length > 32) { return 'API name must be 32 characters or less'; } - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } if (isWindowsReservedName(name)) { return `'${name}' is a Windows reserved device name and cannot be used`; } return null; } +/** + * Validate a name for creation of a new API profile. + * @returns Error message if invalid or reserved, null if valid + */ +export function validateApiName(name: string): string | null { + const syntaxError = validateApiNameSyntax(name); + if (syntaxError) { + return syntaxError; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + /** * Validate URL format * @returns Error message if invalid, null if valid diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index 52ce2eb8..6c030b4c 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -5,6 +5,8 @@ * project workspace context with other accounts in the same context group. */ +import { isReservedName } from '../config/reserved-names'; + export type AccountContextMode = 'isolated' | 'shared'; export type AccountContinuityMode = 'standard' | 'deeper'; @@ -36,7 +38,6 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard'; export const MAX_CONTEXT_GROUP_LENGTH = 64; export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; -const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']); const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -58,10 +59,7 @@ export function isValidContextGroupName(value: string): boolean { * Validate account profile naming constraints. */ export function isValidAccountProfileName(value: string): boolean { - return ( - ACCOUNT_PROFILE_NAME_PATTERN.test(value) && - !RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase()) - ); + return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value); } /** diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index ee78ffba..dd958b99 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -34,6 +34,7 @@ import { maybeShowPoolOnboardingHint, countNativeClaudeProfiles, } from '../../cliproxy/routing/pool-onboarding-hint'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); @@ -63,7 +64,19 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } - if (!isValidAccountProfileName(profileName)) { + // Check exact account ownership before applying the narrow grandfathered + // repair exception. API profiles and case variants do not qualify. + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + profileName, + { + force: force === true, + exists: existsLegacy || existsUnified, + } + ); + + if (!isValidAccountProfileName(profileName) && !canOverwriteGrandfatheredProfile) { exitWithError( 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.', ExitCode.PROFILE_ERROR @@ -71,8 +84,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } // Check if profile already exists (check both legacy and unified) - const existsLegacy = ctx.registry.hasProfile(profileName); - const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!force && (existsLegacy || existsUnified)) { // Keep the --force hint in the exitWithError message so it appears in the single output line exitWithError( diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index dbac96b3..e596a0cc 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -26,8 +26,11 @@ import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profi import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, + isCLIProxyProvider, resolveCLIProxyProviderShortcut, } from '../cliproxy/provider-capabilities'; +import { isGrandfatheredReservedProfileName } from '../config/reserved-names'; +import { ConfigError } from '../errors/error-types'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -85,6 +88,50 @@ export interface ProfileNotFoundError extends Error { availableProfiles: string; } +function hasOwnEntry(value: unknown, key: string): boolean { + return ( + typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key) + ); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isResolvableCompositeVariant(value: unknown): value is CompositeVariantConfig { + if (!isObjectRecord(value) || value.type !== 'composite') { + return false; + } + if ( + value.default_tier !== 'opus' && + value.default_tier !== 'sonnet' && + value.default_tier !== 'haiku' + ) { + return false; + } + if (!isObjectRecord(value.tiers)) { + return false; + } + const tiers = value.tiers; + + return (['opus', 'sonnet', 'haiku'] as const).every((tierName) => { + const tier = tiers[tierName]; + return ( + isObjectRecord(tier) && + typeof tier.provider === 'string' && + isCLIProxyProvider(tier.provider) && + typeof tier.model === 'string' && + tier.model.trim().length > 0 + ); + }); +} + +function isResolvableAccountProfile(value: unknown): value is ProfileMetadata { + return ( + isObjectRecord(value) && typeof value.created === 'string' && value.created.trim().length > 0 + ); +} + /** * Profile Detector Class */ @@ -133,28 +180,22 @@ class ProfileDetector { config: UnifiedConfig ): ProfileDetectionResult | null { // Check CLIProxy variants first - if (config.cliproxy?.variants?.[profileName]) { - const variant = config.cliproxy.variants[profileName]; + if (hasOwnEntry(config.cliproxy?.variants, profileName)) { + const variant = config.cliproxy?.variants?.[profileName]; + if (!isObjectRecord(variant)) { + return null; + } // Handle composite variants if ('type' in variant && variant.type === 'composite') { - const composite = variant as CompositeVariantConfig; - - // Defensive: check for missing tiers or default_tier - if (!composite.tiers || !composite.default_tier) { + if (!isResolvableCompositeVariant(variant)) { console.warn( - `[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier` + `[!] Warning: Composite variant '${profileName}' has invalid or incomplete tiers/default_tier` ); return null; } - + const composite = variant; const defaultTierConfig = composite.tiers[composite.default_tier]; - if (!defaultTierConfig) { - console.warn( - `[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'` - ); - return null; - } return { type: 'cliproxy', @@ -170,6 +211,9 @@ class ProfileDetector { } const singleVariant = variant as CLIProxyVariantConfig; + if (!isCLIProxyProvider(singleVariant.provider)) { + return null; + } return { type: 'cliproxy', name: profileName, @@ -182,11 +226,18 @@ class ProfileDetector { // Check API profiles (supports compatibility aliases, e.g. km -> kimi) for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles?.[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { continue; } const profile = config.profiles[candidate]; + if ( + !isObjectRecord(profile) || + typeof profile.settings !== 'string' || + profile.settings.trim().length === 0 + ) { + return null; + } const settingsPath = profile.settings; const settingsEnv = loadSettingsFromFile(settingsPath); const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate); @@ -204,8 +255,11 @@ class ProfileDetector { } // Check accounts - if (config.accounts?.[profileName]) { + if (hasOwnEntry(config.accounts, profileName)) { const account = config.accounts[profileName]; + if (!isResolvableAccountProfile(account)) { + return null; + } return { type: 'account', name: profileName, @@ -225,6 +279,20 @@ class ProfileDetector { return null; } + private hasUnifiedConfiguredProfile(profileName: string, config: UnifiedConfig): boolean { + return ( + hasOwnEntry(config.cliproxy?.variants, profileName) || + hasOwnEntry(config.profiles, profileName) || + hasOwnEntry(config.accounts, profileName) + ); + } + + private createConfiguredCollisionError(profileName: string, source: string): ConfigError { + return new ConfigError( + `Configured profile "${profileName}" in ${source} is invalid and cannot be resolved as a grandfathered profile.` + ); + } + /** * Read settings-based config (config.json) */ @@ -264,11 +332,12 @@ class ProfileDetector { * * Priority order: * 0. Hardcoded special runtime profiles (copilot, cursor) - * 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...) + * 0.5. Hardcoded CLIProxy profiles (except grandfathered xai/grok collisions) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] * 4. Account-based profiles (profiles.json) [legacy] + * 5. Built-in xai/grok shortcut fallback when no configured profile exists */ detectProfileType(profileName: string | null | undefined): ProfileDetectionResult { // Special case: 'default' means use default profile @@ -336,7 +405,8 @@ class ProfileDetector { // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) const builtinProvider = resolveCLIProxyProviderShortcut(profileName); - if (builtinProvider) { + const shouldGrandfatherConfiguredProfile = builtinProvider === 'xai'; + if (builtinProvider && !shouldGrandfatherConfiguredProfile) { return { type: 'cliproxy', name: builtinProvider, @@ -349,6 +419,12 @@ class ProfileDetector { if (unifiedConfig) { const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(profileName) && + this.hasUnifiedConfiguredProfile(profileName, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(profileName, 'config.yaml'); + } // Fall through to legacy if not found in unified config } @@ -357,22 +433,36 @@ class ProfileDetector { const legacyTargetMap = (config as { profile_targets?: Record }) .profile_targets; - if (config.cliproxy && config.cliproxy[profileName]) { - const variant = config.cliproxy[profileName]; - return { - type: 'cliproxy', - name: profileName, - target: variant.target, - provider: variant.provider as CLIProxyProfileName, - settingsPath: variant.settings, - port: variant.port, - }; + if (hasOwnEntry(config.cliproxy, profileName)) { + const variant = config.cliproxy?.[profileName]; + if (!isObjectRecord(variant) || !isCLIProxyProvider(variant.provider as string)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } + } else { + return { + type: 'cliproxy', + name: profileName, + target: variant.target, + provider: variant.provider, + settingsPath: variant.settings, + port: variant.port, + }; + } } // Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK if (config.profiles) { for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { + continue; + } + + const settingsPath = config.profiles[candidate]; + if (typeof settingsPath !== 'string' || settingsPath.trim().length === 0) { + if (candidate === profileName && isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } continue; } @@ -380,7 +470,7 @@ class ProfileDetector { return { type: 'settings', name: profileName, - settingsPath: config.profiles[candidate], + settingsPath, target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` @@ -392,11 +482,28 @@ class ProfileDetector { // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK const profiles = this.readProfiles(); - if (profiles.profiles && profiles.profiles[profileName]) { + if (hasOwnEntry(profiles.profiles, profileName)) { + const profile = profiles.profiles[profileName]; + if (!isResolvableAccountProfile(profile)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'profiles.json'); + } + } else { + return { + type: 'account', + name: profileName, + profile, + }; + } + } + + // xai and grok became reserved when the built-in xAI provider shipped. Existing + // configured profiles keep working, while new installs still get the shortcuts. + if (builtinProvider) { return { - type: 'account', - name: profileName, - profile: profiles.profiles[profileName], + type: 'cliproxy', + name: builtinProvider, + provider: builtinProvider, }; } @@ -426,25 +533,49 @@ class ProfileDetector { if (unifiedConfig?.default) { const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + this.hasUnifiedConfiguredProfile(unifiedConfig.default, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'config.yaml'); + } } // Check if account-based default exists (legacy) const profiles = this.readProfiles(); - if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) { - return { - type: 'account', - name: unifiedConfig.default, - profile: profiles.profiles[unifiedConfig.default], - }; + if (unifiedConfig?.default && hasOwnEntry(profiles.profiles, unifiedConfig.default)) { + const profile = profiles.profiles[unifiedConfig.default]; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: unifiedConfig.default, + profile, + }; + } } - if (profiles.default && profiles.profiles[profiles.default]) { - return { - type: 'account', - name: profiles.default, - profile: profiles.profiles[profiles.default], - }; + if (profiles.default && hasOwnEntry(profiles.profiles, profiles.default)) { + const profile = profiles.profiles[profiles.default]; + if ( + isGrandfatheredReservedProfileName(profiles.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(profiles.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: profiles.default, + profile, + }; + } } // Check if settings-based default exists diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 77b585f3..1dc86cd7 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -22,6 +22,7 @@ import { isCLIProxyProvider, } from '../../cliproxy/provider-capabilities'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -63,7 +64,8 @@ function showPresetDeprecationNotice(presetId?: string): void { async function resolveProfileName( providedName: string | undefined, - preset: ProviderPreset | null + preset: ProviderPreset | null, + force: boolean ): Promise { const name = providedName || preset?.defaultProfileName; if (!name) { @@ -73,7 +75,11 @@ async function resolveProfileName( } const error = validateApiName(name); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists: apiProfileExists(name), + }); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -83,11 +89,19 @@ async function resolveProfileName( async function resolveCliproxyProfileName( provider: string, providedName: string | undefined, - yes: boolean | undefined + yes: boolean | undefined, + force: boolean ): Promise { if (providedName) { const error = validateApiName(providedName); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force, + exists: apiProfileExists(providedName), + } + ); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -368,7 +382,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { const name = await resolveCliproxyProfileName( cliproxyProvider, parsedArgs.name, - parsedArgs.yes + parsedArgs.yes, + parsedArgs.force === true ); const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); @@ -456,7 +471,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); - const name = await resolveProfileName(parsedArgs.name, preset); + const name = await resolveProfileName(parsedArgs.name, preset, parsedArgs.force === true); if (apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -503,7 +518,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { finalModels, target, undefined, - parsedArgs.extraModels + parsedArgs.extraModels, + { force: parsedArgs.force === true } ); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 2f9fdb11..c979691b 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -31,6 +31,7 @@ import { } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; import { CompositeTierConfig } from '../../config/unified-config-types'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity'; import { isUnifiedMode } from '../../config/config-loader-facade'; @@ -120,6 +121,24 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs { return result; } +export function validateVariantCreateName( + name: string, + force: boolean, + exists: boolean = variantExists(name) +): string | null { + const error = validateProfileName(name); + if ( + error && + !canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists, + }) + ) { + return error; + } + return null; +} + function formatModelOption(model: ModelEntry): string { const tierBadge = model.tier === 'ultra' @@ -306,7 +325,7 @@ export async function handleCreate( validate: validateProfileName, }); } else { - const error = validateProfileName(name); + const error = validateVariantCreateName(name, parsedArgs.force === true); if (error) { console.log(fail(error)); process.exit(1); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index dacfb97d..42e7b6d5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -1,3 +1,5 @@ +import { ConfigError } from '../errors/error-types'; + /** * Reserved profile names that cannot be used for user-defined profiles. * These names are reserved for CLIProxy providers and CLI commands. @@ -6,6 +8,8 @@ export const RESERVED_PROFILE_NAMES = [ // CLIProxy providers (built-in OAuth) 'gemini', 'codex', + 'xai', + 'grok', 'agy', 'qwen', 'iflow', @@ -32,6 +36,14 @@ export const RESERVED_PROFILE_NAMES = [ export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; +/** + * Reserved names that may still identify profiles created before the + * corresponding built-in provider shortcuts shipped. + */ +export const GRANDFATHERED_RESERVED_PROFILE_NAMES = ['xai', 'grok'] as const; +export type GrandfatheredReservedProfileName = + (typeof GRANDFATHERED_RESERVED_PROFILE_NAMES)[number]; + /** * Windows reserved device names - cannot be used as filenames on Windows. * Case-insensitive on Windows filesystem. @@ -70,6 +82,27 @@ export function isReservedName(name: string): boolean { return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); } +/** + * Check whether a reserved name may identify an existing grandfathered profile. + * This does not permit creating a new profile with the name. + */ +export function isGrandfatheredReservedProfileName(name: string): boolean { + return GRANDFATHERED_RESERVED_PROFILE_NAMES.includes( + name.toLowerCase() as GrandfatheredReservedProfileName + ); +} + +/** + * Allow a reserved grandfathered name only for an explicit overwrite of the + * exact profile that already owns it. + */ +export function canOverwriteGrandfatheredReservedProfileName( + name: string, + options: { force: boolean; exists: boolean } +): boolean { + return options.force && options.exists && isGrandfatheredReservedProfileName(name); +} + /** * Check if a name is a Windows reserved device name. * These cause filesystem errors on Windows systems. @@ -89,7 +122,7 @@ export function isWindowsReservedName(name: string): boolean { */ export function validateProfileName(name: string): void { if (isReservedName(name)) { - throw new Error( + throw new ConfigError( `Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}` ); } diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index a5ea70ac..1b0b940a 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -22,7 +22,7 @@ import { apiProfileExists, listCliproxyBridgeProviders, listApiProfiles, - validateApiName, + validateApiNameSyntax, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; import { getPersistedTargetChoices } from '../../targets/target-metadata'; @@ -284,7 +284,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => { } names = payload.names.map((value) => value.trim()); - const invalidName = names.find((name) => validateApiName(name) !== null); + const invalidName = names.find((name) => validateApiNameSyntax(name) !== null); if (invalidName) { res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` }); return; @@ -321,7 +321,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => { } const { name } = req.params; - const sourceNameError = validateApiName(name); + const sourceNameError = validateApiNameSyntax(name); if (sourceNameError) { res.status(400).json({ error: sourceNameError }); return; @@ -359,7 +359,7 @@ router.post('/:name/export', (req: Request, res: Response): void => { } const { name } = req.params; - const profileNameError = validateApiName(name); + const profileNameError = validateApiNameSyntax(name); if (profileNameError) { res.status(400).json({ error: profileNameError }); return; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index affb01b3..89a4854d 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -21,8 +21,9 @@ describe('account context helpers', () => { }); it('rejects reserved account profile names', () => { - expect(isValidAccountProfileName('default')).toBe(false); - expect(isValidAccountProfileName('Default')).toBe(false); + for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) { + expect(isValidAccountProfileName(name)).toBe(false); + } }); it('falls back to default shared group for invalid persisted metadata', () => { diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 310d9af5..5d984973 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, runWithScopedConfigDir, @@ -68,7 +69,7 @@ describe('profile lifecycle service', () => { } }); - it('discovers only API profile orphans (skips registered and reserved names)', async () => { + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -101,9 +102,59 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); + for (const profileName of ['xai', 'grok']) { + fs.writeFileSync( + path.join(ccsDir, `${profileName}.settings.json`), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token', + }, + }, + null, + 2 + ) + '\n' + ); + } const result = await runInScopedCcsDir(() => discoverApiProfileOrphans()); - expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); + expect(result.orphans.map((orphan) => orphan.name).sort()).toEqual(['extra', 'grok', 'xai']); + }); + + it('registers a grandfathered xai orphan without opening general reserved-name creation', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'gemini.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const result = await runInScopedCcsDir(() => + registerApiProfileOrphans({ names: ['xai', 'gemini'] }) + ); + const config = await runInScopedCcsDir(() => loadConfigSafe()); + + expect(result.registered).toEqual(['xai']); + expect(result.skipped).toEqual([]); + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + expect(config.profiles.gemini).toBeUndefined(); }); it('treats explicit empty names list as no-op during orphan registration', async () => { @@ -140,7 +191,10 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { throw new Error('copy failed'); @@ -168,8 +222,15 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); - fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 12\nwebsearch:\n enabled: false\n', + 'utf8' + ); const originalCopyFileSync = fs.copyFileSync.bind(fs); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { @@ -195,7 +256,10 @@ describe('profile lifecycle service', () => { const malformedPath = path.join(ccsDir, 'bad.settings.json'); fs.writeFileSync(malformedPath, '{ invalid json', 'utf8'); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['bad'], force: true }) @@ -247,6 +311,173 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid source profile name'); }); + it('copies and exports grandfathered sources but rejects reserved destinations', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exported = await runInScopedCcsDir(() => exportApiProfile('xai')); + const copied = await runInScopedCcsDir(() => copyApiProfile('xai', 'xai-backup')); + const rejectedDestination = await runInScopedCcsDir(() => copyApiProfile('xai', 'GROK')); + + expect(exported.success).toBe(true); + expect(exported.bundle?.profile.name).toBe('xai'); + expect(copied.success).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'xai-backup.settings.json'))).toBe(true); + expect(rejectedDestination.success).toBe(false); + expect(rejectedDestination.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'GROK.settings.json'))).toBe(false); + }); + + it('allows force copy only onto an exact existing grandfathered destination', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + source: '~/.ccs/source.settings.json', + xai: '~/.ccs/xai.settings.json', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'source.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://source.example.com', + ANTHROPIC_AUTH_TOKEN: 'source-token', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai')); + const forced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai', { force: true })); + const absentForced = await runInScopedCcsDir(() => + copyApiProfile('source', 'grok', { force: true }) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const copiedSettings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(copiedSettings.env.ANTHROPIC_BASE_URL).toBe('https://source.example.com'); + expect(copiedSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('source-token'); + }); + + it('rejects an absent reserved name even when profile creation is forced', async () => { + const result = await runInScopedCcsDir(() => + createApiProfile( + 'XAI', + 'https://api.example.com', + 'token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('reserved name'); + expect(fs.existsSync(path.join(getScopedCcsDir(), 'XAI.settings.json'))).toBe(false); + }); + + it('allows force to repair an exact existing xai API profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => + createApiProfile('xai', 'https://new.example.com', 'new-token', { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }) + ); + const forced = await runInScopedCcsDir(() => + createApiProfile( + 'xai', + 'https://new.example.com', + 'new-token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + it('rolls back copied settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -294,6 +525,62 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid bundle profile target'); }); + it('allows force import only for an exact existing grandfathered profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const bundle = { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'xai', target: 'claude' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://repaired.example.com', + ANTHROPIC_AUTH_TOKEN: 'repaired-token', + }, + }, + }; + + const nonForced = await runInScopedCcsDir(() => importApiProfileBundle(bundle)); + const forced = await runInScopedCcsDir(() => importApiProfileBundle(bundle, { force: true })); + const absentForced = await runInScopedCcsDir(() => + importApiProfileBundle( + { + ...bundle, + profile: { ...bundle.profile, name: 'grok' }, + }, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://repaired.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('repaired-token'); + }); + it('rolls back imported settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts index 2264ed3d..a81b8965 100644 --- a/tests/unit/auth-resources-command.test.ts +++ b/tests/unit/auth-resources-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as childProcess from 'child_process'; import ProfileRegistry from '../../src/auth/profile-registry'; import InstanceManager from '../../src/management/instance-manager'; import { handleResources } from '../../src/auth/commands/resources-command'; @@ -227,6 +228,103 @@ describe('auth resources command', () => { expect(output).toContain('Unknown option(s): "--mode"'); }); + it('rejects reserved xai and grok account profile names on auth create', async () => { + for (const profileName of ['xai', 'GROK']) { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + [profileName] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified(profileName)).toBe(false); + } + }); + + it('rejects --force for an absent reserved account profile', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified('xai')).toBe(false); + }); + + it('allows --force to repair an exact existing xai account profile', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' xai:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const claudeDetector = await import('../../src/utils/claude-detector'); + const cliSpy = spyOn(claudeDetector, 'getClaudeCliInfo').mockReturnValue({ + path: '/usr/bin/claude', + isWindows: false, + needsShell: false, + }); + const child = { + on: () => child, + } as unknown as ReturnType; + const spawnSpy = spyOn(childProcess, 'spawn').mockReturnValue(child); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'xai') + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + await handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ); + + expect(spawnSpy).toHaveBeenCalled(); + expect(registry.hasAccountUnified('xai')).toBe(true); + } finally { + logSpy.mockRestore(); + ensureSpy.mockRestore(); + spawnSpy.mockRestore(); + cliSpy.mockRestore(); + } + }); + it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => { const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index c028453a..66b4f0da 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as os from 'os'; import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector'; import * as unifiedConfigLoader from '../../../src/config/unified-config-loader'; +import { ConfigError } from '../../../src/errors/error-types'; describe('ProfileDetector', () => { const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); @@ -101,11 +102,186 @@ describe('ProfileDetector', () => { expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); - it('should resolve the grok CLI alias to canonical xai routing', () => { - const result = detector.detectProfileType('grok'); - expect(result.type).toBe('cliproxy'); - expect(result.name).toBe('xai'); - expect(result.provider).toBe('xai'); + it('should resolve xai and grok to built-in xai routing when no configured profile exists', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + for (const shortcut of ['xai', 'grok']) { + const result = detector.detectProfileType(shortcut); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('xai'); + expect(result.provider).toBe('xai'); + } + } finally { + isUnifiedModeSpy.mockRestore(); + existsSyncSpy.mockRestore(); + } + }); + + it('should preserve configured xai and grok profiles from unified config', () => { + const xaiSettingsPath = path.join(tempDir, 'xai.settings.json'); + const grokSettingsPath = path.join(tempDir, 'grok.settings.json'); + fs.writeFileSync( + xaiSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-xai.example' } }) + ); + fs.writeFileSync( + grokSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-grok.example' } }) + ); + + const mockUnifiedConfig = { + version: 2, + profiles: { + xai: { settings: xaiSettingsPath, type: 'api' }, + grok: { settings: grokSettingsPath, type: 'api' }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + for (const profileName of ['xai', 'grok']) { + const result = detector.detectProfileType(profileName); + expect(result.type).toBe('settings'); + expect(result.name).toBe(profileName); + expect(result.settingsPath).toBe( + profileName === 'xai' ? xaiSettingsPath : grokSettingsPath + ); + } + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed unified xai composite instead of using the built-in shortcut', () => { + const mockUnifiedConfig = { + version: 12, + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.detectProfileType('xai')).toThrow(ConfigError); + expect(() => detector.detectProfileType('xai')).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy grok variant instead of using the built-in shortcut', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {}, cliproxy: { grok: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.detectProfileType('grok')).toThrow(ConfigError); + expect(() => localDetector.detectProfileType('grok')).toThrow( + /Configured profile "grok" in config.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + + it('should reject a malformed unified xai default instead of using the native default', () => { + const mockUnifiedConfig = { + version: 12, + default: 'xai', + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => detector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy xai account default', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ default: 'xai', profiles: { xai: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in profiles.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } }); it('should detect settings-based profile from unified config', () => { diff --git a/tests/unit/commands/api-create-grandfathered-force.test.ts b/tests/unit/commands/api-create-grandfathered-force.test.ts new file mode 100644 index 00000000..8795562f --- /dev/null +++ b/tests/unit/commands/api-create-grandfathered-force.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { handleApiCreateCommand } from '../../../src/commands/api-command/create-command'; + +describe('api create grandfathered force policy', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-create-grandfathered-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_UNIFIED_CONFIG; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('allows ccs api create --force to repair an exact existing xai profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleApiCreateCommand([ + 'xai', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]); + } finally { + logSpy.mockRestore(); + } + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + + it('rejects ccs api create --force when the reserved profile is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const originalExit = process.exit; + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleApiCreateCommand([ + 'grok', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalExit; + logSpy.mockRestore(); + } + + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + }); +}); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index 5e3f514c..54b5aa75 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand'; +import { + parseProfileArgs, + validateVariantCreateName, +} from '../../../src/commands/cliproxy/variant-subcommand'; describe('cliproxy variant arg parser', () => { test('parses --target value form', () => { @@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual([]); }); }); + +describe('cliproxy variant create name policy', () => { + test('allows force repair of an exact existing grandfathered variant', () => { + expect(validateVariantCreateName('xai', true, true)).toBeNull(); + expect(validateVariantCreateName('GROK', true, true)).toBeNull(); + }); + + test('keeps absent, non-force, and other reserved variant names blocked', () => { + expect(validateVariantCreateName('xai', true, false)).toContain('reserved name'); + expect(validateVariantCreateName('xai', false, true)).toContain('reserved name'); + expect(validateVariantCreateName('gemini', true, true)).toContain('reserved name'); + }); +}); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index b8566a28..095195bb 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -44,6 +44,8 @@ describe('reserved-names', () => { expect(RESERVED_PROFILE_NAMES).toContain('agy'); expect(RESERVED_PROFILE_NAMES).toContain('qwen'); expect(RESERVED_PROFILE_NAMES).toContain('iflow'); + expect(RESERVED_PROFILE_NAMES).toContain('xai'); + expect(RESERVED_PROFILE_NAMES).toContain('grok'); }); it('should include CLI commands', () => { @@ -57,12 +59,16 @@ describe('reserved-names', () => { it('should return true for reserved names', () => { expect(isReservedName('gemini')).toBe(true); expect(isReservedName('codex')).toBe(true); + expect(isReservedName('xai')).toBe(true); + expect(isReservedName('grok')).toBe(true); expect(isReservedName('default')).toBe(true); }); it('should be case-insensitive', () => { expect(isReservedName('GEMINI')).toBe(true); expect(isReservedName('Codex')).toBe(true); + expect(isReservedName('XAI')).toBe(true); + expect(isReservedName('GROK')).toBe(true); expect(isReservedName('DEFAULT')).toBe(true); }); @@ -76,6 +82,8 @@ describe('reserved-names', () => { describe('validateProfileName', () => { it('should throw for reserved names', () => { expect(() => validateProfileName('gemini')).toThrow(/reserved/i); + expect(() => validateProfileName('xai')).toThrow(/reserved/i); + expect(() => validateProfileName('grok')).toThrow(/reserved/i); expect(() => validateProfileName('default')).toThrow(/reserved/i); }); diff --git a/tests/unit/web-server/profile-routes-lifecycle.test.ts b/tests/unit/web-server/profile-routes-lifecycle.test.ts index a79ae8e1..d215cf71 100644 --- a/tests/unit/web-server/profile-routes-lifecycle.test.ts +++ b/tests/unit/web-server/profile-routes-lifecycle.test.ts @@ -76,11 +76,17 @@ describe('profile-routes lifecycle endpoints', () => { it('does not register all orphans when names=[] is explicitly passed', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); fs.writeFileSync( path.join(ccsDir, 'lonely.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { @@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => { const body = (await response.json()) as { error: string }; expect(body.error).toContain('API name must start with letter'); }); + + it('keeps grandfathered profiles exportable, copyable, and removable', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exportResponse = await fetch(`${baseUrl}/api/profiles/xai/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(exportResponse.status).toBe(200); + + const copyResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'xai-backup' }), + }); + expect(copyResponse.status).toBe(201); + + const reservedDestinationResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'GROK' }), + }); + expect(reservedDestinationResponse.status).toBe(400); + const reservedDestinationBody = (await reservedDestinationResponse.json()) as { + error: string; + }; + expect(reservedDestinationBody.error).toContain('reserved name'); + + const deleteResponse = await fetch(`${baseUrl}/api/profiles/xai`, { + method: 'DELETE', + }); + expect(deleteResponse.status).toBe(200); + expect(fs.existsSync(path.join(ccsDir, 'xai.settings.json'))).toBe(false); + }); + + it('recovers a grandfathered xai orphan through the route', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names: ['xai'] }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { registered: string[]; skipped: unknown[] }; + expect(body.registered).toEqual(['xai']); + expect(body.skipped).toEqual([]); + + const config = JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8')) as { + profiles: Record; + }; + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + }); });