diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index b6ba5e7e..38df474f 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -98,8 +98,10 @@ 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 normalizedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined; + if (normalizedGitLabBaseUrl) { + startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`; } const response = await fetch(buildProxyUrl(target, startPath), { headers: buildManagementHeaders(target), @@ -150,9 +152,30 @@ function parseAuthUrlState(url: string | null | undefined): string | null { } } -function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { +export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { const normalized = baseUrl?.trim(); - return normalized ? normalized : undefined; + if (!normalized) { + return undefined; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('GitLab URL must be a valid http:// or https:// URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('GitLab URL must use http:// or https://'); + } + + parsed.hash = ''; + parsed.search = ''; + parsed.username = ''; + parsed.password = ''; + + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin; } export async function promptGitLabPersonalAccessToken(): Promise { @@ -786,8 +809,15 @@ export async function triggerOAuth( 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; + let resolvedGitLabBaseUrl: string | undefined; + if (provider === 'gitlab') { + try { + resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl); + } catch (error) { + console.log(fail((error as Error).message)); + return null; + } + } if (provider === 'agy') { if (fromUI && !acceptAgyRisk) { diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index f5013944..dafd0c95 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,7 +19,10 @@ import { normalizeProtocol, CLIPROXY_DEFAULT_PORT, } from './port-manager'; -import { migrateLegacyProviderSettingsIfNeeded } from './path-resolver'; +import { + getLegacyProviderSettingsPath, + migrateLegacyProviderSettingsIfNeeded, +} from './path-resolver'; import { canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, @@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; +const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]); + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} function stripCodexEffortSuffix(modelId: string): string { return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); @@ -285,6 +297,63 @@ export function getClaudeEnvVars( return normalizeModelEnvVarsForProvider(mergedEnv, provider); } +function buildCursorProviderSettingsFromLegacy( + legacySettings: Record +): Record { + const defaultEnv = getClaudeEnvVars('cursor'); + const legacyEnvSource = legacySettings.env; + const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {}; + const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv }; + + for (const [key, value] of Object.entries(legacyEnv)) { + if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) { + continue; + } + migratedEnv[key] = value; + } + + delete migratedEnv.ANTHROPIC_API_KEY; + + return { + ...legacySettings, + env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'), + }; +} + +/** + * Resolve the provider settings path, migrating legacy Cursor provider settings into + * the dedicated cliproxy/providers namespace on first access. + */ +export function resolveProviderSettingsPath(provider: CLIProxyProvider): string { + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + if (provider !== 'cursor' || fs.existsSync(settingsPath)) { + return settingsPath; + } + + const legacySettingsPath = getLegacyProviderSettingsPath(provider); + if (!fs.existsSync(legacySettingsPath)) { + return settingsPath; + } + + try { + const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown; + if (!isObjectRecord(parsed)) { + return settingsPath; + } + + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n', + { mode: 0o600 } + ); + } catch { + // Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid. + } + + return settingsPath; +} + /** * Get global env vars to inject into all third-party profiles. * Returns empty object if disabled. @@ -465,7 +534,7 @@ export function getEffectiveEnvVars( } // Priority 2: Default provider settings file - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); // Check for user override file if (fs.existsSync(settingsPath)) { @@ -505,7 +574,7 @@ export function getEffectiveEnvVars( * Called during installation/first run */ export function ensureProviderSettings(provider: CLIProxyProvider): void { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); const defaultEnv = getClaudeEnvVars(provider); const writeSettings = (settings: Record): void => { @@ -663,7 +732,7 @@ export function getRemoteEnvVars( // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) if (Object.keys(userEnvVars).length === 0) { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); if (fs.existsSync(settingsPath)) { try { const content = fs.readFileSync(settingsPath, 'utf-8'); diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index b1824361..8b99316d 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; -import { getClaudeEnvVars, migrateLegacyProviderSettingsIfNeeded } from './config-generator'; +import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; @@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string) * Check if provider has user settings configured */ export function hasUserSettings(provider: CLIProxyProvider): boolean { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); return fs.existsSync(settingsPath); } @@ -46,7 +46,7 @@ export function getCurrentModel( ): string | undefined { const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : migrateLegacyProviderSettingsIfNeeded(provider); + : resolveProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; try { @@ -116,7 +116,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : migrateLegacyProviderSettingsIfNeeded(provider); + : resolveProviderSettingsPath(provider); // Skip if already configured with a model (unless --config flag). // A settings file can exist without model env keys (e.g., hook-only writes). @@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); }); + it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => { + process.env.CCS_HOME = tempHome; + const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json'); + const providerSettingsPath = path.join( + tempHome, + '.ccs', + 'cliproxy', + 'providers', + 'cursor.settings.json' + ); + fs.mkdirSync(path.dirname(legacySettingsPath), { recursive: true }); + fs.writeFileSync( + legacySettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:20129', + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_API_KEY: 'legacy-cursor-api-key', + ANTHROPIC_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-4-opus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cursor-small', + ANTHROPIC_SMALL_FAST_MODEL: 'cursor-small', + DISABLE_TELEMETRY: '1', + }, + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [] }], + }, + }, + null, + 2 + ) + ); + + const env = getEffectiveEnvVars('cursor'); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-small'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(env.DISABLE_TELEMETRY).toBe('1'); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + + const migrated = JSON.parse(fs.readFileSync(providerSettingsPath, 'utf-8')) as { + env: Record; + hooks?: Record; + }; + expect(migrated.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(migrated.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(migrated.env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(migrated.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(migrated.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(migrated.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(migrated.hooks?.PreToolUse).toBeDefined(); + }); + it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 45873915..91b21a20 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -262,3 +262,37 @@ describe('promptGitLabPersonalAccessToken', () => { expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token'); }); }); + +describe('normalizeGitLabBaseUrl', () => { + it('returns undefined for blank values', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined(); + expect(normalizeGitLabBaseUrl(' ')).toBeUndefined(); + }); + + it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe( + 'https://gitlab.example.com/custom' + ); + }); + + it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}` + ); + + expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow( + 'GitLab URL must be a valid http:// or https:// URL' + ); + expect(() => normalizeGitLabBaseUrl('ftp://gitlab.example.com')).toThrow( + 'GitLab URL must use http:// or https://' + ); + }); +});