diff --git a/config/base-codebuddy.settings.json b/config/base-codebuddy.settings.json new file mode 100644 index 00000000..483f1630 --- /dev/null +++ b/config/base-codebuddy.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc" + } +} diff --git a/config/base-cursor.settings.json b/config/base-cursor.settings.json new file mode 100644 index 00000000..2ad521a2 --- /dev/null +++ b/config/base-cursor.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "composer-2", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small" + } +} diff --git a/config/base-gitlab.settings.json b/config/base-gitlab.settings.json new file mode 100644 index 00000000..2dff5165 --- /dev/null +++ b/config/base-gitlab.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "gitlab-duo", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5" + } +} diff --git a/config/base-kilo.settings.json b/config/base-kilo.settings.json new file mode 100644 index 00000000..7dc448dc --- /dev/null +++ b/config/base-kilo.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto" + } +} diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index bc7fbab1..637d004c 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -253,9 +253,8 @@ class ProfileDetector { * Detect profile type and return routing information * * Priority order: - * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) - * 0.5. Copilot profile (if enabled in config) - * 0.75. Cursor profile (if enabled in config) + * 0. Hardcoded special runtime profiles (copilot, cursor) + * 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...) * 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] @@ -267,16 +266,7 @@ class ProfileDetector { return this.resolveDefaultProfile(); } - // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config - if (isCLIProxyProvider(profileName)) { - return { - type: 'cliproxy', - name: profileName, - provider: profileName, - }; - } - - // Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api + // Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api if (profileName === 'copilot') { const unifiedConfig = this.readUnifiedConfig(); const copilotConfig = unifiedConfig?.copilot; @@ -306,7 +296,10 @@ class ProfileDetector { }; } - // Priority 0.75: Check Cursor profile - local Cursor daemon runtime + // Priority 0.25: Check Cursor profile - local Cursor daemon runtime. + // This keeps bare `ccs cursor` and `ccs cursor ` bound to the + // existing runtime/admin surface even though CLIProxy also exposes a + // distinct provider named "cursor". if (profileName === 'cursor') { const cursorConfig = getCursorConfig(); @@ -334,6 +327,15 @@ class ProfileDetector { }; } + // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) + if (isCLIProxyProvider(profileName)) { + return { + type: 'cliproxy', + name: profileName, + provider: profileName, + }; + } + // Priority 1: Try unified config if available const unifiedConfig = this.readUnifiedConfig(); if (unifiedConfig) { diff --git a/src/ccs.ts b/src/ccs.ts index 5c907085..31c4e61a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -77,6 +77,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; +import type { ProfileDetectionResult } from './auth/profile-detector'; // Import target adapter system import { @@ -122,6 +123,7 @@ interface RuntimeReasoningResolution { sourceDisplay: string | undefined; } +const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set(['--auth', '--logout', '--config', '--accounts']); const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); @@ -147,6 +149,13 @@ function detectProfile(args: string[]): DetectedProfile { } } +function shouldUseCursorCliproxyShortcut(args: string[]): boolean { + return ( + args[0] === 'cursor' && + args.some((arg, index) => index > 0 && CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(arg)) + ); +} + function resolveRuntimeReasoningFlags( args: string[], envThinkingValue: string | undefined @@ -537,8 +546,17 @@ async function main(): Promise { try { // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo = detector.detectProfileType(profile); + const useCursorCliproxyShortcut = shouldUseCursorCliproxyShortcut(cleanArgs); + const { profile, remainingArgs } = useCursorCliproxyShortcut + ? { profile: 'cursor', remainingArgs: cleanArgs.slice(1) } + : detectProfile(cleanArgs); + const profileInfo: ProfileDetectionResult = useCursorCliproxyShortcut + ? { + type: 'cliproxy', + name: 'cursor', + provider: 'cursor', + } + : detector.detectProfileType(profile); let resolvedTarget: ReturnType; try { resolvedTarget = resolveTargetType( diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 95e44980..3f28ce46 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) * - Kimi: Device Code Flow (polling-based, NO callback port needed) + * - Cursor: Device-style browser polling (NO callback port needed) + * - GitLab: Authorization Code Flow with callback server on port 17171 + * - CodeBuddy: Device-style browser polling (NO callback port needed) + * - Kilo: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = CLIPROXY_PROVIDER_IDS.reduce( @@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record = { scopes: ['api'], authFlag: '--kimi-login', }, + cursor: { + provider: 'cursor', + displayName: 'Cursor', + authUrl: 'https://cursor.com/loginDeepControl', + scopes: [], + authFlag: '--cursor-login', + }, + gitlab: { + provider: 'gitlab', + displayName: 'GitLab Duo', + authUrl: 'https://gitlab.com/oauth/authorize', + scopes: ['api', 'read_user'], + authFlag: '--gitlab-login', + }, + codebuddy: { + provider: 'codebuddy', + displayName: 'CodeBuddy (Tencent)', + authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state', + scopes: [], + authFlag: '--codebuddy-login', + }, + kilo: { + provider: 'kilo', + displayName: 'Kilo AI', + authUrl: 'https://api.kilo.ai/api/device-auth/codes', + scopes: [], + authFlag: '--kilo-login', + }, }; /** @@ -373,6 +405,12 @@ export interface OAuthOptions { noIncognito?: boolean; /** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */ import?: boolean; + /** GitLab auth mode override. */ + gitlabAuthMode?: 'oauth' | 'pat'; + /** GitLab self-hosted base URL override. */ + gitlabBaseUrl?: string; + /** GitLab personal access token for PAT login. */ + gitlabPersonalAccessToken?: string; /** Enable paste-callback mode: show auth URL and prompt for callback paste */ pasteCallback?: boolean; /** If true, use port-forwarding mode (skip interactive prompt in headless) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2185598e..a116e4f4 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -83,9 +83,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; export async function requestPasteCallbackStart( provider: CLIProxyProvider, target: ProxyTarget, - options?: { kiroMethod?: OAuthOptions['kiroMethod'] } + options?: { + kiroMethod?: OAuthOptions['kiroMethod']; + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + } ): Promise { - const startPath = getPasteCallbackStartPath(provider, { + let startPath = getPasteCallbackStartPath(provider, { kiroMethod: options?.kiroMethod, }); if (!startPath) { @@ -93,6 +96,9 @@ export async function requestPasteCallbackStart( `Paste-callback start is not available for ${provider} with the selected method` ); } + if (provider === 'gitlab' && options?.gitlabBaseUrl?.trim()) { + startPath += `&base_url=${encodeURIComponent(options.gitlabBaseUrl.trim())}`; + } const response = await fetch(buildProxyUrl(target, startPath), { headers: buildManagementHeaders(target), }); @@ -142,6 +148,27 @@ function parseAuthUrlState(url: string | null | undefined): string | null { } } +function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { + const normalized = baseUrl?.trim(); + return normalized ? normalized : undefined; +} + +async function promptGitLabPersonalAccessToken(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question('GitLab Personal Access Token: ', (answer) => { + rl.close(); + const token = answer.trim(); + resolve(token.length > 0 ? token : null); + }); + }); +} + export function findNewTokenSnapshotForManualAuth( provider: CLIProxyProvider, tokenDir: string, @@ -458,7 +485,10 @@ async function handlePasteCallbackMode( tokenDir: string, nickname?: string, expectedAccountId?: string, - options?: { kiroMethod?: OAuthOptions['kiroMethod'] } + options?: { + kiroMethod?: OAuthOptions['kiroMethod']; + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + } ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); @@ -647,6 +677,97 @@ async function handlePasteCallbackMode( } } +async function handleGitLabPatLogin( + provider: CLIProxyProvider, + oauthConfig: ProviderOAuthConfig, + verbose: boolean, + tokenDir: string, + nickname?: string, + expectedAccountId?: string, + options?: { + gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken']; + } +): Promise { + const target = getProxyTarget(); + const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl); + const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); + const suppliedToken = options?.gitlabPersonalAccessToken?.trim(); + const personalAccessToken = + suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined; + + let token = personalAccessToken; + if (!token) { + console.log(''); + console.log(info(`Starting ${oauthConfig.displayName} PAT login...`)); + console.log('Paste a Personal Access Token with api and read_user scopes.'); + token = (await promptGitLabPersonalAccessToken()) || undefined; + } + + if (!token) { + console.log(info('Cancelled')); + return null; + } + + const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildManagementHeaders(target), + }, + body: JSON.stringify({ + ...(baseUrl ? { base_url: baseUrl } : {}), + personal_access_token: token, + }), + }); + + const responseBody = (await response.text()).trim(); + let payload: Record = {}; + if (responseBody) { + try { + payload = JSON.parse(responseBody) as Record; + } catch { + payload = { error: responseBody }; + } + } + + if (!response.ok || payload.status !== 'ok') { + const errorMessage = + (typeof payload.error === 'string' && payload.error) || + responseBody || + `GitLab PAT login failed with status ${response.status}`; + console.log(fail(errorMessage)); + return null; + } + + const tokenSnapshot = findNewTokenSnapshotForAuthAttempt( + provider, + tokenDir, + knownTokenFiles, + expectedAccountId + ); + if (!tokenSnapshot) { + console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.')); + return null; + } + + const account = registerAccountFromToken( + provider, + tokenDir, + nickname, + verbose, + expectedAccountId || tokenSnapshot.file + ); + + if (!account) { + console.log(fail('Authenticated GitLab token could not be registered as a CCS account.')); + return null; + } + + console.log(ok('Authentication successful!')); + return account; +} + /** * Trigger OAuth flow for provider * Auto-detects headless environment and uses --no-browser flag accordingly @@ -666,6 +787,10 @@ export async function triggerOAuth( provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; const resolvedKiroIDCFlow = provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW; + const resolvedGitLabAuthMode = + provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth'; + const resolvedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options.gitlabBaseUrl) : undefined; if (provider === 'agy') { if (fromUI && !acceptAgyRisk) { @@ -744,6 +869,14 @@ export async function triggerOAuth( } } + if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) { + selectedPasteCallback = true; + console.log(''); + console.log( + info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.') + ); + } + const useSelectedKiroLocalPasteCallback = selectedPasteCallback && provider === 'kiro' && @@ -765,6 +898,22 @@ export async function triggerOAuth( } } + if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') { + const tokenDir = getProviderTokenDir(provider); + return handleGitLabPatLogin( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id, + { + gitlabBaseUrl: resolvedGitLabBaseUrl, + gitlabPersonalAccessToken: options.gitlabPersonalAccessToken, + } + ); + } + if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { const tokenDir = getProviderTokenDir(provider); return handlePasteCallbackMode( @@ -774,7 +923,10 @@ export async function triggerOAuth( tokenDir, nickname, existingNameMatch?.id, - { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined } + { + kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, + gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, + } ); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index b7934395..4dbe88bf 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -353,6 +353,7 @@ export async function execClaudeWithCLIProxy( const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); const forceImport = argsWithoutProxy.includes('--import'); + const gitlabTokenLogin = argsWithoutProxy.includes('--token-login'); const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy); const incognitoFlag = argsWithoutProxy.includes('--incognito'); @@ -444,6 +445,16 @@ export async function execClaudeWithCLIProxy( kiroIDCFlow = normalizeKiroIDCFlow(normalized); } + let gitlabBaseUrl: string | undefined; + const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url'); + if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) { + gitlabBaseUrl = gitlabBaseUrlValue.value.trim(); + } else if (gitlabBaseUrlValue.present) { + console.error(fail('--gitlab-url requires a value')); + process.exitCode = 1; + return; + } + if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.exitCode = 1; @@ -491,6 +502,13 @@ export async function execClaudeWithCLIProxy( return; } + if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { + const flagName = gitlabTokenLogin ? '--token-login' : '--gitlab-url'; + console.error(fail(`${flagName} is only valid for ccs gitlab`)); + process.exitCode = 1; + return; + } + // Parse --thinking / --effort flags (aliases; first occurrence wins) const thinkingParse = parseThinkingOverride(argsWithoutProxy); if (thinkingParse.error) { @@ -730,6 +748,8 @@ export async function execClaudeWithCLIProxy( ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -775,6 +795,8 @@ export async function execClaudeWithCLIProxy( ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), ...(kiroIDCRegion ? { kiroIDCRegion } : {}), ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index ffd8e921..ebbe2773 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record profileName !== 'cursor'), ...all.cliproxyVariants, ]; const deduped = [...new Set(orderedNames)]; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index eece3645..8e63e3bc 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -229,6 +229,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean } return { flow: normalizeKiroIDCFlow(normalized), invalid: false }; } +function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } { + if (raw === undefined || raw === null || raw === '') { + return { mode: 'oauth', invalid: false }; + } + if (typeof raw !== 'string') { + return { mode: 'oauth', invalid: true }; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === 'oauth' || normalized === 'pat') { + return { mode: normalized, invalid: false }; + } + return { mode: 'oauth', invalid: true }; +} + export function getKiroStartIDCValidationError(options: { kiroMethod: KiroAuthMethod; kiroIDCStartUrl?: string; @@ -603,6 +617,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise ({}))) as { + status?: string; + error?: string; + }; + if (!response.ok || data.status !== 'ok') { + res.status(response.ok ? 400 : response.status).json({ + error: data.error || 'GitLab PAT authentication failed', + }); + return; + } + + const tokenSnapshot = findNewTokenSnapshot( + listProviderTokenSnapshots(localProvider), + knownTokenFiles + ); + if (!tokenSnapshot) { + res.status(409).json({ + error: 'GitLab PAT authentication completed, but CCS could not find the saved token.', + }); + return; + } + + const account = registerAccountFromToken( + localProvider, + getProviderTokenDir(localProvider), + nickname, + false, + tokenSnapshot.file + ); + if (!account) { + res.status(409).json({ + error: 'GitLab PAT authentication succeeded, but account registration failed.', + }); + return; + } + + try { + await ensureManagedModelPrefixes([account.provider]); + } catch { + // Keep auth success path non-fatal when prefix repair cannot run. + } + + res.json({ + success: true, + account: { + id: account.id, + email: account.email, + nickname: account.nickname, + provider: account.provider, + isDefault: account.isDefault, + }, + }); + return; + } catch (error) { + respondInternalError(res, error, 'Failed to start GitLab PAT flow.'); + return; + } + } + const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); const nicknameError = getStartAuthNicknameError( provider as CLIProxyProvider, @@ -681,6 +795,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise) : {}; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; const kiroMethodRaw = requestBody.kiroMethod; + const gitlabAuthModeRaw = requestBody.gitlabAuthMode; + const gitlabBaseUrl = + typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined; const riskAcknowledgement = requestBody.riskAcknowledgement; const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); + const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } = + parseGitLabAuthMode(gitlabAuthModeRaw); // Check remote mode const target = getProxyTarget(); @@ -862,6 +983,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'gitlab' && invalidGitLabAuthMode) { + res.status(400).json({ + error: 'Invalid gitlabAuthMode. Supported: oauth, pat', + code: 'INVALID_GITLAB_AUTH_MODE', + }); + return; + } + + if (provider === 'gitlab' && gitlabAuthMode === 'pat') { + res.status(400).json({ + error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start', + code: 'GITLAB_PAT_REQUIRES_START', + }); + return; + } + if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) { const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement); if (!validation.valid) { @@ -900,11 +1037,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise provider === 'kiro' && kiroManagementMethod ? `&method=${encodeURIComponent(kiroManagementMethod)}` : ''; + const gitlabQuery = + provider === 'gitlab' && gitlabBaseUrl + ? `&base_url=${encodeURIComponent(gitlabBaseUrl)}` + : ''; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix const response = await fetch( - buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`), + buildProxyUrl( + target, + `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}` + ), { headers: buildManagementHeaders(target) } ); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 84d57415..145697cb 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -93,6 +93,12 @@ describe('ProfileDetector', () => { expect(result.provider).toBe('gemini'); }); + it('should detect newly added cliproxy providers', () => { + expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); + expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); + expect(detector.detectProfileType('kilo').provider).toBe('kilo'); + }); + 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/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js index bcccb347..ff2ead96 100644 --- a/tests/unit/cliproxy/backend-selection.test.js +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -121,6 +121,13 @@ describe('Backend Selection', () => { assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp')); }); + it('includes the newer CLIProxyAPIPlus providers as plus-only', () => { + assert(types.PLUS_ONLY_PROVIDERS.includes('cursor')); + assert(types.PLUS_ONLY_PROVIDERS.includes('gitlab')); + assert(types.PLUS_ONLY_PROVIDERS.includes('codebuddy')); + assert(types.PLUS_ONLY_PROVIDERS.includes('kilo')); + }); + it('does not include gemini as plus-only provider', () => { assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini')); }); diff --git a/tests/unit/cliproxy/base-config-loader.test.ts b/tests/unit/cliproxy/base-config-loader.test.ts new file mode 100644 index 00000000..528d9106 --- /dev/null +++ b/tests/unit/cliproxy/base-config-loader.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; + +import { loadBaseConfig } from '../../../src/cliproxy/base-config-loader'; + +describe('base-config-loader new providers', () => { + it.each([ + ['cursor', '/api/provider/cursor', 'composer-2'], + ['gitlab', '/api/provider/gitlab', 'gitlab-duo'], + ['codebuddy', '/api/provider/codebuddy', 'auto'], + ['kilo', '/api/provider/kilo', 'kilo/auto'], + ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { + const config = loadBaseConfig(provider); + + expect(config.env.ANTHROPIC_BASE_URL).toContain(baseUrlPath); + expect(config.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(config.env.ANTHROPIC_MODEL).toBe(defaultModel); + expect(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL.length).toBeGreaterThan(0); + expect(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL.length).toBeGreaterThan(0); + expect(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index d6f1d565..1f574e26 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -41,24 +41,38 @@ describe('provider-capabilities', () => { 'ghcp', 'claude', 'kimi', + 'cursor', + 'gitlab', + 'codebuddy', + 'kilo', ]); }); it('validates provider IDs', () => { expect(isCLIProxyProvider('gemini')).toBe(true); expect(isCLIProxyProvider('ghcp')).toBe(true); + expect(isCLIProxyProvider('gitlab')).toBe(true); expect(isCLIProxyProvider('not-a-provider')).toBe(false); expect(isCLIProxyProvider('Gemini')).toBe(false); }); it('returns providers by OAuth flow capability', () => { - expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp', 'kimi']); + expect(getProvidersByOAuthFlow('device_code')).toEqual([ + 'qwen', + 'kiro', + 'ghcp', + 'kimi', + 'cursor', + 'codebuddy', + 'kilo', + ]); expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ 'gemini', 'codex', 'agy', 'iflow', 'claude', + 'gitlab', ]); }); @@ -69,6 +83,8 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); expect(mapExternalProviderName('copilot')).toBe('ghcp'); expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); + expect(mapExternalProviderName('tencent')).toBe('codebuddy'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); @@ -99,8 +115,11 @@ describe('provider-capabilities', () => { it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); + expect(getOAuthCallbackPort('cursor')).toBeNull(); + expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gemini')).toBe(8085); expect(getProviderDisplayName('agy')).toBe('Antigravity'); + expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); }); it('throws when provider aliases collide across providers', () => { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 5bb12d0a..7b278719 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -82,6 +82,9 @@ describe('completion backend', () => { expect(values).toContain('cursor'); expect(values).toContain('copilot'); expect(values).toContain('gemini'); + expect(values).toContain('gitlab'); + expect(values).toContain('codebuddy'); + expect(values).toContain('kilo'); expect(values).toContain('localglm'); expect(values).toContain('work'); expect(values).toContain('my-codex'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 017ec18e..4d3864f7 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -50,6 +50,9 @@ describe('help command parity', () => { expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('codex')).toBe(true); expect(rendered.includes('ghcp')).toBe(true); + expect(rendered.includes('gitlab')).toBe(true); + expect(rendered.includes('codebuddy')).toBe(true); + expect(rendered.includes('kilo')).toBe(true); }); test('kiro topic documents IDC and callback flags', async () => { diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 703c2870..8c21fb73 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -13,6 +13,15 @@ describe('cliproxy-auth-routes start-url guard', () => { ); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('cursor')).toContain( + "Provider 'cursor' uses Device Code flow" + ); + expect(getStartUrlUnsupportedReason('codebuddy')).toContain( + "Provider 'codebuddy' uses Device Code flow" + ); + expect(getStartUrlUnsupportedReason('kilo')).toContain( + "Provider 'kilo' uses Device Code flow" + ); }); it('allows Kiro social methods on start-url', () => { @@ -36,6 +45,7 @@ describe('cliproxy-auth-routes start-url guard', () => { expect(getStartUrlUnsupportedReason('gemini')).toBeNull(); expect(getStartUrlUnsupportedReason('codex')).toBeNull(); expect(getStartUrlUnsupportedReason('claude')).toBeNull(); + expect(getStartUrlUnsupportedReason('gitlab')).toBeNull(); }); }); @@ -87,6 +97,7 @@ describe('cliproxy-auth-routes start failure messaging', () => { it('keeps generic failure text for other providers', () => { expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled'); expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled'); + expect(getStartAuthFailureMessage('gitlab')).toBe('Authentication failed or was cancelled'); }); }); diff --git a/ui/public/assets/providers/codebuddy.svg b/ui/public/assets/providers/codebuddy.svg new file mode 100644 index 00000000..ec228977 --- /dev/null +++ b/ui/public/assets/providers/codebuddy.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/public/assets/providers/cursor.svg b/ui/public/assets/providers/cursor.svg new file mode 100644 index 00000000..f4a02f5f --- /dev/null +++ b/ui/public/assets/providers/cursor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/assets/providers/gitlab.svg b/ui/public/assets/providers/gitlab.svg new file mode 100644 index 00000000..5abce1d3 --- /dev/null +++ b/ui/public/assets/providers/gitlab.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/public/assets/providers/kilo.svg b/ui/public/assets/providers/kilo.svg new file mode 100644 index 00000000..e7c84dde --- /dev/null +++ b/ui/public/assets/providers/kilo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index ec1c9f78..e9651e6a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -91,6 +91,9 @@ export function AddAccountDialog({ const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState(''); const [kiroIDCRegion, setKiroIDCRegion] = useState(''); const [kiroIDCFlow, setKiroIDCFlow] = useState(DEFAULT_KIRO_IDC_FLOW); + const [gitlabAuthMode, setGitlabAuthMode] = useState<'oauth' | 'pat'>('oauth'); + const [gitlabBaseUrl, setGitlabBaseUrl] = useState(''); + const [gitlabPersonalAccessToken, setGitlabPersonalAccessToken] = useState(''); const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const powerUserModeRequestIdRef = useRef(0); @@ -99,6 +102,7 @@ export function AddAccountDialog({ const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; + const isGitLab = provider === 'gitlab'; const supportsPowerUserMode = provider === 'agy' || provider === 'gemini'; const requiresGeminiSafetyAcknowledgement = provider === 'gemini' && !powerUserModeEnabled; const requiresAgyResponsibilityFlow = provider === 'agy' && !powerUserModeEnabled; @@ -120,6 +124,8 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim(); const kiroIDCRegionTrimmed = kiroIDCRegion.trim(); + const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim(); + const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim(); const errorMessage = localError || authFlow.error; const fetchPowerUserModeState = useCallback(async (): Promise => { @@ -191,6 +197,9 @@ export function AddAccountDialog({ setKiroIDCStartUrl(''); setKiroIDCRegion(''); setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW); + setGitlabAuthMode('oauth'); + setGitlabBaseUrl(''); + setGitlabPersonalAccessToken(''); powerUserModeRequestIdRef.current += 1; powerUserModeLoadErrorShownRef.current = false; wasAuthenticatingRef.current = false; @@ -310,6 +319,10 @@ export function AddAccountDialog({ setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.'); return; } + if (isGitLab && gitlabAuthMode === 'pat' && !gitlabPersonalAccessTokenTrimmed) { + setLocalError('GitLab Personal Access Token is required for PAT login.'); + return; + } wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined, @@ -317,8 +330,15 @@ export function AddAccountDialog({ kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined, + gitlabAuthMode: isGitLab ? gitlabAuthMode : undefined, + gitlabBaseUrl: isGitLab && gitlabBaseUrlTrimmed ? gitlabBaseUrlTrimmed : undefined, + gitlabPersonalAccessToken: + isGitLab && gitlabAuthMode === 'pat' && gitlabPersonalAccessTokenTrimmed + ? gitlabPersonalAccessTokenTrimmed + : undefined, flowType: isKiro ? selectedKiroFlowType : undefined, - startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined, + startEndpoint: + isKiro ? selectedKiroStartEndpoint : isGitLab && gitlabAuthMode === 'pat' ? 'start' : undefined, riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, @@ -512,6 +532,70 @@ export function AddAccountDialog({ )} + {isGitLab && !showAuthUI && ( +
+
+ + +

