fix(cursor): migrate legacy settings and validate gitlab urls

This commit is contained in:
Tam Nhu Tran
2026-04-15 17:48:33 -04:00
parent 3b5941c60b
commit 58c2c46ed7
6 changed files with 209 additions and 17 deletions
+36 -6
View File
@@ -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<string | null> {
@@ -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) {
+73 -4
View File
@@ -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<string, unknown> {
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<string, unknown>
): Record<string, unknown> {
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<string, unknown>): 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');
+5 -5
View File
@@ -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<voi
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
+2 -2
View File
@@ -30,7 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { migrateLegacyProviderSettingsIfNeeded } from '../../cliproxy/config/path-resolver';
import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder';
import { expandPath } from '../../utils/helpers';
import {
canonicalizeModelIdForProvider,
@@ -110,7 +110,7 @@ function resolveSettingsPath(profileOrVariant: string): string {
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(migrateLegacyProviderSettingsIfNeeded(directProvider));
return path.resolve(resolveProviderSettingsPath(directProvider));
}
// Check if this is a variant
@@ -318,6 +318,65 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
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<string, string>;
hooks?: Record<string, unknown>;
};
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');
@@ -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://'
);
});
});