feat: make cliproxy provider nicknames optional by default

This commit is contained in:
Tam Nhu Tran
2026-03-23 11:02:08 -04:00
parent 834704938d
commit 4df08f6d99
46 changed files with 2076 additions and 1096 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI # Dashboard Authentication CLI
Last Updated: 2026-03-17 Last Updated: 2026-03-23
CLI commands for managing CCS dashboard authentication. CLI commands for managing CCS dashboard authentication.
@@ -10,6 +10,8 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it. Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth.
## Account Context Modes (Related Feature) ## Account Context Modes (Related Feature)
Dashboard auth and account context metadata are separate: Dashboard auth and account context metadata are separate:
@@ -180,7 +182,8 @@ dashboard_auth:
1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage 1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage
2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript) 2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript)
3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes) 3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes)
4. **File permissions**: Config file is created with 0o600 permissions 4. **Fail-closed remote writes**: When auth is disabled, sensitive management routes allow localhost only
5. **File permissions**: Config file is created with 0o600 permissions
## Troubleshooting ## Troubleshooting
+3 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap # CCS Project Roadmap
Last Updated: 2026-03-19 Last Updated: 2026-03-23
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes ### Recent Fixes
- **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes.
- **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests.
- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. - **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles.
- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback.
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
@@ -361,6 +361,10 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
**Providers**: GitHub Copilot (ghcp) **Providers**: GitHub Copilot (ghcp)
Provider identity note:
- Providers that do not expose a reliable email no longer require a manual nickname during first auth.
- CCS derives a stable internal account identifier from the token/cache context and still allows the user to rename the account later.
``` ```
+===========================================================================+ +===========================================================================+
| OAuth - Device Code Flow (No Port Needed) | | OAuth - Device Code Flow (No Port Needed) |
@@ -432,6 +436,8 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
- Device Code method uses /start route (no callback port) - Device Code method uses /start route (no callback port)
- Callback/social methods use /start-url + status polling - Callback/social methods use /start-url + status polling
- Some management flows return state first, auth_url later - Some management flows return state first, auth_url later
- Manual nicknames are optional when the upstream provider does not return an email
- Account storage uses a stable internal identifier so reauth/update flows do not depend on dashboard list order
``` ```
### API Key Profiles (GLM, Kimi) ### API Key Profiles (GLM, Kimi)
+11 -15
View File
@@ -12,11 +12,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names'; import { isReservedName } from '../../config/reserved-names';
import { import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
isUnifiedMode,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { validateApiName } from './validation-service'; import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader'; import { listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
@@ -64,17 +60,17 @@ function writeJsonObjectAtomically(filePath: string, value: unknown): void {
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void { function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
if (isUnifiedMode()) { if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (config.profiles[name] && !force) { if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`); throw new Error(`API profile already exists: ${name}`);
} }
config.profiles[name] = { config.profiles[name] = {
type: 'api', type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`, settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }), ...(target !== 'claude' && { target }),
}; };
saveUnifiedConfig(config); });
return; return;
} }
+33 -41
View File
@@ -7,11 +7,7 @@ import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers'; import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service'; import { validateApiName } from './validation-service';
import { import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter'; import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider'; import { resolveDroidProvider } from '../../targets/droid-provider';
@@ -198,13 +194,13 @@ function createApiProfileUnified(
// Inject WebSearch hooks into profile settings // Inject WebSearch hooks into profile settings
ensureProfileHooks(name); ensureProfileHooks(name);
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
config.profiles[name] = { config.profiles[name] = {
type: 'api', type: 'api',
settings: `~/.ccs/${settingsFile}`, settings: `~/.ccs/${settingsFile}`,
...(target !== 'claude' && { target }), ...(target !== 'claude' && { target }),
}; };
saveUnifiedConfig(config); });
} }
/** Create a new API profile */ /** Create a new API profile */
@@ -308,17 +304,17 @@ export function updateApiProfileTarget(
): UpdateApiProfileTargetResult { ): UpdateApiProfileTargetResult {
try { try {
if (isUnifiedMode()) { if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.profiles[name]) { if (!config.profiles[name]) {
return { success: false, error: `API profile not found: ${name}` }; throw new Error(`API profile not found: ${name}`);
} }
if (target === 'claude') { if (target === 'claude') {
delete config.profiles[name].target; delete config.profiles[name].target;
} else { } else {
config.profiles[name].target = target; config.profiles[name].target = target;
} }
saveUnifiedConfig(config); });
return { success: true, target }; return { success: true, target };
} }
@@ -357,30 +353,26 @@ export function updateApiProfileTarget(
/** Remove API profile from unified config */ /** Remove API profile from unified config */
function removeApiProfileUnified(name: string): void { function removeApiProfileUnified(name: string): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
const profile = config.profiles[name]; const profile = config.profiles[name];
if (!profile) { if (!profile) {
throw new Error(`API profile not found: ${name}`); throw new Error(`API profile not found: ${name}`);
}
// Delete the settings file if it exists.
// Uses expandPath() for cross-platform path handling.
if (profile.settings) {
const settingsPath = expandPath(profile.settings);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
} }
}
delete config.profiles[name]; if (profile.settings) {
const settingsPath = expandPath(profile.settings);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
}
// Clear default if it was the deleted profile delete config.profiles[name];
if (config.default === name) {
config.default = undefined;
}
saveUnifiedConfig(config); if (config.default === name) {
config.default = undefined;
}
});
} }
/** Remove API profile from legacy config */ /** Remove API profile from legacy config */
+48 -50
View File
@@ -3,7 +3,7 @@ import * as path from 'path';
import { ProfileMetadata } from '../types'; import { ProfileMetadata } from '../types';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
saveUnifiedConfig, mutateUnifiedConfig,
isUnifiedMode, isUnifiedMode,
} from '../config/unified-config-loader'; } from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types'; import type { AccountConfig } from '../config/unified-config-types';
@@ -313,74 +313,72 @@ export class ProfileRegistry {
* Create account in unified config (config.yaml) * Create account in unified config (config.yaml)
*/ */
createAccountUnified(name: string, metadata: CreateMetadata = {}): void { createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (config.accounts[name]) { if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`); throw new Error(`Account already exists: ${name}`);
} }
config.accounts[name] = this.normalizeUnifiedAccountConfig({ config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(), created: new Date().toISOString(),
last_used: null, last_used: null,
context_mode: metadata.context_mode, context_mode: metadata.context_mode,
context_group: metadata.context_group, context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode, continuity_mode: metadata.continuity_mode,
bare: metadata.bare, bare: metadata.bare,
});
}); });
saveUnifiedConfig(config);
} }
/** /**
* Update account metadata in unified config * Update account metadata in unified config
*/ */
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void { updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.accounts[name]) { if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`); throw new Error(`Account not found: ${name}`);
} }
config.accounts[name] = this.normalizeUnifiedAccountConfig({ config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name], ...config.accounts[name],
...updates, ...updates,
});
}); });
saveUnifiedConfig(config);
} }
/** /**
* Remove account from unified config * Remove account from unified config
*/ */
removeAccountUnified(name: string): void { removeAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.accounts[name]) { if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`); throw new Error(`Account not found: ${name}`);
} }
delete config.accounts[name]; delete config.accounts[name];
// Clear default if it was the deleted account if (config.default === name) {
if (config.default === name) { config.default = undefined;
config.default = undefined; }
} });
saveUnifiedConfig(config);
} }
/** /**
* Set default profile in unified config * Set default profile in unified config
*/ */
setDefaultUnified(name: string): void { setDefaultUnified(name: string): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
// Check if exists in accounts, profiles, or cliproxy variants const exists =
const exists = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; if (!exists) {
if (!exists) { throw new Error(`Profile not found: ${name}`);
throw new Error(`Profile not found: ${name}`); }
} config.default = name;
config.default = name; });
saveUnifiedConfig(config);
} }
/** /**
* Clear default profile in unified config (restore original CCS behavior) * Clear default profile in unified config (restore original CCS behavior)
*/ */
clearDefaultUnified(): void { clearDefaultUnified(): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
config.default = undefined; config.default = undefined;
saveUnifiedConfig(config); });
} }
/** /**
@@ -418,13 +416,13 @@ export class ProfileRegistry {
* Update account last_used in unified config * Update account last_used in unified config
*/ */
touchAccountUnified(name: string): void { touchAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.accounts[name]) { if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`); throw new Error(`Account not found: ${name}`);
} }
config.accounts[name].last_used = new Date().toISOString(); config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
saveUnifiedConfig(config); });
} }
// ========================================== // ==========================================
+3 -5
View File
@@ -6,7 +6,7 @@
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { AccountInfo } from './types'; import { AccountInfo } from './types';
import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry } from './registry'; import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
/** /**
* Get all accounts for a provider * Get all accounts for a provider
@@ -14,10 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] { export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
const registry = loadAccountsRegistry(); const registry = loadAccountsRegistry();
// Sync with actual token files (removes stale entries) // Sync in-memory view with actual token files without mutating disk on read.
if (syncRegistryWithTokenFiles(registry)) { syncRegistryWithTokenFiles(registry);
saveAccountsRegistry(registry);
}
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
+312 -326
View File
@@ -5,9 +5,10 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider } from '../types';
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
import { getAuthDir } from '../config-generator'; import { getAuthDir, getCliproxyDir } from '../config-generator';
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
import { import {
getAccountsRegistryPath, getAccountsRegistryPath,
@@ -30,32 +31,58 @@ function createDefaultRegistry(): AccountsRegistry {
}; };
} }
/** function withAccountsRegistryLock<T>(callback: () => T): T {
* Load accounts registry const lockTarget = getCliproxyDir();
*/ let release: (() => void) | undefined;
export function loadAccountsRegistry(): AccountsRegistry {
if (!fs.existsSync(lockTarget)) {
fs.mkdirSync(lockTarget, { recursive: true, mode: 0o700 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release
}
}
}
}
function readAccountsRegistryFromDisk(): AccountsRegistry {
const registryPath = getAccountsRegistryPath(); const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) { if (!fs.existsSync(registryPath)) {
return createDefaultRegistry(); return createDefaultRegistry();
} }
const content = fs.readFileSync(registryPath, 'utf-8');
let data: unknown;
try { try {
const content = fs.readFileSync(registryPath, 'utf-8'); data = JSON.parse(content);
const data = JSON.parse(content); } catch (error) {
return { throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`);
version: data.version || 1,
providers: data.providers || {},
};
} catch {
return createDefaultRegistry();
} }
if (!data || typeof data !== 'object') {
throw new Error('Accounts registry is corrupted: expected object');
}
const parsed = data as { version?: unknown; providers?: unknown };
return {
version: typeof parsed.version === 'number' ? parsed.version : 1,
providers:
parsed.providers && typeof parsed.providers === 'object'
? (parsed.providers as AccountsRegistry['providers'])
: {},
};
} }
/** function writeAccountsRegistryToDisk(registry: AccountsRegistry): void {
* Save accounts registry
*/
export function saveAccountsRegistry(registry: AccountsRegistry): void {
const registryPath = getAccountsRegistryPath(); const registryPath = getAccountsRegistryPath();
const dir = path.dirname(registryPath); const dir = path.dirname(registryPath);
@@ -63,9 +90,39 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} }
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', { const tempPath = `${registryPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(registry, null, 2) + '\n', {
mode: 0o600, mode: 0o600,
}); });
fs.renameSync(tempPath, registryPath);
}
function mutateAccountsRegistry<T>(mutator: (registry: AccountsRegistry) => T): T {
return withAccountsRegistryLock(() => {
const registry = readAccountsRegistryFromDisk();
const initialSnapshot = JSON.stringify(registry);
const result = mutator(registry);
if (JSON.stringify(registry) !== initialSnapshot) {
writeAccountsRegistryToDisk(registry);
}
return result;
});
}
/**
* Load accounts registry
*/
export function loadAccountsRegistry(): AccountsRegistry {
return readAccountsRegistryFromDisk();
}
/**
* Save accounts registry
*/
export function saveAccountsRegistry(registry: AccountsRegistry): void {
withAccountsRegistryLock(() => {
writeAccountsRegistryToDisk(registry);
});
} }
/** /**
@@ -133,110 +190,103 @@ export function registerAccount(
nickname?: string, nickname?: string,
projectId?: string projectId?: string
): AccountInfo { ): AccountInfo {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
syncRegistryWithTokenFiles(registry);
// Initialize provider section if needed if (!registry.providers[provider]) {
if (!registry.providers[provider]) { registry.providers[provider] = {
registry.providers[provider] = { default: 'default',
default: 'default', accounts: {},
accounts: {}, };
};
}
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
throw new Error('Failed to initialize provider accounts');
}
// Determine account ID based on provider type
let accountId: string;
let accountNickname: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
accountId = email
? extractAccountIdFromTokenFile(tokenFile, email)
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
const existingAccount = providerAccounts.accounts[accountId];
if (nickname) {
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
);
}
} }
accountNickname = const providerAccounts = registry.providers[provider];
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId); if (!providerAccounts) {
} else { throw new Error('Failed to initialize provider accounts');
// For other providers: use email as accountId, fallback to filename extraction }
accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
}
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; let accountId: string;
let accountNickname: string;
// Create or update account if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
const existingAccount = providerAccounts.accounts[accountId]; accountId = email
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = { ? extractAccountIdFromTokenFile(tokenFile, email)
email, : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
nickname: accountNickname, const existingAccount = providerAccounts.accounts[accountId];
tokenFile,
createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
// Include projectId for Antigravity accounts if (nickname) {
if (provider === 'agy' && projectId) { const validationError = validateNickname(nickname);
accountMeta.projectId = projectId; if (validationError) {
} throw new Error(validationError);
}
providerAccounts.accounts[accountId] = accountMeta; const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
);
}
}
// Set as default if first account accountNickname =
if (isFirstAccount) { nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
providerAccounts.default = accountId; } else {
} accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
}
saveAccountsRegistry(registry); const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
const existingAccount = providerAccounts.accounts[accountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: accountNickname,
tokenFile,
createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
return { if (provider === 'agy' && projectId) {
id: accountId, accountMeta.projectId = projectId;
provider, }
isDefault: accountId === providerAccounts.default,
email, providerAccounts.accounts[accountId] = accountMeta;
nickname: accountNickname,
tokenFile, if (isFirstAccount) {
createdAt: providerAccounts.accounts[accountId].createdAt, providerAccounts.default = accountId;
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, }
projectId: providerAccounts.accounts[accountId].projectId,
}; return {
id: accountId,
provider,
isDefault: accountId === providerAccounts.default,
email,
nickname: accountNickname,
tokenFile,
createdAt: providerAccounts.accounts[accountId].createdAt,
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
projectId: providerAccounts.accounts[accountId].projectId,
};
});
} }
/** /**
* Set default account for a provider * Set default account for a provider
*/ */
export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean { export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) { if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false; return false;
} }
providerAccounts.default = accountId; providerAccounts.default = accountId;
saveAccountsRegistry(registry); return true;
return true; });
} }
/** /**
@@ -244,27 +294,26 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it * Moves token file to paused/ subdir so CLIProxyAPI won't discover it
*/ */
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean { export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) { if (!providerAccounts?.accounts[accountId]) {
return false; return false;
} }
const accountMeta = providerAccounts.accounts[accountId]; const accountMeta = providerAccounts.accounts[accountId];
if (accountMeta.paused) {
return true;
}
// Skip if already paused (idempotent) if (!moveTokenToPaused(accountMeta.tokenFile)) {
if (accountMeta.paused) { return false;
}
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
return true; return true;
} });
// Move token file to paused directory (if it exists in auth dir)
moveTokenToPaused(accountMeta.tokenFile);
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
saveAccountsRegistry(registry);
return true;
} }
/** /**
@@ -272,55 +321,53 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it * Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
*/ */
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean { export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) { if (!providerAccounts?.accounts[accountId]) {
return false; return false;
} }
const accountMeta = providerAccounts.accounts[accountId]; const accountMeta = providerAccounts.accounts[accountId];
if (!accountMeta.paused) {
return true;
}
// Skip if already active (idempotent) if (!moveTokenFromPaused(accountMeta.tokenFile)) {
if (!accountMeta.paused) { return false;
}
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
return true; return true;
} });
// Move token file back from paused directory (if it exists in paused dir)
moveTokenFromPaused(accountMeta.tokenFile);
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
saveAccountsRegistry(registry);
return true;
} }
/** /**
* Remove an account * Remove an account
*/ */
export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean { export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) { if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false; return false;
} }
// Delete token file from both auth and paused directories const tokenFile = providerAccounts.accounts[accountId].tokenFile;
const tokenFile = providerAccounts.accounts[accountId].tokenFile; if (!deleteTokenFile(tokenFile)) {
deleteTokenFile(tokenFile); return false;
}
// Remove from registry delete providerAccounts.accounts[accountId];
delete providerAccounts.accounts[accountId];
// Update default if needed const remainingAccounts = Object.keys(providerAccounts.accounts);
const remainingAccounts = Object.keys(providerAccounts.accounts); if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
if (providerAccounts.default === accountId && remainingAccounts.length > 0) { providerAccounts.default = remainingAccounts[0];
providerAccounts.default = remainingAccounts[0]; }
}
saveAccountsRegistry(registry); return true;
return true; });
} }
/** /**
@@ -336,37 +383,36 @@ export function renameAccount(
throw new Error(validationError); throw new Error(validationError);
} }
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) { if (!providerAccounts?.accounts[accountId]) {
return false; return false;
} }
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id, id,
nickname: account.nickname, nickname: account.nickname,
})); }));
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) { if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
throw new Error(`Nickname "${newNickname}" is already used by another account`); throw new Error(`Nickname "${newNickname}" is already used by another account`);
} }
providerAccounts.accounts[accountId].nickname = newNickname; providerAccounts.accounts[accountId].nickname = newNickname;
saveAccountsRegistry(registry); return true;
return true; });
} }
/** /**
* Update last used timestamp for an account * Update last used timestamp for an account
*/ */
export function touchAccount(provider: CLIProxyProvider, accountId: string): void { export function touchAccount(provider: CLIProxyProvider, accountId: string): void {
const registry = loadAccountsRegistry(); mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (providerAccounts?.accounts[accountId]) {
if (providerAccounts?.accounts[accountId]) { providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString(); }
saveAccountsRegistry(registry); });
}
} }
/** /**
@@ -377,16 +423,16 @@ export function setAccountTier(
accountId: string, accountId: string,
tier: 'free' | 'pro' | 'ultra' | 'unknown' tier: 'free' | 'pro' | 'ultra' | 'unknown'
): boolean { ): boolean {
const registry = loadAccountsRegistry(); return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider]; const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) { if (!providerAccounts?.accounts[accountId]) {
return false; return false;
} }
providerAccounts.accounts[accountId].tier = tier; providerAccounts.accounts[accountId].tier = tier;
saveAccountsRegistry(registry); return true;
return true; });
} }
/** /**
@@ -404,163 +450,103 @@ export function discoverExistingAccounts(): void {
return; return;
} }
const registry = loadAccountsRegistry();
const files = fs.readdirSync(authDir); const files = fs.readdirSync(authDir);
mutateAccountsRegistry((registry) => {
syncRegistryWithTokenFiles(registry);
// Track whether any accounts were discovered (to avoid saving empty registry) for (const file of files) {
let discoveredCount = 0; if (!file.endsWith('.json')) continue;
for (const file of files) { const filePath = path.join(authDir, file);
if (!file.endsWith('.json')) continue;
const filePath = path.join(authDir, file); try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!data.type) continue;
try { const typeValue = data.type.toLowerCase();
const content = fs.readFileSync(filePath, 'utf-8'); let provider: CLIProxyProvider | undefined;
const data = JSON.parse(content); for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
if (typeValues.includes(typeValue)) {
// Skip if no type field provider = prov as CLIProxyProvider;
if (!data.type) continue; break;
}
// Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider)
// e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro"
const typeValue = data.type.toLowerCase();
let provider: CLIProxyProvider | undefined;
for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
if (typeValues.includes(typeValue)) {
provider = prov as CLIProxyProvider;
break;
} }
}
// Skip if unknown provider type if (!provider) {
if (!provider) { continue;
continue;
}
// Extract email if available, fallback to filename-based ID
let email = data.email || undefined;
// Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json")
if (!email && file.includes('@')) {
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
if (match) {
email = match[1];
} }
}
// Initialize provider section if needed let email = data.email || undefined;
if (!registry.providers[provider]) { if (!email && file.includes('@')) {
registry.providers[provider] = { const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
default: 'default', if (match) {
accounts: {}, email = match[1];
}
}
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: 'default',
accounts: {},
};
}
const providerAccounts = registry.providers[provider];
if (!providerAccounts) continue;
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
if (existingTokenFiles.includes(file)) {
const projectIdValue =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
if (provider === 'agy' && projectIdValue) {
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, meta]) => meta.tokenFile === file
);
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
existingEntry[1].projectId = projectIdValue;
}
}
continue;
}
const accountId =
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
: extractAccountIdFromTokenFile(file, email);
if (providerAccounts.accounts[accountId]) {
continue;
}
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = accountId;
}
const stats = fs.statSync(filePath);
const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: email ? generateNickname(email) : accountId,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
}; };
}
const providerAccounts = registry.providers[provider]; const discoveredProjectId =
if (!providerAccounts) continue;
// Skip if token file already registered (under any accountId)
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
if (existingTokenFiles.includes(file)) {
// Token file exists - check if we need to update projectId for agy accounts
const projectIdValue =
typeof data.project_id === 'string' && data.project_id.trim() typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim() ? data.project_id.trim()
: null; : null;
if (provider === 'agy' && projectIdValue) { if (provider === 'agy' && discoveredProjectId) {
const existingEntry = Object.entries(providerAccounts.accounts).find( accountMeta.projectId = discoveredProjectId;
([, meta]) => meta.tokenFile === file
);
// Update if missing or changed
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
existingEntry[1].projectId = projectIdValue;
discoveredCount++; // Count projectId updates as changes
}
} }
providerAccounts.accounts[accountId] = accountMeta;
} catch {
continue; continue;
} }
const accountId =
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
: extractAccountIdFromTokenFile(file, email);
// Skip if account already registered
if (providerAccounts.accounts[accountId]) {
continue;
}
// Set as default if first account
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = accountId;
}
// Get file stats for creation time
const stats = fs.statSync(filePath);
// Register account with auto-generated nickname
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: email ? generateNickname(email) : accountId,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
};
// Read project_id for Antigravity accounts (read-only field from auth token)
const discoveredProjectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
if (provider === 'agy' && discoveredProjectId) {
accountMeta.projectId = discoveredProjectId;
}
providerAccounts.accounts[accountId] = accountMeta;
discoveredCount++;
} catch {
// Skip invalid files
continue;
} }
} });
// Only save if at least one account was discovered or updated
// This prevents creating accounts.json with empty provider sections
if (discoveredCount === 0) {
return;
}
// Reload-merge pattern: reduce race condition with concurrent OAuth registration
// Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts)
const freshRegistry = loadAccountsRegistry();
for (const [providerName, discovered] of Object.entries(registry.providers)) {
if (!discovered) continue;
// Skip empty provider sections (no accounts discovered)
if (Object.keys(discovered.accounts).length === 0) continue;
const prov = providerName as CLIProxyProvider;
if (!freshRegistry.providers[prov]) {
freshRegistry.providers[prov] = discovered;
} else {
// Merge accounts, preferring fresh registry's existing entries but updating projectId
const freshProviderAccounts = freshRegistry.providers[prov];
if (!freshProviderAccounts) continue;
for (const [id, meta] of Object.entries(discovered.accounts)) {
if (!freshProviderAccounts.accounts[id]) {
freshProviderAccounts.accounts[id] = meta;
// Set default if none exists
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
freshProviderAccounts.default = id;
}
} else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) {
// Update existing account with projectId if discovered from auth file
freshProviderAccounts.accounts[id].projectId = meta.projectId;
}
}
}
}
saveAccountsRegistry(freshRegistry);
} }
+6 -3
View File
@@ -93,16 +93,17 @@ export function moveTokenFromPaused(tokenFile: string): boolean {
* Delete token file from both auth and paused directories * Delete token file from both auth and paused directories
* Idempotent * Idempotent
*/ */
export function deleteTokenFile(tokenFile: string): void { export function deleteTokenFile(tokenFile: string): boolean {
const tokenPath = path.join(getAuthDir(), tokenFile); const tokenPath = path.join(getAuthDir(), tokenFile);
const pausedPath = path.join(getPausedDir(), tokenFile); const pausedPath = path.join(getPausedDir(), tokenFile);
let success = true;
// Delete from auth directory // Delete from auth directory
if (fs.existsSync(tokenPath)) { if (fs.existsSync(tokenPath)) {
try { try {
fs.unlinkSync(tokenPath); fs.unlinkSync(tokenPath);
} catch { } catch {
// Ignore deletion errors success = false;
} }
} }
@@ -111,9 +112,11 @@ export function deleteTokenFile(tokenFile: string): void {
try { try {
fs.unlinkSync(pausedPath); fs.unlinkSync(pausedPath);
} catch { } catch {
// Ignore deletion errors success = false;
} }
} }
return success;
} }
/** /**
+60 -4
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator'; import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator';
@@ -21,6 +22,41 @@ type FamilyEntriesMap = {
export type FamilyEntries<F extends AiProviderFamilyId> = FamilyEntriesMap[F]; export type FamilyEntries<F extends AiProviderFamilyId> = FamilyEntriesMap[F];
type EntryWithOptionalId = {
id?: string;
};
function createFamilyEntryId(family: AiProviderFamilyId): string {
return `${family}-${randomUUID()}`;
}
function ensureStableEntryIds<F extends AiProviderFamilyId>(
family: F,
entries: FamilyEntries<F>
): { entries: FamilyEntries<F>; changed: boolean } {
const seenIds = new Set<string>();
let changed = false;
const normalizedEntries = entries.map((entry) => {
const rawId = (entry as EntryWithOptionalId).id;
const normalizedId = typeof rawId === 'string' && rawId.trim().length > 0 ? rawId.trim() : null;
const nextId =
normalizedId && !seenIds.has(normalizedId) ? normalizedId : createFamilyEntryId(family);
if (normalizedId !== nextId) {
changed = true;
}
seenIds.add(nextId);
return {
...entry,
id: nextId,
};
}) as FamilyEntries<F>;
return { entries: normalizedEntries, changed };
}
function ensureLocalConfigPath(): string { function ensureLocalConfigPath(): string {
if (!configExists()) { if (!configExists()) {
regenerateConfig(); regenerateConfig();
@@ -91,7 +127,12 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
if (!target.isRemote) { if (!target.isRemote) {
const config = readLocalConfig(); const config = readLocalConfig();
return (config[family] || []) as FamilyEntries<F>; const entries = (Array.isArray(config[family]) ? config[family] : []) as FamilyEntries<F>;
const normalized = ensureStableEntryIds(family, entries);
if (normalized.changed) {
writeLocalFamilySection(family, normalized.entries);
}
return normalized.entries;
} }
const client = createManagementClient({ const client = createManagementClient({
@@ -102,17 +143,29 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
auth_token: target.authToken, auth_token: target.authToken,
}); });
return client.getSection<FamilyEntries<F>[number]>(family) as Promise<FamilyEntries<F>>; const entries = (await client.getSection<FamilyEntries<F>[number]>(family)) as FamilyEntries<F>;
const normalized = ensureStableEntryIds(
family,
Array.isArray(entries) ? entries : ([] as unknown as FamilyEntries<F>)
);
if (normalized.changed) {
await client.putSection<FamilyEntries<F>[number]>(
family,
normalized.entries as FamilyEntries<F>[number][]
);
}
return normalized.entries;
} }
export async function writeFamilyEntries<F extends AiProviderFamilyId>( export async function writeFamilyEntries<F extends AiProviderFamilyId>(
family: F, family: F,
entries: FamilyEntries<F> entries: FamilyEntries<F>
): Promise<void> { ): Promise<void> {
const normalized = ensureStableEntryIds(family, entries);
const target = getProxyTarget(); const target = getProxyTarget();
if (!target.isRemote) { if (!target.isRemote) {
writeLocalFamilySection(family, entries); writeLocalFamilySection(family, normalized.entries);
return; return;
} }
@@ -124,5 +177,8 @@ export async function writeFamilyEntries<F extends AiProviderFamilyId>(
auth_token: target.authToken, auth_token: target.authToken,
}); });
await client.putSection<FamilyEntries<F>[number]>(family, entries as FamilyEntries<F>[number][]); await client.putSection<FamilyEntries<F>[number]>(
family,
normalized.entries as FamilyEntries<F>[number][]
);
} }
+49 -10
View File
@@ -51,7 +51,7 @@ function buildApiKeyEntryView(
index: number index: number
): AiProviderEntryView { ): AiProviderEntryView {
return { return {
id: `${family}:${index}`, id: entry.id || `${family}:${index}`,
index, index,
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
baseUrl: entry['base-url']?.trim() || undefined, baseUrl: entry['base-url']?.trim() || undefined,
@@ -67,7 +67,7 @@ function buildApiKeyEntryView(
function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView { function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView {
return { return {
id: `openai-compatibility:${index}`, id: entry.id || `openai-compatibility:${index}`,
index, index,
name: entry.name, name: entry.name,
label: entry.name, label: entry.name,
@@ -131,6 +131,7 @@ function toApiKeyEntry(
: existing?.['api-key'] || ''; : existing?.['api-key'] || '';
return { return {
id: existing?.id,
'api-key': nextSecret, 'api-key': nextSecret,
'base-url': input.baseUrl?.trim() || undefined, 'base-url': input.baseUrl?.trim() || undefined,
'proxy-url': input.proxyUrl?.trim() || undefined, 'proxy-url': input.proxyUrl?.trim() || undefined,
@@ -155,6 +156,7 @@ function toOpenAiCompatEntry(
: (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']); : (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']);
return { return {
id: existing?.id,
name: input.name?.trim() || existing?.name || 'connector', name: input.name?.trim() || existing?.name || 'connector',
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
headers: normalizeHeaders(input.headers), headers: normalizeHeaders(input.headers),
@@ -163,8 +165,41 @@ function toOpenAiCompatEntry(
}; };
} }
function assertIndex(entries: unknown[], index: number): void { function resolveEntryIndex(entries: Array<{ id?: string }>, entryId: string): number {
if (!Number.isInteger(index) || index < 0 || index >= entries.length) { const normalizedEntryId = entryId.trim();
const matchedIndex = entries.findIndex((entry) => entry.id === normalizedEntryId);
if (matchedIndex !== -1) {
return matchedIndex;
}
const legacyIndex = Number.parseInt(normalizedEntryId, 10);
if (
Number.isInteger(legacyIndex) &&
legacyIndex >= 0 &&
legacyIndex < entries.length &&
String(legacyIndex) === normalizedEntryId
) {
return legacyIndex;
}
if (normalizedEntryId.startsWith('openai-compatibility:') || normalizedEntryId.includes(':')) {
const legacySuffix = normalizedEntryId.split(':').at(-1) || '';
const legacySuffixIndex = Number.parseInt(legacySuffix, 10);
if (
Number.isInteger(legacySuffixIndex) &&
legacySuffixIndex >= 0 &&
legacySuffixIndex < entries.length &&
String(legacySuffixIndex) === legacySuffix
) {
return legacySuffixIndex;
}
}
throw new Error('Entry not found');
}
function assertEntryId(entryId: string): void {
if (!entryId.trim()) {
throw new Error('Entry not found'); throw new Error('Entry not found');
} }
} }
@@ -208,12 +243,14 @@ export async function createAiProviderEntry(
export async function updateAiProviderEntry( export async function updateAiProviderEntry(
family: AiProviderFamilyId, family: AiProviderFamilyId,
index: number, entryId: string,
input: UpsertAiProviderEntryInput input: UpsertAiProviderEntryInput
): Promise<void> { ): Promise<void> {
assertEntryId(entryId);
if (family === 'openai-compatibility') { if (family === 'openai-compatibility') {
const entries = await readFamilyEntries(family); const entries = await readFamilyEntries(family);
assertIndex(entries, index); const index = resolveEntryIndex(entries, entryId);
validateFamilyInput(family, input); validateFamilyInput(family, input);
entries[index] = toOpenAiCompatEntry(input, entries[index]); entries[index] = toOpenAiCompatEntry(input, entries[index]);
await writeFamilyEntries(family, entries); await writeFamilyEntries(family, entries);
@@ -221,7 +258,7 @@ export async function updateAiProviderEntry(
} }
const entries = await readFamilyEntries(family); const entries = await readFamilyEntries(family);
assertIndex(entries, index); const index = resolveEntryIndex(entries, entryId);
validateFamilyInput(family, input); validateFamilyInput(family, input);
entries[index] = toApiKeyEntry(input, entries[index]); entries[index] = toApiKeyEntry(input, entries[index]);
await writeFamilyEntries(family, entries); await writeFamilyEntries(family, entries);
@@ -229,18 +266,20 @@ export async function updateAiProviderEntry(
export async function deleteAiProviderEntry( export async function deleteAiProviderEntry(
family: AiProviderFamilyId, family: AiProviderFamilyId,
index: number entryId: string
): Promise<void> { ): Promise<void> {
assertEntryId(entryId);
if (family === 'openai-compatibility') { if (family === 'openai-compatibility') {
const entries = await readFamilyEntries(family); const entries = await readFamilyEntries(family);
assertIndex(entries, index); const index = resolveEntryIndex(entries, entryId);
entries.splice(index, 1); entries.splice(index, 1);
await writeFamilyEntries(family, entries); await writeFamilyEntries(family, entries);
return; return;
} }
const entries = await readFamilyEntries(family); const entries = await readFamilyEntries(family);
assertIndex(entries, index); const index = resolveEntryIndex(entries, entryId);
entries.splice(index, 1); entries.splice(index, 1);
await writeFamilyEntries(family, entries); await writeFamilyEntries(family, entries);
} }
+2
View File
@@ -14,6 +14,7 @@ export interface AiProviderModelAlias {
} }
export interface AiProviderApiKeyEntry { export interface AiProviderApiKeyEntry {
id?: string;
'api-key': string; 'api-key': string;
'base-url'?: string; 'base-url'?: string;
'proxy-url'?: string; 'proxy-url'?: string;
@@ -29,6 +30,7 @@ export interface OpenAICompatApiKeyEntry {
} }
export interface OpenAICompatEntry { export interface OpenAICompatEntry {
id?: string;
name: string; name: string;
'base-url': string; 'base-url': string;
headers?: Record<string, string>; headers?: Record<string, string>;
+46 -56
View File
@@ -8,7 +8,7 @@
*/ */
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator'; import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
/** /**
@@ -87,19 +87,17 @@ export function getEffectiveManagementSecret(): string {
* @param apiKey - New API key (or undefined to reset to default) * @param apiKey - New API key (or undefined to reset to default)
*/ */
export function setGlobalApiKey(apiKey: string | undefined): void { export function setGlobalApiKey(apiKey: string | undefined): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (!config.cliproxy.auth) { if (apiKey === undefined) {
config.cliproxy.auth = {}; delete config.cliproxy.auth.api_key;
} } else {
config.cliproxy.auth.api_key = apiKey;
if (apiKey === undefined) { }
delete config.cliproxy.auth.api_key; });
} else {
config.cliproxy.auth.api_key = apiKey;
}
saveUnifiedConfig(config);
} }
/** /**
@@ -109,19 +107,17 @@ export function setGlobalApiKey(apiKey: string | undefined): void {
* @param secret - New management secret (or undefined to reset to default) * @param secret - New management secret (or undefined to reset to default)
*/ */
export function setGlobalManagementSecret(secret: string | undefined): void { export function setGlobalManagementSecret(secret: string | undefined): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (!config.cliproxy.auth) { if (secret === undefined) {
config.cliproxy.auth = {}; delete config.cliproxy.auth.management_secret;
} } else {
config.cliproxy.auth.management_secret = secret;
if (secret === undefined) { }
delete config.cliproxy.auth.management_secret; });
} else {
config.cliproxy.auth.management_secret = secret;
}
saveUnifiedConfig(config);
} }
/** /**
@@ -132,28 +128,26 @@ export function setGlobalManagementSecret(secret: string | undefined): void {
* @param apiKey - New API key (or undefined to remove override) * @param apiKey - New API key (or undefined to remove override)
*/ */
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void { export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
const variant = config.cliproxy.variants[variantName]; const variant = config.cliproxy.variants[variantName];
if (!variant) { if (!variant) {
throw new Error(`Variant '${variantName}' not found`); throw new Error(`Variant '${variantName}' not found`);
}
if (!variant.auth) {
variant.auth = {};
}
if (apiKey === undefined) {
delete variant.auth.api_key;
// Clean up empty auth object
if (Object.keys(variant.auth).length === 0) {
delete variant.auth;
} }
} else {
variant.auth.api_key = apiKey;
}
saveUnifiedConfig(config); if (!variant.auth) {
variant.auth = {};
}
if (apiKey === undefined) {
delete variant.auth.api_key;
if (Object.keys(variant.auth).length === 0) {
delete variant.auth;
}
} else {
variant.auth.api_key = apiKey;
}
});
} }
/** /**
@@ -161,20 +155,16 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined
* Removes cliproxy.auth and all variant auth overrides. * Removes cliproxy.auth and all variant auth overrides.
*/ */
export function resetAuthToDefaults(): void { export function resetAuthToDefaults(): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
delete config.cliproxy.auth;
// Remove global auth for (const variantName of Object.keys(config.cliproxy.variants)) {
delete config.cliproxy.auth; const variant = config.cliproxy.variants[variantName];
if (variant.auth) {
// Remove all variant auth overrides delete variant.auth;
for (const variantName of Object.keys(config.cliproxy.variants)) { }
const variant = config.cliproxy.variants[variantName];
if (variant.auth) {
delete variant.auth;
} }
} });
saveUnifiedConfig(config);
} }
/** /**
+63 -30
View File
@@ -11,7 +11,7 @@ import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator'; import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile } from '../accounts/token-file-ops'; import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import { import {
AuthStatus, AuthStatus,
PROVIDER_AUTH_PREFIXES, PROVIDER_AUTH_PREFIXES,
@@ -206,40 +206,78 @@ export function registerAccountFromToken(
verbose = false, verbose = false,
expectedAccountId?: string expectedAccountId?: string
): import('../account-manager').AccountInfo | null { ): import('../account-manager').AccountInfo | null {
type TokenCandidate = {
file: string;
filePath: string;
email?: string;
projectId?: string;
accountId: string;
mtimeMs: number;
alreadyRegistered: boolean;
};
const { registerAccount } = require('../account-manager'); const { registerAccount } = require('../account-manager');
let newestFile: string | null = null; let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
try { try {
const files = fs.readdirSync(tokenDir); const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json')); const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
const existingAccounts = getProviderAccounts(provider);
const candidates: TokenCandidate[] = jsonFiles
.map((file): TokenCandidate | null => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return null;
let newestMtime = 0; const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const accountId = extractAccountIdFromTokenFile(file, email);
const stats = fs.statSync(filePath);
return {
file,
filePath,
email,
projectId,
accountId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
};
})
.filter((candidate): candidate is TokenCandidate => candidate !== null)
.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const file of jsonFiles) { if (expectedAccountId) {
const filePath = path.join(tokenDir, file); selectedCandidate =
if (!isTokenFileForProvider(filePath, provider)) continue; candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
candidates.find((candidate) => {
const stats = fs.statSync(filePath); const existingAccount = existingAccounts.find(
if (stats.mtimeMs > newestMtime) { (account) => account.id === expectedAccountId
newestMtime = stats.mtimeMs; );
newestFile = file; return !!existingAccount && existingAccount.tokenFile === candidate.file;
} }) ||
null;
} else {
selectedCandidate = candidates[0] || null;
} }
if (!newestFile) { if (!selectedCandidate) {
if (verbose && expectedAccountId) {
console.error(
`[auth] No token matched the expected account ${expectedAccountId}; refusing ambiguous registration`
);
}
return null; return null;
} }
const tokenPath = path.join(tokenDir, newestFile); const account = registerAccount(
const content = fs.readFileSync(tokenPath, 'utf-8'); provider,
const data = JSON.parse(content); selectedCandidate.file,
const email = data.email || undefined; selectedCandidate.email,
const projectId = data.project_id || undefined; nickname,
selectedCandidate.projectId
const account = registerAccount(provider, newestFile, email, nickname, projectId); );
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose);
uploadTokenToRemoteAsync(selectedCandidate.filePath, verbose);
return account; return account;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -247,13 +285,8 @@ export function registerAccountFromToken(
console.error(`[auth] Failed to register token-backed account: ${message}`); console.error(`[auth] Failed to register token-backed account: ${message}`);
} }
if (typeof newestFile === 'string') { if (selectedCandidate && !selectedCandidate.alreadyRegistered && !expectedAccountId) {
const hasExistingRegistration = getProviderAccounts(provider).some( deleteTokenFile(selectedCandidate.file);
(account) => account.tokenFile === newestFile
);
if (!hasExistingRegistration) {
deleteTokenFile(newestFile);
}
} }
if (expectedAccountId && verbose) { if (expectedAccountId && verbose) {
+45 -44
View File
@@ -10,7 +10,7 @@ import {
} from '../../config/unified-config-types'; } from '../../config/unified-config-types';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
saveUnifiedConfig, mutateUnifiedConfig,
isUnifiedMode, isUnifiedMode,
} from '../../config/unified-config-loader'; } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
@@ -171,21 +171,20 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
} }
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
const unifiedConfig = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((unifiedConfig) => {
if (!unifiedConfig.cliproxy) {
unifiedConfig.cliproxy = {
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
if (!unifiedConfig.cliproxy.variants) {
unifiedConfig.cliproxy.variants = {};
}
if (!unifiedConfig.cliproxy) { unifiedConfig.cliproxy.variants[name] = config;
unifiedConfig.cliproxy = { });
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
if (!unifiedConfig.cliproxy.variants) {
unifiedConfig.cliproxy.variants = {};
}
unifiedConfig.cliproxy.variants[name] = config;
saveUnifiedConfig(unifiedConfig);
} }
export function saveVariantUnified( export function saveVariantUnified(
@@ -196,28 +195,26 @@ export function saveVariantUnified(
port?: number, port?: number,
target: TargetType = 'claude' target: TargetType = 'claude'
): void { ): void {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.cliproxy) {
config.cliproxy = {
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
if (!config.cliproxy.variants) {
config.cliproxy.variants = {};
}
if (!config.cliproxy) { config.cliproxy.variants[name] = {
config.cliproxy = { provider,
oauth_accounts: {}, account,
providers: [...CLIPROXY_SUPPORTED_PROVIDERS], settings: settingsPath,
variants: {}, port,
...(target !== 'claude' && { target }),
}; };
} });
if (!config.cliproxy.variants) {
config.cliproxy.variants = {};
}
config.cliproxy.variants[name] = {
provider,
account,
settings: settingsPath,
port,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
} }
export function saveVariantLegacy( export function saveVariantLegacy(
@@ -268,18 +265,22 @@ export function saveVariantLegacy(
} }
export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null {
const config = loadOrCreateUnifiedConfig(); let removedVariant: CLIProxyVariantConfig | CompositeVariantConfig | null = null;
mutateUnifiedConfig((config) => {
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
return;
}
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { removedVariant = config.cliproxy.variants[name];
delete config.cliproxy.variants[name];
});
if (!removedVariant) {
return null; return null;
} }
const variant = config.cliproxy.variants[name]; const composite = removedVariant as CompositeVariantConfig;
delete config.cliproxy.variants[name]; if (composite.type === 'composite') {
saveUnifiedConfig(config);
if ('type' in variant && variant.type === 'composite') {
const composite = variant as CompositeVariantConfig;
return { return {
provider: composite.tiers[composite.default_tier].provider, provider: composite.tiers[composite.default_tier].provider,
settings: composite.settings, settings: composite.settings,
@@ -290,7 +291,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
tiers: composite.tiers, tiers: composite.tiers,
}; };
} }
const singleVariant = variant as CLIProxyVariantConfig; const singleVariant = removedVariant as CLIProxyVariantConfig;
return { return {
provider: singleVariant.provider, provider: singleVariant.provider,
settings: singleVariant.settings, settings: singleVariant.settings,
@@ -11,17 +11,7 @@
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
import { detectRunningProxy } from '../../cliproxy/proxy-detector'; import { detectRunningProxy } from '../../cliproxy/proxy-detector';
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; import { resolveLifecyclePort } from './resolve-lifecycle-port';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
/**
* Resolve the local CLIProxy lifecycle port from unified config.
* Falls back to default port when unset/invalid.
*/
export function resolveLifecyclePort(): number {
const config = loadOrCreateUnifiedConfig();
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
}
export async function handleStart(verbose = false): Promise<void> { export async function handleStart(verbose = false): Promise<void> {
await initUI(); await initUI();
@@ -0,0 +1,11 @@
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
/**
* Resolve the local CLIProxy lifecycle port from unified config.
* Falls back to default port when unset/invalid.
*/
export function resolveLifecyclePort(): number {
const config = loadOrCreateUnifiedConfig();
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
}
+9 -14
View File
@@ -5,11 +5,7 @@
*/ */
import { InteractivePrompt } from '../../utils/prompt'; import { InteractivePrompt } from '../../utils/prompt';
import { import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
getDashboardAuthConfig,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { initUI, header, ok, info, warn, dim } from '../../utils/ui'; import { initUI, header, ok, info, warn, dim } from '../../utils/ui';
/** /**
@@ -57,15 +53,14 @@ export async function handleDisable(): Promise<void> {
} }
// Disable auth // Disable auth
const fullConfig = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((fullConfig) => {
fullConfig.dashboard_auth = { fullConfig.dashboard_auth = {
enabled: false, enabled: false,
username: fullConfig.dashboard_auth?.username ?? '', username: fullConfig.dashboard_auth?.username ?? '',
password_hash: fullConfig.dashboard_auth?.password_hash ?? '', password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24, session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
}; };
});
saveUnifiedConfig(fullConfig);
console.log(''); console.log('');
console.log(ok('Dashboard authentication disabled')); console.log(ok('Dashboard authentication disabled'));
+12 -13
View File
@@ -8,7 +8,7 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { InteractivePrompt } from '../../utils/prompt'; import { InteractivePrompt } from '../../utils/prompt';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui'; import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui';
import type { AuthSetupResult } from './types'; import type { AuthSetupResult } from './types';
@@ -99,24 +99,23 @@ export async function handleSetup(): Promise<AuthSetupResult> {
// Hash password // Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Load existing config and update const config = mutateUnifiedConfig((currentConfig) => {
const config = loadOrCreateUnifiedConfig(); currentConfig.dashboard_auth = {
config.dashboard_auth = { enabled: true,
enabled: true, username,
username, password_hash: passwordHash,
password_hash: passwordHash, session_timeout_hours: currentConfig.dashboard_auth?.session_timeout_hours ?? 24,
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, };
}; });
// Save config
saveUnifiedConfig(config);
console.log(''); console.log('');
console.log(ok('Dashboard authentication configured')); console.log(ok('Dashboard authentication configured'));
console.log(''); console.log('');
console.log(info('Settings saved to ~/.ccs/config.yaml')); console.log(info('Settings saved to ~/.ccs/config.yaml'));
console.log(info(`Username: ${username}`)); console.log(info(`Username: ${username}`));
console.log(info(`Session timeout: ${config.dashboard_auth.session_timeout_hours} hours`)); console.log(
info(`Session timeout: ${config.dashboard_auth?.session_timeout_hours ?? 24} hours`)
);
console.log(''); console.log('');
console.log(dim(' Start dashboard: ccs config')); console.log(dim(' Start dashboard: ccs config'));
console.log(dim(' Show status: ccs config auth show')); console.log(dim(' Show status: ccs config auth show'));
+12 -14
View File
@@ -15,7 +15,7 @@ import {
normalizeCopilotConfigWithWarnings, normalizeCopilotConfigWithWarnings,
} from '../copilot'; } from '../copilot';
import type { CopilotModel } from '../copilot'; import type { CopilotModel } from '../copilot';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { ok, fail, info, color, warn } from '../utils/ui'; import { ok, fail, info, color, warn } from '../utils/ui';
import { normalizeCopilotSubcommand } from '../copilot/constants'; import { normalizeCopilotSubcommand } from '../copilot/constants';
@@ -361,14 +361,13 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand. * Handle enable subcommand.
*/ */
async function handleEnable(): Promise<number> { async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
if (!config.copilot) { config.copilot.enabled = true;
config.copilot = { ...DEFAULT_COPILOT_CONFIG }; });
}
config.copilot.enabled = true;
saveUnifiedConfig(config);
console.log(ok('Copilot integration enabled')); console.log(ok('Copilot integration enabled'));
console.log(''); console.log('');
@@ -384,12 +383,11 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand. * Handle disable subcommand.
*/ */
async function handleDisable(): Promise<number> { async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (config.copilot) {
if (config.copilot) { config.copilot.enabled = false;
config.copilot.enabled = false; }
saveUnifiedConfig(config); });
}
console.log(ok('Copilot integration disabled')); console.log(ok('Copilot integration disabled'));
+12 -18
View File
@@ -15,11 +15,7 @@ import {
getAvailableModels, getAvailableModels,
getDefaultModel, getDefaultModel,
} from '../cursor'; } from '../cursor';
import { import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
getCursorConfig,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display'; import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display';
import { ok, fail, info } from '../utils/ui'; import { ok, fail, info } from '../utils/ui';
@@ -239,14 +235,13 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand. * Handle enable subcommand.
*/ */
async function handleEnable(): Promise<number> { async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
}
if (!config.cursor) { config.cursor.enabled = true;
config.cursor = { ...DEFAULT_CURSOR_CONFIG }; });
}
config.cursor.enabled = true;
saveUnifiedConfig(config);
console.log(ok('Cursor integration enabled')); console.log(ok('Cursor integration enabled'));
console.log(''); console.log('');
@@ -262,12 +257,11 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand. * Handle disable subcommand.
*/ */
async function handleDisable(): Promise<number> { async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); mutateUnifiedConfig((config) => {
if (config.cursor) {
if (config.cursor) { config.cursor.enabled = false;
config.cursor.enabled = false; }
saveUnifiedConfig(config); });
}
console.log(ok('Cursor integration disabled')); console.log(ok('Cursor integration disabled'));
return 0; return 0;
+5 -4
View File
@@ -19,7 +19,7 @@ import { initUI, header, ok, info, warn } from '../utils/ui';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
loadUnifiedConfig, loadUnifiedConfig,
saveUnifiedConfig, mutateUnifiedConfig,
hasUnifiedConfig, hasUnifiedConfig,
} from '../config/unified-config-loader'; } from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
@@ -377,9 +377,10 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
console.log(' After creating, edit the settings file to add your API key.'); console.log(' After creating, edit the settings file to add your API key.');
} }
// Save config mutateUnifiedConfig((currentConfig) => {
config.setup_completed = true; currentConfig.setup_completed = true;
saveUnifiedConfig(config); currentConfig.cliproxy_server = config.cliproxy_server;
});
// Final summary // Final summary
console.log(''); console.log('');
+41 -32
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
import { import {
@@ -64,65 +65,73 @@ function getLockFilePath(): string {
/** /**
* Acquire lockfile for config write operations. * Acquire lockfile for config write operations.
* Returns true if lock acquired, false if already locked by another process. * Returns a lock token if acquired, null if already locked by another process.
* Cleans up stale locks (older than LOCK_STALE_MS). * Cleans up stale locks (older than LOCK_STALE_MS).
*/ */
function acquireLock(): boolean { function acquireLock(): string | null {
const lockPath = getLockFilePath(); const lockPath = getLockFilePath();
const lockData = `${process.pid}\n${Date.now()}`; const lockToken = crypto.randomUUID();
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
try { try {
// Check if lock exists // Check if lock exists
if (fs.existsSync(lockPath)) { if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8'); const content = fs.readFileSync(lockPath, 'utf8');
const [pidStr, timestampStr] = content.trim().split('\n'); const [pidStr, timestampStr] = content.trim().split('\n');
const timestamp = parseInt(timestampStr, 10); const pid = Number.parseInt(pidStr, 10);
const timestamp = Number.parseInt(timestampStr, 10);
const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid);
const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS;
// Check if lock is stale if (hasLiveOwner) {
if (Date.now() - timestamp > LOCK_STALE_MS) { return null;
// Stale lock - remove and acquire }
if (isStale || !hasLiveOwner) {
fs.unlinkSync(lockPath); fs.unlinkSync(lockPath);
} else {
// Check if process still exists
try {
process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists
// Process exists - lock is valid
return false;
} catch {
// Process doesn't exist - remove stale lock
fs.unlinkSync(lockPath);
}
} }
} }
// Acquire lock // Acquire lock
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
return true; return lockToken;
} catch (error) { } catch (error) {
// EEXIST means another process acquired the lock between our check and write // EEXIST means another process acquired the lock between our check and write
if ((error as NodeJS.ErrnoException).code === 'EEXIST') { if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return false; return null;
} }
return false; return null;
} }
} }
/** /**
* Release lockfile after config write operation. * Release lockfile after config write operation.
*/ */
function releaseLock(lockToken: string): void {
function releaseLock(): void {
const lockPath = getLockFilePath(); const lockPath = getLockFilePath();
try { try {
if (fs.existsSync(lockPath)) { if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath); const content = fs.readFileSync(lockPath, 'utf8');
const fileToken = content.trim().split('\n')[2];
if (fileToken === lockToken) {
fs.unlinkSync(lockPath);
}
} }
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/** /**
* Check if unified config.yaml exists * Check if unified config.yaml exists
*/ */
@@ -152,7 +161,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
/** /**
* Load unified config from YAML file. * Load unified config from YAML file.
* Returns null if file doesn't exist or format check fails. * Returns null if file doesn't exist.
* Auto-upgrades config if version is outdated (regenerates comments). * Auto-upgrades config if version is outdated (regenerates comments).
*/ */
export function loadUnifiedConfig(): UnifiedConfig | null { export function loadUnifiedConfig(): UnifiedConfig | null {
@@ -168,8 +177,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
const parsed = yaml.load(content); const parsed = yaml.load(content);
if (!isUnifiedConfig(parsed)) { if (!isUnifiedConfig(parsed)) {
console.error(`[!] Invalid config format in ${yamlPath}`); throw new Error(`Invalid config format in ${yamlPath}`);
return null;
} }
// Auto-upgrade if version is outdated (regenerates YAML with new comments and fields) // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
@@ -208,7 +216,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
const error = err instanceof Error ? err.message : 'Unknown error'; const error = err instanceof Error ? err.message : 'Unknown error';
console.error(`[X] Failed to load config: ${error}`); console.error(`[X] Failed to load config: ${error}`);
} }
return null; throw err;
} }
} }
@@ -808,16 +816,17 @@ function withConfigWriteLock<T>(callback: () => T): T {
// Acquire lock (retry for up to 1 second) // Acquire lock (retry for up to 1 second)
const maxRetries = 10; const maxRetries = 10;
const retryDelayMs = 100; const retryDelayMs = 100;
let lockAcquired = false; let lockToken: string | null = null;
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
if (acquireLock()) { const acquiredToken = acquireLock();
lockAcquired = true; if (acquiredToken) {
lockToken = acquiredToken;
break; break;
} }
sleepSync(retryDelayMs); sleepSync(retryDelayMs);
} }
if (!lockAcquired) { if (!lockToken) {
throw new Error('Config file is locked by another process. Wait a moment and try again.'); throw new Error('Config file is locked by another process. Wait a moment and try again.');
} }
@@ -825,7 +834,7 @@ function withConfigWriteLock<T>(callback: () => T): T {
return callback(); return callback();
} finally { } finally {
// Always release lock // Always release lock
releaseLock(); releaseLock(lockToken);
} }
} }
+2 -6
View File
@@ -145,12 +145,8 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
// Use appropriate config based on unified mode // Use appropriate config based on unified mode
const { isUnifiedMode } = require('../config/unified-config-loader'); const { isUnifiedMode } = require('../config/unified-config-loader');
if (isUnifiedMode()) { if (isUnifiedMode()) {
const { const { mutateUnifiedConfig } = require('../config/unified-config-loader');
loadOrCreateUnifiedConfig, mutateUnifiedConfig(() => {});
saveUnifiedConfig,
} = require('../config/unified-config-loader');
const config = loadOrCreateUnifiedConfig();
saveUnifiedConfig(config);
return { success: true, message: 'Created/updated config.yaml' }; return { success: true, message: 'Created/updated config.yaml' };
} }
const configPath = getConfigPath(); const configPath = getConfigPath();
@@ -132,3 +132,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
// Unauthorized // Unauthorized
res.status(401).json({ error: 'Authentication required' }); res.status(401).json({ error: 'Authentication required' });
} }
export function isLoopbackRemoteAddress(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().replace(/^\[|\]$/g, '');
return (
normalized === '::1' ||
normalized === '127.0.0.1' ||
normalized.startsWith('127.') ||
normalized === '::ffff:127.0.0.1' ||
normalized.startsWith('::ffff:127.')
);
}
export function requireLocalAccessWhenAuthDisabled(
req: Request,
res: Response,
error = 'This endpoint requires localhost access when dashboard auth is disabled.'
): boolean {
if (getDashboardAuthConfig().enabled) {
return true;
}
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
return true;
}
res.status(403).json({ error });
return false;
}
+26 -13
View File
@@ -8,9 +8,22 @@ import {
type AiProviderFamilyId, type AiProviderFamilyId,
type UpsertAiProviderEntryInput, type UpsertAiProviderEntryInput,
} from '../../cliproxy/ai-providers'; } from '../../cliproxy/ai-providers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'AI provider endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
function isAiProviderFamilyId(value: string): value is AiProviderFamilyId { function isAiProviderFamilyId(value: string): value is AiProviderFamilyId {
return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId); return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId);
} }
@@ -24,13 +37,13 @@ function parseFamily(req: Request, res: Response): AiProviderFamilyId | null {
return family; return family;
} }
function parseIndex(req: Request, res: Response): number | null { function parseEntryId(req: Request, res: Response): string | null {
const index = Number.parseInt(req.params.index || '', 10); const entryId = req.params.entryId?.trim();
if (!Number.isInteger(index) || index < 0) { if (!entryId) {
res.status(400).json({ error: 'Invalid entry index' }); res.status(400).json({ error: 'Invalid entry id' });
return null; return null;
} }
return index; return entryId;
} }
function parseInput(body: unknown): UpsertAiProviderEntryInput { function parseInput(body: unknown): UpsertAiProviderEntryInput {
@@ -95,14 +108,14 @@ router.post('/:family', async (req: Request, res: Response) => {
} }
}); });
router.put('/:family/:index', async (req: Request, res: Response) => { router.put('/:family/:entryId', async (req: Request, res: Response) => {
const family = parseFamily(req, res); const family = parseFamily(req, res);
if (!family) return; if (!family) return;
const index = parseIndex(req, res); const entryId = parseEntryId(req, res);
if (index === null) return; if (!entryId) return;
try { try {
await updateAiProviderEntry(family, index, parseInput(req.body)); await updateAiProviderEntry(family, entryId, parseInput(req.body));
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
const message = (error as Error).message; const message = (error as Error).message;
@@ -110,14 +123,14 @@ router.put('/:family/:index', async (req: Request, res: Response) => {
} }
}); });
router.delete('/:family/:index', async (req: Request, res: Response) => { router.delete('/:family/:entryId', async (req: Request, res: Response) => {
const family = parseFamily(req, res); const family = parseFamily(req, res);
if (!family) return; if (!family) return;
const index = parseIndex(req, res); const entryId = parseEntryId(req, res);
if (index === null) return; if (!entryId) return;
try { try {
await deleteAiProviderEntry(family, index); await deleteAiProviderEntry(family, entryId);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
const message = (error as Error).message; const message = (error as Error).message;
+36 -17
View File
@@ -23,7 +23,6 @@ import {
resumeAccount as resumeAccountFn, resumeAccount as resumeAccountFn,
touchAccount, touchAccount,
hasAccountNameConflict, hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL, PROVIDERS_WITHOUT_EMAIL,
validateNickname, validateNickname,
} from '../../cliproxy/account-manager'; } from '../../cliproxy/account-manager';
@@ -53,6 +52,7 @@ import {
isAntigravityResponsibilityBypassEnabled, isAntigravityResponsibilityBypassEnabled,
} from '../../cliproxy/antigravity-responsibility'; } from '../../cliproxy/antigravity-responsibility';
import { createRouteErrorHelpers } from './route-helpers'; import { createRouteErrorHelpers } from './route-helpers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
@@ -66,6 +66,18 @@ const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy auth endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
function pruneExpiredManualAuthState(now = Date.now()): void { function pruneExpiredManualAuthState(now = Date.now()): void {
for (const [state, pending] of pendingManualAuthState.entries()) { for (const [state, pending] of pendingManualAuthState.entries()) {
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) { if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
@@ -149,6 +161,13 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled'; return 'Authentication failed or was cancelled';
} }
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.';
}
return 'Authenticated token could not be registered. Retry the flow.';
}
export function getStartAuthNicknameError( export function getStartAuthNicknameError(
provider: CLIProxyProvider, provider: CLIProxyProvider,
nickname: string | undefined, nickname: string | undefined,
@@ -504,12 +523,10 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
} }
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError( const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider, provider as CLIProxyProvider,
nickname, nickname,
existingAccounts, existingAccounts
existingNameMatch?.id
); );
if (nicknameError) { if (nicknameError) {
res.status(400).json(nicknameError); res.status(400).json(nicknameError);
@@ -727,12 +744,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
} }
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError( const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider, provider as CLIProxyProvider,
nickname, nickname,
existingAccounts, existingAccounts
existingNameMatch?.id
); );
if (nicknameError) { if (nicknameError) {
res.status(400).json(nicknameError); res.status(400).json(nicknameError);
@@ -780,7 +795,6 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (oauthState) { if (oauthState) {
rememberManualAuthState(oauthState, { rememberManualAuthState(oauthState, {
nickname: nickname || undefined, nickname: nickname || undefined,
expectedAccountId: existingNameMatch?.id,
}); });
} }
@@ -927,17 +941,22 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
pendingManualAuthState.delete(parsed.state); pendingManualAuthState.delete(parsed.state);
} }
if (!account) {
res.status(409).json({
error: getManualCallbackRegistrationError(provider as CLIProxyProvider),
});
return;
}
res.json({ res.json({
success: true, success: true,
account: account account: {
? { id: account.id,
id: account.id, email: account.email,
email: account.email, nickname: account.nickname,
nickname: account.nickname, provider: account.provider,
provider: account.provider, isDefault: account.isDefault,
isDefault: account.isDefault, },
}
: null,
}); });
} catch (error) { } catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
@@ -53,6 +53,7 @@ import {
getDeniedModelIdReasonForProvider, getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer'; } from '../../cliproxy/model-id-normalizer';
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service'; import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
@@ -66,6 +67,18 @@ interface QuotaRateLimitEntry {
const quotaRateLimits = new Map<string, QuotaRateLimitEntry>(); const quotaRateLimits = new Map<string, QuotaRateLimitEntry>();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy management endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
function buildQuotaRateLimitKey(req: Request, provider: string): string { function buildQuotaRateLimitKey(req: Request, provider: string): string {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
return `${clientIp}:${provider}`; return `${clientIp}:${provider}`;
+7 -13
View File
@@ -3,7 +3,7 @@
*/ */
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import { import {
generateSyncPayload, generateSyncPayload,
generateSyncPreview, generateSyncPreview,
@@ -12,7 +12,6 @@ import {
syncToLocalConfig, syncToLocalConfig,
getLocalSyncStatus, getLocalSyncStatus,
} from '../../cliproxy/sync'; } from '../../cliproxy/sync';
import { saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router(); const router = Router();
@@ -130,18 +129,13 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
return; return;
} }
// Update config
const config = loadOrCreateUnifiedConfig();
if (!config.cliproxy) {
// Should not happen as loadOrCreate initializes it, but handle gracefully
res.status(500).json({ error: 'CLIProxy config not initialized' });
return;
}
// Save config
try { try {
config.cliproxy.auto_sync = enabled; mutateUnifiedConfig((config) => {
saveUnifiedConfig(config); if (!config.cliproxy) {
throw new Error('CLIProxy config not initialized');
}
config.cliproxy.auto_sync = enabled;
});
} catch (error) { } catch (error) {
res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` }); res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` });
return; return;
+42 -44
View File
@@ -9,11 +9,7 @@ import { promises as fsp } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager'; import { getCcsDir } from '../../utils/config-manager';
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
import { import { mutateUnifiedConfig, getCursorConfig } from '../../config/unified-config-loader';
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
getCursorConfig,
} from '../../config/unified-config-loader';
import type { CursorConfig } from '../../config/unified-config-types'; import type { CursorConfig } from '../../config/unified-config-types';
const router = Router(); const router = Router();
@@ -184,36 +180,38 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
return; return;
} }
const config = loadOrCreateUnifiedConfig();
const normalizedModel = parseRequiredModel(updates.model); const normalizedModel = parseRequiredModel(updates.model);
const config = mutateUnifiedConfig((currentConfig) => {
currentConfig.cursor = {
enabled: updates.enabled ?? currentConfig.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: updates.port ?? currentConfig.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start:
updates.auto_start ??
currentConfig.cursor?.auto_start ??
DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode:
updates.ghost_mode ??
currentConfig.cursor?.ghost_mode ??
DEFAULT_CURSOR_CONFIG.ghost_mode,
model: normalizedModel ?? currentConfig.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model:
'opus_model' in updates
? parseOptionalModel(updates.opus_model)
: currentConfig.cursor?.opus_model,
sonnet_model:
'sonnet_model' in updates
? parseOptionalModel(updates.sonnet_model)
: currentConfig.cursor?.sonnet_model,
haiku_model:
'haiku_model' in updates
? parseOptionalModel(updates.haiku_model)
: currentConfig.cursor?.haiku_model,
};
});
// Merge updates with existing config const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG;
// Only known fields are merged — unknown properties are ignored await syncRawSettingsFromCursorConfig(cursorConfig);
config.cursor = { res.json({ success: true, cursor: cursorConfig });
enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start:
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode:
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: normalizedModel ?? config.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model:
'opus_model' in updates
? parseOptionalModel(updates.opus_model)
: config.cursor?.opus_model,
sonnet_model:
'sonnet_model' in updates
? parseOptionalModel(updates.sonnet_model)
: config.cursor?.sonnet_model,
haiku_model:
'haiku_model' in updates
? parseOptionalModel(updates.haiku_model)
: config.cursor?.haiku_model,
};
saveUnifiedConfig(config);
await syncRawSettingsFromCursorConfig(config.cursor);
res.json({ success: true, cursor: config.cursor });
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
@@ -295,19 +293,19 @@ router.put('/raw', (req: Request, res: Response): void => {
// Keep unified config aligned with raw settings edits (parity with Copilot raw editor). // Keep unified config aligned with raw settings edits (parity with Copilot raw editor).
const parsedPort = parseLocalCursorPort(settings); const parsedPort = parseLocalCursorPort(settings);
const config = loadOrCreateUnifiedConfig();
const env = (settings as { env?: Record<string, unknown> }).env ?? {}; const env = (settings as { env?: Record<string, unknown> }).env ?? {};
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model; mutateUnifiedConfig((config) => {
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model;
config.cursor = { config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG), ...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
...(parsedPort !== null ? { port: parsedPort } : {}), ...(parsedPort !== null ? { port: parsedPort } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL), opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL), sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL), haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
}; };
saveUnifiedConfig(config); });
const stat = fs.statSync(settingsPath); const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs }); res.json({ success: true, mtime: stat.mtimeMs });
+40 -29
View File
@@ -8,8 +8,6 @@ import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager'; import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers'; import { expandPath } from '../../utils/helpers';
import { import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
mutateUnifiedConfig, mutateUnifiedConfig,
getGlobalEnvConfig, getGlobalEnvConfig,
getThinkingConfig, getThinkingConfig,
@@ -24,9 +22,22 @@ import {
THINKING_OFF_VALUES, THINKING_OFF_VALUES,
} from '../../cliproxy'; } from '../../cliproxy';
import { validateFilePath } from './route-helpers'; import { validateFilePath } from './route-helpers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'Local configuration endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
export function resolveThinkingProviderOverridesForSave( export function resolveThinkingProviderOverridesForSave(
currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined, currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined,
updatesProviderOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> | undefined, updatesProviderOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> | undefined,
@@ -304,14 +315,10 @@ router.put('/thinking', (req: Request, res: Response): void => {
} }
} }
const config = loadOrCreateUnifiedConfig();
const shouldClearOverride = clearOverrideFlag === true || updates.override === null; const shouldClearOverride = clearOverrideFlag === true || updates.override === null;
const shouldClearProviderOverrides = const shouldClearProviderOverrides =
clearProviderOverridesFlag === true || updates.provider_overrides === null; clearProviderOverridesFlag === true || updates.provider_overrides === null;
let normalizedOverride: string | number | undefined = config.thinking?.override as let normalizedOverride: string | number | undefined;
| string
| number
| undefined;
let normalizedProviderOverrides: let normalizedProviderOverrides:
| Record<string, Partial<ThinkingConfig['tier_defaults']>> | Record<string, Partial<ThinkingConfig['tier_defaults']>>
| undefined; | undefined;
@@ -438,28 +445,32 @@ router.put('/thinking', (req: Request, res: Response): void => {
Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined; Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined;
} }
// Update thinking section const config = mutateUnifiedConfig((currentConfig) => {
config.thinking = { currentConfig.thinking = {
mode: updates.mode ?? config.thinking?.mode ?? 'auto', mode: updates.mode ?? currentConfig.thinking?.mode ?? 'auto',
override: shouldClearOverride override: shouldClearOverride
? undefined ? undefined
: updates.override !== undefined : updates.override !== undefined
? normalizedOverride ? normalizedOverride
: config.thinking?.override, : currentConfig.thinking?.override,
tier_defaults: { tier_defaults: {
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high', opus:
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium', updates.tier_defaults?.opus ?? currentConfig.thinking?.tier_defaults?.opus ?? 'high',
haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low', sonnet:
}, updates.tier_defaults?.sonnet ??
provider_overrides: resolveThinkingProviderOverridesForSave( currentConfig.thinking?.tier_defaults?.sonnet ??
config.thinking?.provider_overrides, 'medium',
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined, haiku:
shouldClearProviderOverrides updates.tier_defaults?.haiku ?? currentConfig.thinking?.tier_defaults?.haiku ?? 'low',
), },
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true, provider_overrides: resolveThinkingProviderOverridesForSave(
}; currentConfig.thinking?.provider_overrides,
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
saveUnifiedConfig(config); shouldClearProviderOverrides
),
show_warnings: updates.show_warnings ?? currentConfig.thinking?.show_warnings ?? true,
};
});
// W4: Return new mtime for subsequent requests // W4: Return new mtime for subsequent requests
let newMtime: number | undefined; let newMtime: number | undefined;
+13
View File
@@ -17,9 +17,22 @@ import {
CliproxyServerConfig, CliproxyServerConfig,
} from '../../config/unified-config-types'; } from '../../config/unified-config-types';
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy server endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
/** /**
* GET /api/cliproxy-server - Get proxy configuration * GET /api/cliproxy-server - Get proxy configuration
*/ */
+175 -132
View File
@@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getCcsDir, loadSettings } from '../../utils/config-manager'; import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
import { listVariants } from '../../cliproxy/services/variant-service'; import { listVariants } from '../../cliproxy/services/variant-service';
@@ -20,11 +21,8 @@ import {
import { regenerateConfig } from '../../cliproxy/config-generator'; import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services'; import { resolveCliproxyBridgeMetadata } from '../../api/services';
import { import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
getDashboardAuthConfig, import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
} from '../../config/unified-config-loader';
import type { Settings } from '../../types/config'; import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types'; import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
@@ -43,40 +41,25 @@ const MODEL_ENV_KEYS = [
'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const; ] as const;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
const { logRouteError, respondInternalError } = createRouteErrorHelpers('settings-routes'); const { logRouteError, respondInternalError } = createRouteErrorHelpers('settings-routes');
function isLoopbackAddress(value: string | undefined): boolean { function resolvePathWithin(basePath: string, targetPath: string): string {
if (!value) return false; const resolvedBase = path.resolve(basePath);
const normalized = value.trim().replace(/^\[|\]$/g, ''); const resolvedTarget = path.resolve(targetPath);
return ( if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) {
normalized === '::1' || throw new Error('Invalid settings path');
normalized === '127.0.0.1' || }
normalized.startsWith('127.') || return resolvedTarget;
normalized === '::ffff:127.0.0.1' ||
normalized.startsWith('::ffff:127.')
);
} }
function requireSensitiveLocalAccess(req: Request, res: Response): boolean { function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
const dashboardAuth = getDashboardAuthConfig(); return requireLocalAccessWhenAuthDisabled(
if (dashboardAuth.enabled) { req,
return true; res,
} 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.'
);
// Use only socket-level address for security decisions.
// X-Forwarded-For is trivially spoofable and must NOT be trusted
// without an explicit trust proxy configuration.
const candidateAddress = req.socket.remoteAddress;
if (isLoopbackAddress(candidateAddress)) {
return true;
}
res.status(403).json({
error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.',
});
return false;
} }
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
@@ -100,18 +83,26 @@ function classifyConfigSaveFailure(error: unknown): { statusCode: number; messag
* Variants have settings paths in config, regular profiles use {name}.settings.json * Variants have settings paths in config, regular profiles use {name}.settings.json
*/ */
function resolveSettingsPath(profileOrVariant: string): string { function resolveSettingsPath(profileOrVariant: string): string {
if (!SETTINGS_IDENTIFIER_PATTERN.test(profileOrVariant)) {
throw new Error('Invalid profile name');
}
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir);
// Check if this is a variant // Check if this is a variant
const variants = listVariants(); const variants = listVariants();
const variant = variants[profileOrVariant]; const variant = variants[profileOrVariant];
if (variant?.settings) { if (variant?.settings) {
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json) // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
return variant.settings.replace(/^~/, os.homedir()); return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir()));
} }
// Regular profile settings // Regular profile settings
return path.join(ccsDir, `${profileOrVariant}.settings.json`); return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
} }
function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null { function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null {
@@ -261,11 +252,29 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
} }
function writeSettingsAtomically(settingsPath: string, settings: Settings): void { function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
const tempPath = settingsPath + '.tmp'; const tempPath = `${settingsPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath); fs.renameSync(tempPath, settingsPath);
} }
function withSettingsFileLock<T>(settingsPath: string, callback: () => T): T {
const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath);
let release: (() => void) | undefined;
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release
}
}
}
}
function loadCanonicalProfileSettings( function loadCanonicalProfileSettings(
profileOrVariant: string, profileOrVariant: string,
settingsPath: string, settingsPath: string,
@@ -339,6 +348,8 @@ router.get('/:profile', (req: Request, res: Response): void => {
* GET /api/settings/:profile/raw - Get full settings (for editing) * GET /api/settings/:profile/raw - Get full settings (for editing)
*/ */
router.get('/:profile/raw', (req: Request, res: Response): void => { router.get('/:profile/raw', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try { try {
const { profile } = req.params; const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile); const settingsPath = resolveSettingsPath(profile);
@@ -379,6 +390,8 @@ function checkRequiredEnvVars(settings: Settings): string[] {
* PUT /api/settings/:profile - Update settings with conflict detection and backup * PUT /api/settings/:profile - Update settings with conflict detection and backup
*/ */
router.put('/:profile', (req: Request, res: Response): void => { router.put('/:profile', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try { try {
const { profile } = req.params; const { profile } = req.params;
const { settings, expectedMtime } = req.body; const { settings, expectedMtime } = req.body;
@@ -407,53 +420,56 @@ router.put('/:profile', (req: Request, res: Response): void => {
const missingFields = checkRequiredEnvVars(normalizedSettings); const missingFields = checkRequiredEnvVars(normalizedSettings);
const settingsPath = resolveSettingsPath(profile); const settingsPath = resolveSettingsPath(profile);
const fileExists = fs.existsSync(settingsPath);
// Only check conflict if file exists and expectedMtime was provided
if (fileExists && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
}
}
// Create backup only if file exists AND content actually changed
let backupPath: string | undefined; let backupPath: string | undefined;
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n'; let created = false;
if (fileExists) { let newMtime = 0;
const existingContent = fs.readFileSync(settingsPath, 'utf8');
// Only create backup if content differs withSettingsFileLock(settingsPath, () => {
if (existingContent !== newContent) { const fileExists = fs.existsSync(settingsPath);
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) { if (fileExists && expectedMtime) {
fs.mkdirSync(backupDir, { recursive: true }); const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
} }
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
} }
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
if (fileExists) {
const existingContent = fs.readFileSync(settingsPath, 'utf8');
if (existingContent !== newContent) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
}
} else {
created = true;
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
const tempPath = `${settingsPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, newContent);
fs.renameSync(tempPath, settingsPath);
newMtime = fs.statSync(settingsPath).mtime.getTime();
});
if (res.headersSent) {
return;
} }
// Ensure directory exists for new files
if (!fileExists) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
// Write new settings atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, newContent);
fs.renameSync(tempPath, settingsPath);
const newStat = fs.statSync(settingsPath);
res.json({ res.json({
profile, profile,
mtime: newStat.mtime.getTime(), mtime: newMtime,
backupPath, backupPath,
created: !fileExists, created,
// Include warning if fields missing (runtime will use defaults) // Include warning if fields missing (runtime will use defaults)
...(missingFields.length > 0 && { ...(missingFields.length > 0 && {
warning: `Missing fields will use defaults: ${missingFields.join(', ')}`, warning: `Missing fields will use defaults: ${missingFields.join(', ')}`,
@@ -491,6 +507,8 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
* POST /api/settings/:profile/presets - Create a new preset * POST /api/settings/:profile/presets - Create a new preset
*/ */
router.post('/:profile/presets', (req: Request, res: Response): void => { router.post('/:profile/presets', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try { try {
const { profile } = req.params; const { profile } = req.params;
const { name, default: defaultModel, opus, sonnet, haiku } = req.body; const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
@@ -502,56 +520,65 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
const settingsPath = resolveSettingsPath(profile); const settingsPath = resolveSettingsPath(profile);
// Create settings file if it doesn't exist let persistedPreset:
if (!fs.existsSync(settingsPath)) { | {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); name: string;
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); default: string;
} opus: string;
sonnet: string;
haiku: string;
}
| undefined;
const settings = loadCanonicalProfileSettings(profile, settingsPath, false); withSettingsFileLock(settingsPath, () => {
settings.presets = settings.presets || []; if (!fs.existsSync(settingsPath)) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
// Check for duplicate name const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
if (settings.presets.some((p) => p.name === name)) { settings.presets = settings.presets || [];
res.status(409).json({ error: 'Preset with this name already exists' });
if (settings.presets.some((p) => p.name === name)) {
res.status(409).json({ error: 'Preset with this name already exists' });
return;
}
const normalizePresetModel = (modelId: string): string =>
canonicalizeProfileModelId(profile, modelId, settings);
for (const modelId of [
defaultModel,
opus || defaultModel,
sonnet || defaultModel,
haiku || defaultModel,
]) {
const deniedReason = findDeniedProfileModel(profile, modelId, settings);
if (deniedReason) {
res.status(400).json({ error: deniedReason });
return;
}
}
const preset = {
name,
default: normalizePresetModel(defaultModel),
opus: normalizePresetModel(opus || defaultModel),
sonnet: normalizePresetModel(sonnet || defaultModel),
haiku: normalizePresetModel(haiku || defaultModel),
};
settings.presets.push(preset);
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
writeSettingsAtomically(settingsPath, canonicalizedSettings);
persistedPreset =
canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset;
});
if (res.headersSent) {
return; return;
} }
const normalizePresetModel = (modelId: string): string =>
canonicalizeProfileModelId(profile, modelId, settings);
for (const modelId of [
defaultModel,
opus || defaultModel,
sonnet || defaultModel,
haiku || defaultModel,
]) {
const deniedReason = findDeniedProfileModel(profile, modelId, settings);
if (deniedReason) {
res.status(400).json({ error: deniedReason });
return;
}
}
const normalizedDefaultModel = normalizePresetModel(defaultModel);
const normalizedOpusModel = normalizePresetModel(opus || defaultModel);
const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel);
const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel);
const preset = {
name,
default: normalizedDefaultModel,
opus: normalizedOpusModel,
sonnet: normalizedSonnetModel,
haiku: normalizedHaikuModel,
};
settings.presets.push(preset);
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
writeSettingsAtomically(settingsPath, canonicalizedSettings);
const persistedPreset =
canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset;
res.status(201).json({ preset: persistedPreset }); res.status(201).json({ preset: persistedPreset });
} catch (error) { } catch (error) {
respondInternalError(res, error, 'Internal server error.'); respondInternalError(res, error, 'Internal server error.');
@@ -562,25 +589,33 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
* DELETE /api/settings/:profile/presets/:name - Delete a preset * DELETE /api/settings/:profile/presets/:name - Delete a preset
*/ */
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try { try {
const { profile, name } = req.params; const { profile, name } = req.params;
const settingsPath = resolveSettingsPath(profile); const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) { withSettingsFileLock(settingsPath, () => {
res.status(404).json({ error: 'Settings not found' }); if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
settings.presets = settings.presets.filter((p) => p.name !== name);
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
writeSettingsAtomically(settingsPath, canonicalizedSettings);
});
if (res.headersSent) {
return; return;
} }
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
settings.presets = settings.presets.filter((p) => p.name !== name);
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
writeSettingsAtomically(settingsPath, canonicalizedSettings);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
respondInternalError(res, error, 'Internal server error.'); respondInternalError(res, error, 'Internal server error.');
@@ -642,6 +677,8 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
* GET /api/settings/auth/tokens - Get current auth token status (masked) * GET /api/settings/auth/tokens - Get current auth token status (masked)
*/ */
router.get('/auth/tokens', (_req: Request, res: Response): void => { router.get('/auth/tokens', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try { try {
const summary = getAuthSummary(); const summary = getAuthSummary();
@@ -692,6 +729,8 @@ router.get('/auth/tokens/raw', (req: Request, res: Response): void => {
* PUT /api/settings/auth/tokens - Update auth tokens * PUT /api/settings/auth/tokens - Update auth tokens
*/ */
router.put('/auth/tokens', (req: Request, res: Response): void => { router.put('/auth/tokens', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try { try {
const { apiKey, managementSecret } = req.body; const { apiKey, managementSecret } = req.body;
@@ -728,6 +767,8 @@ router.put('/auth/tokens', (req: Request, res: Response): void => {
* POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret * POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret
*/ */
router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => { router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try { try {
const newSecret = generateSecureToken(32); const newSecret = generateSecureToken(32);
setGlobalManagementSecret(newSecret); setGlobalManagementSecret(newSecret);
@@ -752,6 +793,8 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo
* POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults * POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults
*/ */
router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try { try {
resetAuthToDefaults(); resetAuthToDefaults();
+40 -58
View File
@@ -3,11 +3,7 @@
*/ */
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader';
loadUnifiedConfig,
saveUnifiedConfig,
getWebSearchConfig,
} from '../../config/unified-config-loader';
import type { WebSearchConfig } from '../../config/unified-config-types'; import type { WebSearchConfig } from '../../config/unified-config-types';
import { import {
getWebSearchReadiness, getWebSearchReadiness,
@@ -52,59 +48,45 @@ router.put('/', (req: Request, res: Response): void => {
} }
try { try {
// Load existing config and update websearch section const existingConfig = mutateUnifiedConfig((config) => {
const existingConfig = loadUnifiedConfig(); config.websearch = {
if (!existingConfig) { enabled: enabled ?? config.websearch?.enabled ?? true,
res.status(500).json({ error: 'Failed to load config' }); providers: providers
return; ? {
} gemini: {
enabled:
// Merge updates - supports Gemini CLI and Grok CLI providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true,
existingConfig.websearch = { model:
enabled: enabled ?? existingConfig.websearch?.enabled ?? true, providers.gemini?.model ??
providers: providers config.websearch?.providers?.gemini?.model ??
? { 'gemini-2.5-flash',
gemini: { timeout:
enabled: providers.gemini?.timeout ?? config.websearch?.providers?.gemini?.timeout ?? 55,
providers.gemini?.enabled ?? },
existingConfig.websearch?.providers?.gemini?.enabled ?? grok: {
true, enabled:
model: providers.grok?.enabled ?? config.websearch?.providers?.grok?.enabled ?? false,
providers.gemini?.model ?? timeout:
existingConfig.websearch?.providers?.gemini?.model ?? providers.grok?.timeout ?? config.websearch?.providers?.grok?.timeout ?? 55,
'gemini-2.5-flash', },
timeout: opencode: {
providers.gemini?.timeout ?? enabled:
existingConfig.websearch?.providers?.gemini?.timeout ?? providers.opencode?.enabled ??
55, config.websearch?.providers?.opencode?.enabled ??
}, false,
grok: { model:
enabled: providers.opencode?.model ??
providers.grok?.enabled ?? config.websearch?.providers?.opencode?.model ??
existingConfig.websearch?.providers?.grok?.enabled ?? 'opencode/grok-code',
false, timeout:
timeout: providers.opencode?.timeout ??
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55, config.websearch?.providers?.opencode?.timeout ??
}, 60,
opencode: { },
enabled: }
providers.opencode?.enabled ?? : config.websearch?.providers,
existingConfig.websearch?.providers?.opencode?.enabled ?? };
false, });
model:
providers.opencode?.model ??
existingConfig.websearch?.providers?.opencode?.model ??
'opencode/grok-code',
timeout:
providers.opencode?.timeout ??
existingConfig.websearch?.providers?.opencode?.timeout ??
60,
},
}
: existingConfig.websearch?.providers,
};
saveUnifiedConfig(existingConfig);
res.json({ res.json({
success: true, success: true,
+13
View File
@@ -10,9 +10,22 @@ import * as path from 'path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path'; import { getClaudeConfigDir } from '../utils/claude-config-path';
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
export const sharedRoutes = Router(); export const sharedRoutes = Router();
sharedRoutes.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'Shared-content endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
const MAX_DESCRIPTION_LENGTH = 140; const MAX_DESCRIPTION_LENGTH = 140;
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-'));
try {
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
}
async function loadRegistryModule() {
return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`);
}
async function loadAccountManager() {
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
}
describe('account registry integrity', () => {
it('does not create accounts.json during no-op discovery', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
const { discoverExistingAccounts } = await loadRegistryModule();
discoverExistingAccounts();
expect(fs.existsSync(registryPath)).toBe(false);
});
});
it('does not write accounts.json during provider account reads', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
const { getProviderAccounts } = await loadAccountManager();
expect(getProviderAccounts('kiro')).toEqual([]);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
it('removes stale accounts before choosing the next default during registration', async () => {
await withIsolatedHome(async (homeDir) => {
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
const authDir = path.join(cliproxyDir, 'auth');
const registryPath = path.join(cliproxyDir, 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, 'kiro-github-ABC123.json'), JSON.stringify({ type: 'kiro' }));
fs.writeFileSync(
registryPath,
JSON.stringify({
version: 1,
providers: {
kiro: {
default: 'github-OLD999',
accounts: {
'github-OLD999': {
nickname: 'old',
tokenFile: 'kiro-github-OLD999.json',
createdAt: '2025-01-01T00:00:00.000Z',
lastUsedAt: '2025-01-01T00:00:00.000Z',
},
},
},
},
}),
'utf8'
);
const { registerAccount } = await loadAccountManager();
const account = registerAccount('kiro', 'kiro-github-ABC123.json');
const { loadAccountsRegistry } = await loadRegistryModule();
const registry = loadAccountsRegistry();
const kiroAccounts = registry.providers.kiro;
expect(account.id).toBe('github-ABC123');
expect(kiroAccounts?.default).toBe('github-ABC123');
expect(kiroAccounts?.accounts['github-OLD999']).toBeUndefined();
expect(Object.keys(kiroAccounts?.accounts ?? {})).toEqual(['github-ABC123']);
});
});
it('fails closed on corrupted accounts.json', async () => {
await withIsolatedHome(async (homeDir) => {
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '{not-valid-json', 'utf8');
const { loadAccountsRegistry } = await loadRegistryModule();
expect(() => loadAccountsRegistry()).toThrow(/corrupted/i);
});
});
});
@@ -8,10 +8,16 @@ async function loadAccountManager() {
return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`); return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`);
} }
async function withIsolatedHome<T>(fn: () => Promise<T> | T): Promise<T> { function writeTokenFile(homeDir: string, tokenFile: string): void {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), '{}', 'utf8');
}
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-')); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-'));
try { try {
return await runWithScopedCcsHome(testDir, fn); return await runWithScopedCcsHome(testDir, () => fn(testDir));
} finally { } finally {
fs.rmSync(testDir, { recursive: true, force: true }); fs.rmSync(testDir, { recursive: true, force: true });
} }
@@ -19,7 +25,8 @@ async function withIsolatedHome<T>(fn: () => Promise<T> | T): Promise<T> {
describe('registerAccount optional nickname flow', () => { describe('registerAccount optional nickname flow', () => {
it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => { it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => {
const account = await withIsolatedHome(async () => { const account = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
const { registerAccount } = await loadAccountManager(); const { registerAccount } = await loadAccountManager();
return registerAccount('kiro', 'kiro-github-ABC123.json'); return registerAccount('kiro', 'kiro-github-ABC123.json');
}); });
@@ -29,7 +36,9 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => { it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => {
const { first, second } = await withIsolatedHome(async () => { const { first, second } = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-nomail.json');
writeTokenFile(homeDir, 'kiro-second.json');
const { registerAccount } = await loadAccountManager(); const { registerAccount } = await loadAccountManager();
return { return {
first: registerAccount('kiro', 'kiro-nomail.json'), first: registerAccount('kiro', 'kiro-nomail.json'),
@@ -44,7 +53,8 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('keeps user nicknames optional metadata separate from internal ids', async () => { it('keeps user nicknames optional metadata separate from internal ids', async () => {
const account = await withIsolatedHome(async () => { const account = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'ghcp-amazon-XYZ789.json');
const { registerAccount } = await loadAccountManager(); const { registerAccount } = await loadAccountManager();
return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work'); return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work');
}); });
@@ -54,7 +64,8 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('preserves an existing custom nickname when the same token file is re-registered', async () => { it('preserves an existing custom nickname when the same token file is re-registered', async () => {
const reauthenticated = await withIsolatedHome(async () => { const reauthenticated = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
const { registerAccount } = await loadAccountManager(); const { registerAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work'); registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work');
return registerAccount('kiro', 'kiro-github-ABC123.json'); return registerAccount('kiro', 'kiro-github-ABC123.json');
@@ -65,7 +76,10 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('rejects nickname collisions against existing account ids and nicknames', async () => { it('rejects nickname collisions against existing account ids and nicknames', async () => {
await withIsolatedHome(async () => { await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
writeTokenFile(homeDir, 'kiro-google-NEW123.json');
const { registerAccount, renameAccount } = await loadAccountManager(); const { registerAccount, renameAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json'); registerAccount('kiro', 'kiro-github-ABC123.json');
const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal'); const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal');
@@ -78,7 +92,9 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('avoids auto-generated ids that would collide with an existing nickname', async () => { it('avoids auto-generated ids that would collide with an existing nickname', async () => {
const added = await withIsolatedHome(async () => { const added = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
const { registerAccount } = await loadAccountManager(); const { registerAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789'); registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789');
return registerAccount('kiro', 'kiro-google-XYZ789.json'); return registerAccount('kiro', 'kiro-google-XYZ789.json');
@@ -89,7 +105,9 @@ describe('registerAccount optional nickname flow', () => {
}); });
it('does not resolve ambiguous nickname prefixes to the first generated account', async () => { it('does not resolve ambiguous nickname prefixes to the first generated account', async () => {
const match = await withIsolatedHome(async () => { const match = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
writeTokenFile(homeDir, 'kiro-github-DEF456.json');
const { registerAccount, findAccountByQuery } = await loadAccountManager(); const { registerAccount, findAccountByQuery } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json'); registerAccount('kiro', 'kiro-github-ABC123.json');
registerAccount('kiro', 'kiro-github-DEF456.json'); registerAccount('kiro', 'kiro-github-DEF456.json');
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as yaml from 'js-yaml';
function getCliproxyConfigPath(homeDir: string): string {
return path.join(homeDir, '.ccs', 'cliproxy', 'config.yaml');
}
function writeCliproxyConfig(homeDir: string, value: Record<string, unknown>): void {
const configPath = getCliproxyConfigPath(homeDir);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, yaml.dump(value), 'utf8');
}
function readCliproxyConfig(homeDir: string): Record<string, any> {
return (
(yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record<string, any>) || {}
);
}
async function loadAiProviderService() {
return import(`../../../src/cliproxy/ai-providers/service?stable-id=${Date.now()}`);
}
describe('ai-provider service stable ids', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-ids-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('backfills and persists stable ids for api-key provider entries', async () => {
const { listAiProviders } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'gemini-api-key': [{ 'api-key': 'alpha' }, { 'api-key': 'beta' }],
});
const listed = await listAiProviders();
const family = listed.families.find((entry) => entry.id === 'gemini-api-key');
expect(family).toBeDefined();
expect(family?.entries).toHaveLength(2);
expect(family?.entries[0]?.id).toBeTruthy();
expect(family?.entries[1]?.id).toBeTruthy();
expect(family?.entries[0]?.id).not.toBe(family?.entries[1]?.id);
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
expect(persisted[0]?.id).toBe(family?.entries[0]?.id);
expect(persisted[1]?.id).toBe(family?.entries[1]?.id);
});
it('updates and deletes api-key entries by stable id while preserving the id', async () => {
const { updateAiProviderEntry, deleteAiProviderEntry } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'gemini-api-key': [
{ 'api-key': 'alpha', id: 'gemini-a' },
{ 'api-key': 'beta', id: 'gemini-b' },
],
});
await updateAiProviderEntry('gemini-api-key', 'gemini-a', {
apiKey: 'gamma',
baseUrl: 'https://example.test/gemini',
});
let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
expect(persisted[0]?.id).toBe('gemini-a');
expect(persisted[0]?.['api-key']).toBe('gamma');
expect(persisted[0]?.['base-url']).toBe('https://example.test/gemini');
await deleteAiProviderEntry('gemini-api-key', 'gemini-a');
persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
expect(persisted).toHaveLength(1);
expect(persisted[0]?.id).toBe('gemini-b');
});
it('keeps legacy numeric index updates working during the route transition', async () => {
const { updateAiProviderEntry } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'gemini-api-key': [{ 'api-key': 'alpha', id: 'gemini-a' }],
});
await updateAiProviderEntry('gemini-api-key', '0', {
apiKey: 'legacy-index-update',
});
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
expect(persisted[0]?.id).toBe('gemini-a');
expect(persisted[0]?.['api-key']).toBe('legacy-index-update');
});
it('backfills and preserves stable ids for openai-compatible connectors', async () => {
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'openai-compatibility': [
{
name: 'openrouter',
'base-url': 'https://openrouter.ai/api/v1',
'api-key-entries': [{ 'api-key': 'sk-openrouter' }],
},
],
});
const listed = await listAiProviders();
const family = listed.families.find((entry) => entry.id === 'openai-compatibility');
const connectorId = family?.entries[0]?.id;
expect(connectorId).toBeTruthy();
await updateAiProviderEntry('openai-compatibility', connectorId!, {
name: 'openrouter',
baseUrl: 'https://router.example/v1',
preserveSecrets: true,
});
const persisted = readCliproxyConfig(tempHome)['openai-compatibility'] as Array<
Record<string, unknown>
>;
expect(persisted[0]?.id).toBe(connectorId);
expect(persisted[0]?.['base-url']).toBe('https://router.example/v1');
expect((persisted[0]?.['api-key-entries'] as Array<Record<string, unknown>>)[0]?.['api-key']).toBe(
'sk-openrouter'
);
});
});
@@ -1,64 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { afterEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand';
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
let tempDir: string; type MockUnifiedConfig = {
cliproxy_server?: {
local?: {
port?: number;
};
};
};
function writeUnifiedConfig(localPort: number): void { function mockUnifiedConfig(config: MockUnifiedConfig): void {
const configPath = path.join(tempDir, 'config.yaml'); mock.module('../../../src/config/unified-config-loader', () => ({
const yaml = `version: 2 loadOrCreateUnifiedConfig: () => config,
accounts: {} }));
profiles: {} }
preferences:
theme: system async function loadResolveLifecyclePort() {
telemetry: false const mod = await import(
auto_update: true `../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}`
cliproxy: );
oauth_accounts: {} return mod.resolveLifecyclePort;
providers:
- gemini
- codex
- agy
variants: {}
cliproxy_server:
local:
port: ${localPort}
`;
fs.writeFileSync(configPath, yaml, 'utf8');
} }
describe('resolveLifecyclePort', () => { describe('resolveLifecyclePort', () => {
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-'));
});
afterEach(() => { afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) { mock.restore();
fs.rmSync(tempDir, { recursive: true, force: true });
}
}); });
it('uses configured cliproxy_server.local.port', async () => { it('uses configured cliproxy_server.local.port', async () => {
writeUnifiedConfig(9456); mockUnifiedConfig({
await runWithScopedConfigDir(tempDir, () => { cliproxy_server: {
expect(resolveLifecyclePort()).toBe(9456); local: {
port: 9456,
},
},
}); });
const resolveLifecyclePort = await loadResolveLifecyclePort();
expect(resolveLifecyclePort()).toBe(9456);
}); });
it('falls back to default port when configured local port is invalid', async () => { it('falls back to default port when configured local port is invalid', async () => {
writeUnifiedConfig(70000); mockUnifiedConfig({
await runWithScopedConfigDir(tempDir, () => { cliproxy_server: {
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); local: {
port: 70000,
},
},
}); });
const resolveLifecyclePort = await loadResolveLifecyclePort();
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
}); });
it('falls back to default port when config file is missing', async () => { it('falls back to default port when config file is missing', async () => {
await runWithScopedConfigDir(tempDir, () => { mockUnifiedConfig({});
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
}); const resolveLifecyclePort = await loadResolveLifecyclePort();
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
}); });
}); });
@@ -0,0 +1,173 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import express from 'express';
import type { Server } from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const listCalls: string[] = [];
const updateCalls: Array<{ family: string; entryId: string; data: Record<string, unknown> }> = [];
mock.module('../../../src/cliproxy/ai-providers', () => ({
AI_PROVIDER_FAMILY_DEFINITIONS: {
'gemini-api-key': {
id: 'gemini-api-key',
displayName: 'Gemini',
description: 'Mock Gemini family',
authMode: 'hybrid',
supportsNamedEntries: false,
routePath: '/api/provider/gemini',
},
'openai-compatibility': {
id: 'openai-compatibility',
displayName: 'OpenAI-Compatible',
description: 'Mock connector family',
authMode: 'connector',
supportsNamedEntries: true,
routePath: '/api/provider/openai-compat',
},
},
AI_PROVIDER_FAMILY_IDS: ['gemini-api-key', 'openai-compatibility'],
listAiProviders: async () => {
listCalls.push('list');
return {
source: {
mode: 'local',
label: 'Local CLIProxy',
target: 'http://127.0.0.1:8317',
managementAuth: 'configured',
},
families: [],
};
},
createAiProviderEntry: async () => {},
updateAiProviderEntry: async (family: string, entryId: string, data: Record<string, unknown>) => {
updateCalls.push({ family, entryId, data });
},
deleteAiProviderEntry: async () => {},
}));
describe('ai-provider-routes', () => {
let router: typeof import('../../../src/web-server/routes/ai-provider-routes').default;
let server: Server;
let baseUrl = '';
let forcedRemoteAddress = '127.0.0.1';
let tempHome = '';
let originalDashboardAuthEnabled: string | undefined;
let originalCcsHome: string | undefined;
beforeAll(async () => {
({ default: router } = await import(
`../../../src/web-server/routes/ai-provider-routes?ai-provider-routes=${Date.now()}`
));
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api/cliproxy/ai-providers', router);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
originalCcsHome = process.env.CCS_HOME;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-routes-'));
process.env.CCS_HOME = tempHome;
forcedRemoteAddress = '127.0.0.1';
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
listCalls.length = 0;
updateCalls.length = 0;
});
afterEach(() => {
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
}
});
it('blocks remote access when dashboard auth is disabled', async () => {
forcedRemoteAddress = '10.10.0.24';
const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'AI provider endpoints require localhost access when dashboard auth is disabled.',
});
expect(listCalls).toHaveLength(0);
});
it('allows non-local access when dashboard auth is enabled', async () => {
forcedRemoteAddress = '10.10.0.24';
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`);
expect(response.status).toBe(200);
expect(listCalls).toHaveLength(1);
});
it('passes stable entry ids through update routes', async () => {
const response = await fetch(
`${baseUrl}/api/cliproxy/ai-providers/gemini-api-key/entry-alpha-123`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: 'sk-test' }),
}
);
expect(response.status).toBe(200);
expect(updateCalls).toEqual([
{
family: 'gemini-api-key',
entryId: 'entry-alpha-123',
data: {
apiKey: 'sk-test',
apiKeys: undefined,
baseUrl: undefined,
excludedModels: undefined,
headers: undefined,
models: undefined,
name: undefined,
prefix: undefined,
preserveSecrets: false,
proxyUrl: undefined,
},
},
]);
});
});
@@ -112,9 +112,9 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
]); ]);
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', { const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
nickname: 'work', nickname: 'work',
kiroMethod: 'google', kiroMethod: 'google',
}); });
expect(startResponse.status).toBe(200); expect(startResponse.status).toBe(200);
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
@@ -126,8 +126,8 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
); );
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', { const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-123', redirectUrl: 'http://localhost/callback?code=abc123&state=state-123',
}); });
expect(callbackResponse.status).toBe(200); expect(callbackResponse.status).toBe(200);
@@ -142,4 +142,37 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
}); });
it('returns 409 when callback completes upstream but no account can be registered locally', async () => {
mockFetch([
{
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-409',
state: 'state-409',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
method: 'POST',
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
nickname: 'work',
kiroMethod: 'google',
});
expect(startResponse.status).toBe(200);
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-409',
});
expect(callbackResponse.status).toBe(409);
expect(callbackResponse.body).toEqual({
error:
'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.',
});
});
}); });
+5 -5
View File
@@ -42,13 +42,13 @@ export function useUpdateCliproxyAiProviderEntry() {
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
family, family,
index, entryId,
data, data,
}: { }: {
family: AiProviderFamilyId; family: AiProviderFamilyId;
index: number; entryId: string;
data: UpsertAiProviderEntryInput; data: UpsertAiProviderEntryInput;
}) => api.cliproxy.aiProviders.update(family, index, data), }) => api.cliproxy.aiProviders.update(family, entryId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY }); queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success('Provider entry updated'); toast.success('Provider entry updated');
@@ -63,8 +63,8 @@ export function useDeleteCliproxyAiProviderEntry() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ family, index }: { family: AiProviderFamilyId; index: number }) => mutationFn: ({ family, entryId }: { family: AiProviderFamilyId; entryId: string }) =>
api.cliproxy.aiProviders.delete(family, index), api.cliproxy.aiProviders.delete(family, entryId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY }); queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success('Provider entry removed'); toast.success('Provider entry removed');
+94 -16
View File
@@ -41,6 +41,8 @@ interface StartAuthOptions {
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
/** Maximum polling duration (5 minutes) */ /** Maximum polling duration (5 minutes) */
const MAX_POLL_DURATION = 5 * 60 * 1000; const MAX_POLL_DURATION = 5 * 60 * 1000;
/** Fail visibly after repeated poll transport errors instead of retrying forever */
const MAX_POLL_FAILURES = 3;
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> { async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
const text = await response.text(); const text = await response.text();
@@ -69,18 +71,26 @@ const INITIAL_STATE: AuthFlowState = {
export function useCliproxyAuthFlow() { export function useCliproxyAuthFlow() {
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE); const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
const attemptIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollStartRef = useRef<number>(0); const pollStartRef = useRef<number>(0);
const pollFailureCountRef = useRef(0);
const openedAuthUrlRef = useRef(false); const openedAuthUrlRef = useRef(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isActiveAttempt = useCallback(
(attemptId: number) => attemptId === attemptIdRef.current,
[]
);
// Clear polling // Clear polling
const stopPolling = useCallback(() => { const stopPolling = useCallback(() => {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null; pollIntervalRef.current = null;
} }
pollFailureCountRef.current = 0;
}, []); }, []);
// Cleanup on unmount // Cleanup on unmount
@@ -94,15 +104,21 @@ export function useCliproxyAuthFlow() {
// Poll OAuth status // Poll OAuth status
const pollStatus = useCallback( const pollStatus = useCallback(
async (provider: string, oauthState: string) => { async (provider: string, oauthState: string, attemptId: number) => {
if (!isActiveAttempt(attemptId)) {
return;
}
// Check timeout // Check timeout
if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) { if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) {
stopPolling(); stopPolling();
setState((prev) => ({ if (isActiveAttempt(attemptId)) {
...prev, setState((prev) => ({
isAuthenticating: false, ...prev,
error: 'Authentication timed out. Please try again.', isAuthenticating: false,
})); error: 'Authentication timed out. Please try again.',
}));
}
return; return;
} }
@@ -110,6 +126,10 @@ export function useCliproxyAuthFlow() {
const response = await fetch( const response = await fetch(
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}` `/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
); );
if (!isActiveAttempt(attemptId)) {
return;
}
const data = (await response.json()) as { const data = (await response.json()) as {
status?: string; status?: string;
error?: string; error?: string;
@@ -118,6 +138,7 @@ export function useCliproxyAuthFlow() {
verification_url?: string; verification_url?: string;
user_code?: string; user_code?: string;
}; };
pollFailureCountRef.current = 0;
if (data.status === 'ok') { if (data.status === 'ok') {
stopPolling(); stopPolling();
@@ -161,11 +182,30 @@ export function useCliproxyAuthFlow() {
})); }));
} }
// status === 'wait' (or pending) means continue polling // status === 'wait' (or pending) means continue polling
} catch { } catch (error) {
// Network error - continue polling if (!isActiveAttempt(attemptId)) {
return;
}
pollFailureCountRef.current += 1;
if (pollFailureCountRef.current < MAX_POLL_FAILURES) {
return;
}
stopPolling();
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: 'Lost contact with the auth status endpoint';
toast.error(message);
setState((prev) => ({
...prev,
isAuthenticating: false,
error: message,
}));
} }
}, },
[queryClient, stopPolling] [isActiveAttempt, queryClient, stopPolling]
); );
const startAuth = useCallback( const startAuth = useCallback(
@@ -182,9 +222,12 @@ export function useCliproxyAuthFlow() {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
stopPolling(); stopPolling();
openedAuthUrlRef.current = false; openedAuthUrlRef.current = false;
pollFailureCountRef.current = 0;
// Create fresh controller and capture locally to avoid race with cancelAuth // Create fresh controller and capture locally to avoid race with cancelAuth
const controller = new AbortController(); const controller = new AbortController();
const attemptId = attemptIdRef.current + 1;
attemptIdRef.current = attemptId;
abortControllerRef.current = controller; abortControllerRef.current = controller;
const flowType = const flowType =
@@ -219,7 +262,13 @@ export function useCliproxyAuthFlow() {
signal: controller.signal, signal: controller.signal,
}) })
.then(async (response) => { .then(async (response) => {
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response); const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true; const success = data.success === true;
if (response.ok && success) { if (response.ok && success) {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
@@ -240,6 +289,9 @@ export function useCliproxyAuthFlow() {
} }
}) })
.catch((error) => { .catch((error) => {
if (!isActiveAttempt(attemptId)) {
return;
}
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
// Cancelled - state already reset by cancelAuth // Cancelled - state already reset by cancelAuth
return; return;
@@ -261,8 +313,14 @@ export function useCliproxyAuthFlow() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: controller.signal, signal: controller.signal,
}); });
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response); const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true; const success = data.success === true;
if (!response.ok || !success) { if (!response.ok || !success) {
@@ -290,11 +348,14 @@ export function useCliproxyAuthFlow() {
if (oauthState) { if (oauthState) {
pollStartRef.current = Date.now(); pollStartRef.current = Date.now();
pollIntervalRef.current = setInterval(() => { pollIntervalRef.current = setInterval(() => {
pollStatus(provider, oauthState); void pollStatus(provider, oauthState, attemptId);
}, POLL_INTERVAL); }, POLL_INTERVAL);
} }
} }
} catch (error) { } catch (error) {
if (!isActiveAttempt(attemptId)) {
return;
}
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
openedAuthUrlRef.current = false; openedAuthUrlRef.current = false;
setState(INITIAL_STATE); setState(INITIAL_STATE);
@@ -309,11 +370,12 @@ export function useCliproxyAuthFlow() {
})); }));
} }
}, },
[pollStatus, stopPolling, queryClient] [isActiveAttempt, pollStatus, stopPolling, queryClient]
); );
const cancelAuth = useCallback(() => { const cancelAuth = useCallback(() => {
const currentProvider = state.provider; const currentProvider = state.provider;
attemptIdRef.current += 1;
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
stopPolling(); stopPolling();
openedAuthUrlRef.current = false; openedAuthUrlRef.current = false;
@@ -329,37 +391,53 @@ export function useCliproxyAuthFlow() {
const submitCallback = useCallback( const submitCallback = useCallback(
async (redirectUrl: string) => { async (redirectUrl: string) => {
if (!state.provider) return; if (!state.provider) return;
const attemptId = attemptIdRef.current;
const currentProvider = state.provider;
setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null })); setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null }));
try { try {
const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, { const response = await fetch(`/api/cliproxy/auth/${currentProvider}/submit-callback`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectUrl }), body: JSON.stringify({ redirectUrl }),
}); });
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response); const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true; const success = data.success === true;
const hasAccount = typeof data.account === 'object' && data.account !== null;
if (response.ok && success) { if (response.ok && success && hasAccount) {
stopPolling(); stopPolling();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] });
toast.success(`${state.provider} authentication successful`); toast.success(`${currentProvider} authentication successful`);
setState(INITIAL_STATE); setState(INITIAL_STATE);
} else { } else {
const errorMsg = const errorMsg =
typeof data.error === 'string' ? data.error : 'Callback submission failed'; typeof data.error === 'string'
? data.error
: success
? 'Authenticated account could not be registered'
: 'Callback submission failed';
throw new Error(errorMsg); throw new Error(errorMsg);
} }
} catch (error) { } catch (error) {
if (!isActiveAttempt(attemptId)) {
return;
}
const message = error instanceof Error ? error.message : 'Failed to submit callback'; const message = error instanceof Error ? error.message : 'Failed to submit callback';
toast.error(message); toast.error(message);
setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message })); setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message }));
} }
}, },
[state.provider, queryClient, stopPolling] [isActiveAttempt, state.provider, queryClient, stopPolling]
); );
return useMemo( return useMemo(
+15 -9
View File
@@ -854,15 +854,21 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
update: (family: AiProviderFamilyId, index: number, data: UpsertAiProviderEntryInput) => update: (family: AiProviderFamilyId, entryId: string, data: UpsertAiProviderEntryInput) =>
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, { request(
method: 'PUT', `/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
body: JSON.stringify(data), {
}), method: 'PUT',
delete: (family: AiProviderFamilyId, index: number) => body: JSON.stringify(data),
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, { }
method: 'DELETE', ),
}), delete: (family: AiProviderFamilyId, entryId: string) =>
request(
`/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
{
method: 'DELETE',
}
),
}, },
// Config YAML for Config tab // Config YAML for Config tab
+3 -3
View File
@@ -1759,7 +1759,7 @@ export function CliproxyAiProvidersPage() {
onSave={async (payload) => { onSave={async (payload) => {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
family: selectedFamily, family: selectedFamily,
index: selectedEntry.index, entryId: selectedEntry.id,
data: payload, data: payload,
}); });
void refetch(); void refetch();
@@ -1793,7 +1793,7 @@ export function CliproxyAiProvidersPage() {
if (editingEntry) { if (editingEntry) {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
family: selectedFamily, family: selectedFamily,
index: editingEntry.index, entryId: editingEntry.id,
data: payload, data: payload,
}); });
} else { } else {
@@ -1820,7 +1820,7 @@ export function CliproxyAiProvidersPage() {
if (!deleteEntry) return; if (!deleteEntry) return;
await deleteMutation.mutateAsync({ await deleteMutation.mutateAsync({
family: selectedFamily, family: selectedFamily,
index: deleteEntry.index, entryId: deleteEntry.id,
}); });
setDeleteEntry(null); setDeleteEntry(null);
}} }}
@@ -0,0 +1,199 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner';
import { AllProviders } from '../../setup/test-utils';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AllProviders>{children}</AllProviders>
);
describe('useCliproxyAuthFlow', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.spyOn(window, 'open').mockImplementation(() => null);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('ignores stale poll completions from a superseded auth attempt', async () => {
const firstPoll = createDeferred<Response>();
let startCount = 0;
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/start-url')) {
startCount += 1;
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: `https://auth.example/${startCount}`,
state: `state-${startCount}`,
})
);
}
if (url.includes('/status?state=state-1')) {
return firstPoll.promise;
}
if (url.includes('/status?state=state-2')) {
return Promise.resolve(createJsonResponse({ status: 'wait' }));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
});
expect(result.current.oauthState).toBe('state-1');
await act(async () => {
vi.advanceTimersByTime(3000);
await Promise.resolve();
});
await act(async () => {
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
});
expect(result.current.oauthState).toBe('state-2');
expect(result.current.isAuthenticating).toBe(true);
await act(async () => {
firstPoll.resolve(createJsonResponse({ status: 'ok' }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.oauthState).toBe('state-2');
expect(result.current.isAuthenticating).toBe(true);
expect(toast.success).not.toHaveBeenCalled();
});
it('surfaces repeated poll transport failures instead of retrying forever', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/start-url')) {
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: 'https://auth.example/fail',
state: 'state-fail',
})
);
}
if (url.includes('/status?state=state-fail')) {
return Promise.reject(new Error('poll failed'));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
});
for (let attempt = 0; attempt < 3; attempt += 1) {
await act(async () => {
vi.advanceTimersByTime(3000);
await Promise.resolve();
await Promise.resolve();
});
}
expect(result.current.error).toBe('poll failed');
expect(result.current.isAuthenticating).toBe(false);
expect(toast.error).toHaveBeenCalledWith('poll failed');
});
it('treats callback responses without an account as failures', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/start-url')) {
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: 'https://auth.example/callback',
state: 'state-callback',
})
);
}
if (url.includes('/submit-callback')) {
return Promise.resolve(createJsonResponse({ success: true, account: null }));
}
if (url.includes('/status?state=state-callback')) {
return Promise.resolve(createJsonResponse({ status: 'wait' }));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
});
await act(async () => {
await result.current.submitCallback(
'http://localhost/callback?code=abc123&state=state-callback'
);
});
expect(result.current.error).toBe('Authenticated account could not be registered');
expect(result.current.isSubmittingCallback).toBe(false);
expect(toast.error).toHaveBeenCalledWith('Authenticated account could not be registered');
expect(toast.success).not.toHaveBeenCalled();
});
});