+ Use Browser OAuth for gitlab.com, or PAT for self-hosted and admin-managed setups. +

+
+ +
+ + { + setGitlabBaseUrl(e.target.value); + setLocalError(null); + }} + placeholder="https://gitlab.com" + disabled={isPending} + /> +

+ Optional. Leave blank for gitlab.com, or set your self-hosted GitLab base URL. +

+
+ + {gitlabAuthMode === 'pat' && ( +
+ + { + setGitlabPersonalAccessToken(e.target.value); + setLocalError(null); + }} + placeholder="glpat-..." + disabled={isPending} + /> +

+ Token must include at least api and{' '} + read_user scopes. +

+
+ )} +
+ )} + {/* Nickname input - only show before auth starts */} {!showAuthUI && (
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 2022ce5c..72b3d7ef 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -86,8 +86,12 @@ export function ProviderEditor({ gemini: ['google'], agy: ['antigravity'], codex: ['openai'], + cursor: ['cursor'], + gitlab: ['gitlab', 'duo'], + codebuddy: ['codebuddy'], qwen: ['alibaba', 'qwen'], iflow: ['iflow'], + kilo: ['kilo'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], kimi: ['kimi', 'moonshot'], diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 9f5014ed..50a2c1ca 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -1,7 +1,7 @@ /** * Constants for Quick Setup Wizard * Provider display info with custom ordering for wizard UI. - * Provider IDs must match CLIPROXY_PROVIDERS from provider-config.ts + * IDs come from the provider-config presentation layer. */ import type { ProviderOption } from './types'; @@ -17,9 +17,13 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'claude', 'gemini', 'codex', + 'cursor', + 'gitlab', + 'codebuddy', 'qwen', 'kimi', 'iflow', + 'kilo', 'kiro', 'ghcp', ]; diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index ce847823..b9dca50d 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -30,6 +30,9 @@ interface StartAuthOptions { kiroIDCStartUrl?: string; kiroIDCRegion?: string; kiroIDCFlow?: 'authcode' | 'device'; + gitlabAuthMode?: 'oauth' | 'pat'; + gitlabBaseUrl?: string; + gitlabPersonalAccessToken?: string; flowType?: 'authorization_code' | 'device_code'; startEndpoint?: 'start' | 'start-url'; riskAcknowledgement?: { @@ -272,6 +275,9 @@ export function useCliproxyAuthFlow() { kiroIDCStartUrl: options?.kiroIDCStartUrl, kiroIDCRegion: options?.kiroIDCRegion, kiroIDCFlow: options?.kiroIDCFlow, + gitlabAuthMode: options?.gitlabAuthMode, + gitlabBaseUrl: options?.gitlabBaseUrl, + gitlabPersonalAccessToken: options?.gitlabPersonalAccessToken, riskAcknowledgement: options?.riskAcknowledgement, }; diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 7b26a31e..ab8ee75e 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -16,12 +16,10 @@ import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/clip /** Canonical list of CLIProxy provider IDs (shared with backend). */ export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS; - -/** Union type for CLIProxy provider IDs */ export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number]; export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex'; -/** Check if a string is a valid CLIProxy provider */ +/** Check if a string is a backend-supported CLIProxy provider. */ export function isValidProvider(provider: string): provider is CLIProxyProvider { return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider); } @@ -37,9 +35,13 @@ interface ProviderMetadata { const SPECIAL_PROVIDER_VISUAL_IDS = ['openai', 'vertex'] as const; +function isPresentationProvider(provider: string): provider is CLIProxyProvider { + return isValidProvider(provider); +} + function isProviderVisualId(provider: string): provider is ProviderVisualId { return ( - isValidProvider(provider) || + isPresentationProvider(provider) || SPECIAL_PROVIDER_VISUAL_IDS.includes(provider as (typeof SPECIAL_PROVIDER_VISUAL_IDS)[number]) ); } @@ -64,6 +66,10 @@ export const PROVIDER_ASSETS: Partial> = { qwen: '/assets/providers/qwen-color.svg', iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', + cursor: '/assets/providers/cursor.svg', + gitlab: '/assets/providers/gitlab.svg', + codebuddy: '/assets/providers/codebuddy.svg', + kilo: '/assets/providers/kilo.svg', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', kimi: '/assets/providers/kimi.svg', @@ -90,6 +96,10 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { iflow: '#f94144', qwen: '#6236FF', kiro: '#4d908e', + cursor: '#111827', + gitlab: '#FC6D26', + codebuddy: '#2563EB', + kilo: '#E11D48', ghcp: '#43aa8b', claude: '#D97757', kimi: '#FF6B35', @@ -247,7 +261,7 @@ export function getProviderDisplayName(provider: unknown): string { /** Map provider to user-facing short description */ export function getProviderDescription(provider: unknown): string { const normalized = normalizeProviderInput(provider); - if (!isValidProvider(normalized)) return ''; + if (!isPresentationProvider(normalized)) return ''; return PROVIDER_METADATA[normalized].description; } diff --git a/ui/tests/unit/components/account/add-account-dialog.test.tsx b/ui/tests/unit/components/account/add-account-dialog.test.tsx index e39e271a..8e7176e6 100644 --- a/ui/tests/unit/components/account/add-account-dialog.test.tsx +++ b/ui/tests/unit/components/account/add-account-dialog.test.tsx @@ -59,6 +59,22 @@ describe('AddAccountDialog power user mode', () => { vi.clearAllMocks(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: () => false, + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: () => {}, + }); }); afterEach(() => { @@ -168,4 +184,26 @@ describe('AddAccountDialog power user mode', () => { expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument(); expect(authMocks.startAuth).not.toHaveBeenCalled(); }); + + it('submits GitLab PAT mode with base URL and token through the shared auth hook', async () => { + render(); + + await userEvent.click(screen.getByRole('combobox', { name: 'GitLab auth method' })); + await userEvent.click(screen.getByRole('option', { name: 'Personal Access Token' })); + + await userEvent.type(screen.getByLabelText('GitLab URL'), 'https://gitlab.example.com'); + await userEvent.type(screen.getByLabelText('Personal Access Token'), 'glpat-test-token'); + + await userEvent.click(screen.getByRole('button', { name: 'Authenticate' })); + + expect(authMocks.startAuth).toHaveBeenCalledWith( + 'gitlab', + expect.objectContaining({ + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', + startEndpoint: 'start', + }) + ); + }); }); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index b37b8533..42f54ca8 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -307,6 +307,56 @@ describe('useCliproxyAuthFlow', () => { kiroIDCStartUrl: 'https://d-123.awsapps.com/start', kiroIDCRegion: 'ca-central-1', kiroIDCFlow: 'authcode', + gitlabAuthMode: undefined, + gitlabBaseUrl: undefined, + gitlabPersonalAccessToken: undefined, + riskAcknowledgement: undefined, + }); + }); + + it('forwards GitLab PAT options to the backend start endpoint payload', async () => { + let requestBody: Record | null = null; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.includes('/start')) { + requestBody = JSON.parse(String(init?.body)) as Record; + return createJsonResponse({ + success: true, + account: { + id: 'gitlab-account', + provider: 'gitlab', + }, + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('gitlab', { + startEndpoint: 'start', + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', + }); + }); + + expect(requestBody).toEqual({ + nickname: undefined, + kiroMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabAuthMode: 'pat', + gitlabBaseUrl: 'https://gitlab.example.com', + gitlabPersonalAccessToken: 'glpat-test-token', riskAcknowledgement: undefined, }); }); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 6d48a838..5f953089 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest'; import { formatRequestedUpstreamModelRules, + getProviderDescription, + getProviderDisplayName, + getProviderFallbackVisual, + getProviderLogoAsset, getRequestedUpstreamModelRuleErrors, getRequestedModelId, parseRequestedUpstreamModelRules, + PROVIDER_COLORS, } from '@/lib/provider-config'; describe('provider model mapping helpers', () => { @@ -42,3 +47,45 @@ describe('provider model mapping helpers', () => { ]); }); }); + +describe('provider presentation metadata', () => { + it.each([ + ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/providers/cursor.svg'], + ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'], + [ + 'codebuddy', + 'CodeBuddy (Tencent)', + 'Tencent CodeBuddy AI assistant', + '/assets/providers/codebuddy.svg', + ], + ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.svg'], + ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { + expect(getProviderDisplayName(provider)).toBe(name); + expect(getProviderDescription(provider)).toBe(description); + expect(getProviderLogoAsset(provider)).toBe(asset); + }); + + it('provides fallback visuals and brand colors for new providers', () => { + expect(getProviderFallbackVisual('cursor')).toEqual({ + textClass: 'text-slate-900', + letter: 'C', + }); + expect(getProviderFallbackVisual('gitlab')).toEqual({ + textClass: 'text-orange-600', + letter: 'G', + }); + expect(getProviderFallbackVisual('codebuddy')).toEqual({ + textClass: 'text-blue-600', + letter: 'B', + }); + expect(getProviderFallbackVisual('kilo')).toEqual({ + textClass: 'text-rose-600', + letter: 'K', + }); + + expect(PROVIDER_COLORS.cursor).toBe('#111827'); + expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); + expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); + expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); + }); +});