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
Last Updated: 2026-03-17
Last Updated: 2026-03-23
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.
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)
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
2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript)
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
+3 -1
View File
@@ -1,6 +1,6 @@
# 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.
@@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic
### 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-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.
@@ -361,6 +361,10 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
**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) |
@@ -432,6 +436,8 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
- Device Code method uses /start route (no callback port)
- Callback/social methods use /start-url + status polling
- 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)
+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 { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import {
isUnifiedMode,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
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 {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
mutateUnifiedConfig((config) => {
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }),
};
});
return;
}
+33 -41
View File
@@ -7,11 +7,7 @@ import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
@@ -198,13 +194,13 @@ function createApiProfileUnified(
// Inject WebSearch hooks into profile settings
ensureProfileHooks(name);
const config = loadOrCreateUnifiedConfig();
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${settingsFile}`,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${settingsFile}`,
...(target !== 'claude' && { target }),
};
});
}
/** Create a new API profile */
@@ -308,17 +304,17 @@ export function updateApiProfileTarget(
): UpdateApiProfileTargetResult {
try {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
if (!config.profiles[name]) {
return { success: false, error: `API profile not found: ${name}` };
}
mutateUnifiedConfig((config) => {
if (!config.profiles[name]) {
throw new Error(`API profile not found: ${name}`);
}
if (target === 'claude') {
delete config.profiles[name].target;
} else {
config.profiles[name].target = target;
}
saveUnifiedConfig(config);
if (target === 'claude') {
delete config.profiles[name].target;
} else {
config.profiles[name].target = target;
}
});
return { success: true, target };
}
@@ -357,30 +353,26 @@ export function updateApiProfileTarget(
/** Remove API profile from unified config */
function removeApiProfileUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
const profile = config.profiles[name];
mutateUnifiedConfig((config) => {
const profile = config.profiles[name];
if (!profile) {
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);
if (!profile) {
throw new Error(`API profile not found: ${name}`);
}
}
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
if (config.default === name) {
config.default = undefined;
}
delete config.profiles[name];
saveUnifiedConfig(config);
if (config.default === name) {
config.default = undefined;
}
});
}
/** Remove API profile from legacy config */
+48 -50
View File
@@ -3,7 +3,7 @@ import * as path from 'path';
import { ProfileMetadata } from '../types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
mutateUnifiedConfig,
isUnifiedMode,
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
@@ -313,74 +313,72 @@ export class ProfileRegistry {
* Create account in unified config (config.yaml)
*/
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
const config = loadOrCreateUnifiedConfig();
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
bare: metadata.bare,
mutateUnifiedConfig((config) => {
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
bare: metadata.bare,
});
});
saveUnifiedConfig(config);
}
/**
* Update account metadata in unified config
*/
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
...updates,
mutateUnifiedConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
...updates,
});
});
saveUnifiedConfig(config);
}
/**
* Remove account from unified config
*/
removeAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
delete config.accounts[name];
// Clear default if it was the deleted account
if (config.default === name) {
config.default = undefined;
}
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
delete config.accounts[name];
if (config.default === name) {
config.default = undefined;
}
});
}
/**
* Set default profile in unified config
*/
setDefaultUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
// Check if exists in accounts, profiles, or cliproxy variants
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
throw new Error(`Profile not found: ${name}`);
}
config.default = name;
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
throw new Error(`Profile not found: ${name}`);
}
config.default = name;
});
}
/**
* Clear default profile in unified config (restore original CCS behavior)
*/
clearDefaultUnified(): void {
const config = loadOrCreateUnifiedConfig();
config.default = undefined;
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
config.default = undefined;
});
}
/**
@@ -418,13 +416,13 @@ export class ProfileRegistry {
* Update account last_used in unified config
*/
touchAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
});
}
// ==========================================
+3 -5
View File
@@ -6,7 +6,7 @@
import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { AccountInfo } from './types';
import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry } from './registry';
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
/**
* Get all accounts for a provider
@@ -14,10 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
const registry = loadAccountsRegistry();
// Sync with actual token files (removes stale entries)
if (syncRegistryWithTokenFiles(registry)) {
saveAccountsRegistry(registry);
}
// Sync in-memory view with actual token files without mutating disk on read.
syncRegistryWithTokenFiles(registry);
const providerAccounts = registry.providers[provider];
+312 -326
View File
@@ -5,9 +5,10 @@
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { CLIProxyProvider } from '../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 {
getAccountsRegistryPath,
@@ -30,32 +31,58 @@ function createDefaultRegistry(): AccountsRegistry {
};
}
/**
* Load accounts registry
*/
export function loadAccountsRegistry(): AccountsRegistry {
function withAccountsRegistryLock<T>(callback: () => T): T {
const lockTarget = getCliproxyDir();
let release: (() => void) | undefined;
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();
if (!fs.existsSync(registryPath)) {
return createDefaultRegistry();
}
const content = fs.readFileSync(registryPath, 'utf-8');
let data: unknown;
try {
const content = fs.readFileSync(registryPath, 'utf-8');
const data = JSON.parse(content);
return {
version: data.version || 1,
providers: data.providers || {},
};
} catch {
return createDefaultRegistry();
data = JSON.parse(content);
} catch (error) {
throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`);
}
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'])
: {},
};
}
/**
* Save accounts registry
*/
export function saveAccountsRegistry(registry: AccountsRegistry): void {
function writeAccountsRegistryToDisk(registry: AccountsRegistry): void {
const registryPath = getAccountsRegistryPath();
const dir = path.dirname(registryPath);
@@ -63,9 +90,39 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
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,
});
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,
projectId?: string
): AccountInfo {
const registry = loadAccountsRegistry();
return mutateAccountsRegistry((registry) => {
syncRegistryWithTokenFiles(registry);
// Initialize provider section if needed
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: 'default',
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.`
);
}
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: 'default',
accounts: {},
};
}
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
// For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
}
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
throw new Error('Failed to initialize provider accounts');
}
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
let accountId: string;
let accountNickname: string;
// Create or update account
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(),
};
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
accountId = email
? extractAccountIdFromTokenFile(tokenFile, email)
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
const existingAccount = providerAccounts.accounts[accountId];
// Include projectId for Antigravity accounts
if (provider === 'agy' && projectId) {
accountMeta.projectId = projectId;
}
if (nickname) {
const validationError = validateNickname(nickname);
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
if (isFirstAccount) {
providerAccounts.default = accountId;
}
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : 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 {
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,
};
if (provider === 'agy' && projectId) {
accountMeta.projectId = projectId;
}
providerAccounts.accounts[accountId] = accountMeta;
if (isFirstAccount) {
providerAccounts.default = accountId;
}
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
*/
export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
providerAccounts.default = accountId;
saveAccountsRegistry(registry);
return true;
providerAccounts.default = accountId;
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
*/
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
if (!providerAccounts?.accounts[accountId]) {
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
const accountMeta = providerAccounts.accounts[accountId];
if (accountMeta.paused) {
return true;
}
// Skip if already paused (idempotent)
if (accountMeta.paused) {
if (!moveTokenToPaused(accountMeta.tokenFile)) {
return false;
}
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
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
*/
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
if (!providerAccounts?.accounts[accountId]) {
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
const accountMeta = providerAccounts.accounts[accountId];
if (!accountMeta.paused) {
return true;
}
// Skip if already active (idempotent)
if (!accountMeta.paused) {
if (!moveTokenFromPaused(accountMeta.tokenFile)) {
return false;
}
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
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
*/
export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
// Delete token file from both auth and paused directories
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
deleteTokenFile(tokenFile);
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
if (!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);
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
providerAccounts.default = remainingAccounts[0];
}
const remainingAccounts = Object.keys(providerAccounts.accounts);
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
providerAccounts.default = remainingAccounts[0];
}
saveAccountsRegistry(registry);
return true;
return true;
});
}
/**
@@ -336,37 +383,36 @@ export function renameAccount(
throw new Error(validationError);
}
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
if (!providerAccounts?.accounts[accountId]) {
return false;
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
throw new Error(`Nickname "${newNickname}" is already used by another account`);
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
throw new Error(`Nickname "${newNickname}" is already used by another account`);
}
providerAccounts.accounts[accountId].nickname = newNickname;
saveAccountsRegistry(registry);
return true;
providerAccounts.accounts[accountId].nickname = newNickname;
return true;
});
}
/**
* Update last used timestamp for an account
*/
export function touchAccount(provider: CLIProxyProvider, accountId: string): void {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (providerAccounts?.accounts[accountId]) {
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
saveAccountsRegistry(registry);
}
mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (providerAccounts?.accounts[accountId]) {
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
}
});
}
/**
@@ -377,16 +423,16 @@ export function setAccountTier(
accountId: string,
tier: 'free' | 'pro' | 'ultra' | 'unknown'
): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts?.accounts[accountId]) {
return false;
}
if (!providerAccounts?.accounts[accountId]) {
return false;
}
providerAccounts.accounts[accountId].tier = tier;
saveAccountsRegistry(registry);
return true;
providerAccounts.accounts[accountId].tier = tier;
return true;
});
}
/**
@@ -404,163 +450,103 @@ export function discoverExistingAccounts(): void {
return;
}
const registry = loadAccountsRegistry();
const files = fs.readdirSync(authDir);
mutateAccountsRegistry((registry) => {
syncRegistryWithTokenFiles(registry);
// Track whether any accounts were discovered (to avoid saving empty registry)
let discoveredCount = 0;
for (const file of files) {
if (!file.endsWith('.json')) continue;
for (const file of files) {
if (!file.endsWith('.json')) continue;
const filePath = path.join(authDir, file);
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 content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Skip if no type field
if (!data.type) continue;
// 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;
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) {
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];
if (!provider) {
continue;
}
}
// Initialize provider section if needed
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: 'default',
accounts: {},
let email = data.email || undefined;
if (!email && file.includes('@')) {
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
if (match) {
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];
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 =
const discoveredProjectId =
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
);
// Update if missing or changed
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
existingEntry[1].projectId = projectIdValue;
discoveredCount++; // Count projectId updates as changes
}
if (provider === 'agy' && discoveredProjectId) {
accountMeta.projectId = discoveredProjectId;
}
providerAccounts.accounts[accountId] = accountMeta;
} catch {
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
* Idempotent
*/
export function deleteTokenFile(tokenFile: string): void {
export function deleteTokenFile(tokenFile: string): boolean {
const tokenPath = path.join(getAuthDir(), tokenFile);
const pausedPath = path.join(getPausedDir(), tokenFile);
let success = true;
// Delete from auth directory
if (fs.existsSync(tokenPath)) {
try {
fs.unlinkSync(tokenPath);
} catch {
// Ignore deletion errors
success = false;
}
}
@@ -111,9 +112,11 @@ export function deleteTokenFile(tokenFile: string): void {
try {
fs.unlinkSync(pausedPath);
} 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 yaml from 'js-yaml';
import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator';
@@ -21,6 +22,41 @@ type FamilyEntriesMap = {
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 {
if (!configExists()) {
regenerateConfig();
@@ -91,7 +127,12 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
if (!target.isRemote) {
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({
@@ -102,17 +143,29 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
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>(
family: F,
entries: FamilyEntries<F>
): Promise<void> {
const normalized = ensureStableEntryIds(family, entries);
const target = getProxyTarget();
if (!target.isRemote) {
writeLocalFamilySection(family, entries);
writeLocalFamilySection(family, normalized.entries);
return;
}
@@ -124,5 +177,8 @@ export async function writeFamilyEntries<F extends AiProviderFamilyId>(
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
): AiProviderEntryView {
return {
id: `${family}:${index}`,
id: entry.id || `${family}:${index}`,
index,
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
baseUrl: entry['base-url']?.trim() || undefined,
@@ -67,7 +67,7 @@ function buildApiKeyEntryView(
function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView {
return {
id: `openai-compatibility:${index}`,
id: entry.id || `openai-compatibility:${index}`,
index,
name: entry.name,
label: entry.name,
@@ -131,6 +131,7 @@ function toApiKeyEntry(
: existing?.['api-key'] || '';
return {
id: existing?.id,
'api-key': nextSecret,
'base-url': input.baseUrl?.trim() || undefined,
'proxy-url': input.proxyUrl?.trim() || undefined,
@@ -155,6 +156,7 @@ function toOpenAiCompatEntry(
: (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']);
return {
id: existing?.id,
name: input.name?.trim() || existing?.name || 'connector',
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
headers: normalizeHeaders(input.headers),
@@ -163,8 +165,41 @@ function toOpenAiCompatEntry(
};
}
function assertIndex(entries: unknown[], index: number): void {
if (!Number.isInteger(index) || index < 0 || index >= entries.length) {
function resolveEntryIndex(entries: Array<{ id?: string }>, entryId: string): number {
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');
}
}
@@ -208,12 +243,14 @@ export async function createAiProviderEntry(
export async function updateAiProviderEntry(
family: AiProviderFamilyId,
index: number,
entryId: string,
input: UpsertAiProviderEntryInput
): Promise<void> {
assertEntryId(entryId);
if (family === 'openai-compatibility') {
const entries = await readFamilyEntries(family);
assertIndex(entries, index);
const index = resolveEntryIndex(entries, entryId);
validateFamilyInput(family, input);
entries[index] = toOpenAiCompatEntry(input, entries[index]);
await writeFamilyEntries(family, entries);
@@ -221,7 +258,7 @@ export async function updateAiProviderEntry(
}
const entries = await readFamilyEntries(family);
assertIndex(entries, index);
const index = resolveEntryIndex(entries, entryId);
validateFamilyInput(family, input);
entries[index] = toApiKeyEntry(input, entries[index]);
await writeFamilyEntries(family, entries);
@@ -229,18 +266,20 @@ export async function updateAiProviderEntry(
export async function deleteAiProviderEntry(
family: AiProviderFamilyId,
index: number
entryId: string
): Promise<void> {
assertEntryId(entryId);
if (family === 'openai-compatibility') {
const entries = await readFamilyEntries(family);
assertIndex(entries, index);
const index = resolveEntryIndex(entries, entryId);
entries.splice(index, 1);
await writeFamilyEntries(family, entries);
return;
}
const entries = await readFamilyEntries(family);
assertIndex(entries, index);
const index = resolveEntryIndex(entries, entryId);
entries.splice(index, 1);
await writeFamilyEntries(family, entries);
}
+2
View File
@@ -14,6 +14,7 @@ export interface AiProviderModelAlias {
}
export interface AiProviderApiKeyEntry {
id?: string;
'api-key': string;
'base-url'?: string;
'proxy-url'?: string;
@@ -29,6 +30,7 @@ export interface OpenAICompatApiKeyEntry {
}
export interface OpenAICompatEntry {
id?: string;
name: string;
'base-url': string;
headers?: Record<string, string>;
+46 -56
View File
@@ -8,7 +8,7 @@
*/
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';
/**
@@ -87,19 +87,17 @@ export function getEffectiveManagementSecret(): string {
* @param apiKey - New API key (or undefined to reset to default)
*/
export function setGlobalApiKey(apiKey: string | undefined): void {
const config = loadOrCreateUnifiedConfig();
mutateUnifiedConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (apiKey === undefined) {
delete config.cliproxy.auth.api_key;
} else {
config.cliproxy.auth.api_key = apiKey;
}
saveUnifiedConfig(config);
if (apiKey === undefined) {
delete config.cliproxy.auth.api_key;
} else {
config.cliproxy.auth.api_key = apiKey;
}
});
}
/**
@@ -109,19 +107,17 @@ export function setGlobalApiKey(apiKey: string | undefined): void {
* @param secret - New management secret (or undefined to reset to default)
*/
export function setGlobalManagementSecret(secret: string | undefined): void {
const config = loadOrCreateUnifiedConfig();
mutateUnifiedConfig((config) => {
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (!config.cliproxy.auth) {
config.cliproxy.auth = {};
}
if (secret === undefined) {
delete config.cliproxy.auth.management_secret;
} else {
config.cliproxy.auth.management_secret = secret;
}
saveUnifiedConfig(config);
if (secret === undefined) {
delete config.cliproxy.auth.management_secret;
} else {
config.cliproxy.auth.management_secret = secret;
}
});
}
/**
@@ -132,28 +128,26 @@ export function setGlobalManagementSecret(secret: string | undefined): void {
* @param apiKey - New API key (or undefined to remove override)
*/
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
const config = loadOrCreateUnifiedConfig();
const variant = config.cliproxy.variants[variantName];
mutateUnifiedConfig((config) => {
const variant = config.cliproxy.variants[variantName];
if (!variant) {
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;
if (!variant) {
throw new Error(`Variant '${variantName}' not found`);
}
} 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.
*/
export function resetAuthToDefaults(): void {
const config = loadOrCreateUnifiedConfig();
mutateUnifiedConfig((config) => {
delete config.cliproxy.auth;
// Remove global auth
delete config.cliproxy.auth;
// Remove all variant auth overrides
for (const variantName of Object.keys(config.cliproxy.variants)) {
const variant = config.cliproxy.variants[variantName];
if (variant.auth) {
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 { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile } from '../accounts/token-file-ops';
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -206,40 +206,78 @@ export function registerAccountFromToken(
verbose = false,
expectedAccountId?: string
): 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');
let newestFile: string | null = null;
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
try {
const files = fs.readdirSync(tokenDir);
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) {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) continue;
const stats = fs.statSync(filePath);
if (stats.mtimeMs > newestMtime) {
newestMtime = stats.mtimeMs;
newestFile = file;
}
if (expectedAccountId) {
selectedCandidate =
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
candidates.find((candidate) => {
const existingAccount = existingAccounts.find(
(account) => account.id === expectedAccountId
);
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;
}
const tokenPath = path.join(tokenDir, newestFile);
const content = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(content);
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const account = registerAccount(provider, newestFile, email, nickname, projectId);
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose);
const account = registerAccount(
provider,
selectedCandidate.file,
selectedCandidate.email,
nickname,
selectedCandidate.projectId
);
uploadTokenToRemoteAsync(selectedCandidate.filePath, verbose);
return account;
} catch (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}`);
}
if (typeof newestFile === 'string') {
const hasExistingRegistration = getProviderAccounts(provider).some(
(account) => account.tokenFile === newestFile
);
if (!hasExistingRegistration) {
deleteTokenFile(newestFile);
}
if (selectedCandidate && !selectedCandidate.alreadyRegistered && !expectedAccountId) {
deleteTokenFile(selectedCandidate.file);
}
if (expectedAccountId && verbose) {
+45 -44
View File
@@ -10,7 +10,7 @@ import {
} from '../../config/unified-config-types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
mutateUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
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 {
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 = {
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
if (!unifiedConfig.cliproxy.variants) {
unifiedConfig.cliproxy.variants = {};
}
unifiedConfig.cliproxy.variants[name] = config;
saveUnifiedConfig(unifiedConfig);
unifiedConfig.cliproxy.variants[name] = config;
});
}
export function saveVariantUnified(
@@ -196,28 +195,26 @@ export function saveVariantUnified(
port?: number,
target: TargetType = 'claude'
): 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 = {
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
config.cliproxy.variants[name] = {
provider,
account,
settings: settingsPath,
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(
@@ -268,18 +265,22 @@ export function saveVariantLegacy(
}
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;
}
const variant = config.cliproxy.variants[name];
delete config.cliproxy.variants[name];
saveUnifiedConfig(config);
if ('type' in variant && variant.type === 'composite') {
const composite = variant as CompositeVariantConfig;
const composite = removedVariant as CompositeVariantConfig;
if (composite.type === 'composite') {
return {
provider: composite.tiers[composite.default_tier].provider,
settings: composite.settings,
@@ -290,7 +291,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
tiers: composite.tiers,
};
}
const singleVariant = variant as CLIProxyVariantConfig;
const singleVariant = removedVariant as CLIProxyVariantConfig;
return {
provider: singleVariant.provider,
settings: singleVariant.settings,
@@ -11,17 +11,7 @@
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
import { detectRunningProxy } from '../../cliproxy/proxy-detector';
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);
}
import { resolveLifecyclePort } from './resolve-lifecycle-port';
export async function handleStart(verbose = false): Promise<void> {
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 {
getDashboardAuthConfig,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { initUI, header, ok, info, warn, dim } from '../../utils/ui';
/**
@@ -57,15 +53,14 @@ export async function handleDisable(): Promise<void> {
}
// Disable auth
const fullConfig = loadOrCreateUnifiedConfig();
fullConfig.dashboard_auth = {
enabled: false,
username: fullConfig.dashboard_auth?.username ?? '',
password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
};
saveUnifiedConfig(fullConfig);
mutateUnifiedConfig((fullConfig) => {
fullConfig.dashboard_auth = {
enabled: false,
username: fullConfig.dashboard_auth?.username ?? '',
password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
};
});
console.log('');
console.log(ok('Dashboard authentication disabled'));
+12 -13
View File
@@ -8,7 +8,7 @@
import bcrypt from 'bcrypt';
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 type { AuthSetupResult } from './types';
@@ -99,24 +99,23 @@ export async function handleSetup(): Promise<AuthSetupResult> {
// Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Load existing config and update
const config = loadOrCreateUnifiedConfig();
config.dashboard_auth = {
enabled: true,
username,
password_hash: passwordHash,
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
// Save config
saveUnifiedConfig(config);
const config = mutateUnifiedConfig((currentConfig) => {
currentConfig.dashboard_auth = {
enabled: true,
username,
password_hash: passwordHash,
session_timeout_hours: currentConfig.dashboard_auth?.session_timeout_hours ?? 24,
};
});
console.log('');
console.log(ok('Dashboard authentication configured'));
console.log('');
console.log(info('Settings saved to ~/.ccs/config.yaml'));
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(dim(' Start dashboard: ccs config'));
console.log(dim(' Show status: ccs config auth show'));
+12 -14
View File
@@ -15,7 +15,7 @@ import {
normalizeCopilotConfigWithWarnings,
} 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 { ok, fail, info, color, warn } from '../utils/ui';
import { normalizeCopilotSubcommand } from '../copilot/constants';
@@ -361,14 +361,13 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
mutateUnifiedConfig((config) => {
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
config.copilot.enabled = true;
saveUnifiedConfig(config);
config.copilot.enabled = true;
});
console.log(ok('Copilot integration enabled'));
console.log('');
@@ -384,12 +383,11 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (config.copilot) {
config.copilot.enabled = false;
saveUnifiedConfig(config);
}
mutateUnifiedConfig((config) => {
if (config.copilot) {
config.copilot.enabled = false;
}
});
console.log(ok('Copilot integration disabled'));
+12 -18
View File
@@ -15,11 +15,7 @@ import {
getAvailableModels,
getDefaultModel,
} from '../cursor';
import {
getCursorConfig,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../config/unified-config-loader';
import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
@@ -239,14 +235,13 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
mutateUnifiedConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
}
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
}
config.cursor.enabled = true;
saveUnifiedConfig(config);
config.cursor.enabled = true;
});
console.log(ok('Cursor integration enabled'));
console.log('');
@@ -262,12 +257,11 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (config.cursor) {
config.cursor.enabled = false;
saveUnifiedConfig(config);
}
mutateUnifiedConfig((config) => {
if (config.cursor) {
config.cursor.enabled = false;
}
});
console.log(ok('Cursor integration disabled'));
return 0;
+5 -4
View File
@@ -19,7 +19,7 @@ import { initUI, header, ok, info, warn } from '../utils/ui';
import {
loadOrCreateUnifiedConfig,
loadUnifiedConfig,
saveUnifiedConfig,
mutateUnifiedConfig,
hasUnifiedConfig,
} from '../config/unified-config-loader';
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.');
}
// Save config
config.setup_completed = true;
saveUnifiedConfig(config);
mutateUnifiedConfig((currentConfig) => {
currentConfig.setup_completed = true;
currentConfig.cliproxy_server = config.cliproxy_server;
});
// Final summary
console.log('');
+41 -32
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import {
@@ -64,65 +65,73 @@ function getLockFilePath(): string {
/**
* 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).
*/
function acquireLock(): boolean {
function acquireLock(): string | null {
const lockPath = getLockFilePath();
const lockData = `${process.pid}\n${Date.now()}`;
const lockToken = crypto.randomUUID();
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
try {
// Check if lock exists
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
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 (Date.now() - timestamp > LOCK_STALE_MS) {
// Stale lock - remove and acquire
if (hasLiveOwner) {
return null;
}
if (isStale || !hasLiveOwner) {
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
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
return true;
return lockToken;
} catch (error) {
// EEXIST means another process acquired the lock between our check and write
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return false;
return null;
}
return false;
return null;
}
}
/**
* Release lockfile after config write operation.
*/
function releaseLock(): void {
function releaseLock(lockToken: string): void {
const lockPath = getLockFilePath();
try {
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 {
// Ignore cleanup errors
}
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Check if unified config.yaml exists
*/
@@ -152,7 +161,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
/**
* 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).
*/
export function loadUnifiedConfig(): UnifiedConfig | null {
@@ -168,8 +177,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
const parsed = yaml.load(content);
if (!isUnifiedConfig(parsed)) {
console.error(`[!] Invalid config format in ${yamlPath}`);
return null;
throw new Error(`Invalid config format in ${yamlPath}`);
}
// 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';
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)
const maxRetries = 10;
const retryDelayMs = 100;
let lockAcquired = false;
let lockToken: string | null = null;
for (let i = 0; i < maxRetries; i++) {
if (acquireLock()) {
lockAcquired = true;
const acquiredToken = acquireLock();
if (acquiredToken) {
lockToken = acquiredToken;
break;
}
sleepSync(retryDelayMs);
}
if (!lockAcquired) {
if (!lockToken) {
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();
} finally {
// 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
const { isUnifiedMode } = require('../config/unified-config-loader');
if (isUnifiedMode()) {
const {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} = require('../config/unified-config-loader');
const config = loadOrCreateUnifiedConfig();
saveUnifiedConfig(config);
const { mutateUnifiedConfig } = require('../config/unified-config-loader');
mutateUnifiedConfig(() => {});
return { success: true, message: 'Created/updated config.yaml' };
}
const configPath = getConfigPath();
@@ -132,3 +132,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
// Unauthorized
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 UpsertAiProviderEntryInput,
} from '../../cliproxy/ai-providers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
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 {
return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId);
}
@@ -24,13 +37,13 @@ function parseFamily(req: Request, res: Response): AiProviderFamilyId | null {
return family;
}
function parseIndex(req: Request, res: Response): number | null {
const index = Number.parseInt(req.params.index || '', 10);
if (!Number.isInteger(index) || index < 0) {
res.status(400).json({ error: 'Invalid entry index' });
function parseEntryId(req: Request, res: Response): string | null {
const entryId = req.params.entryId?.trim();
if (!entryId) {
res.status(400).json({ error: 'Invalid entry id' });
return null;
}
return index;
return entryId;
}
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);
if (!family) return;
const index = parseIndex(req, res);
if (index === null) return;
const entryId = parseEntryId(req, res);
if (!entryId) return;
try {
await updateAiProviderEntry(family, index, parseInput(req.body));
await updateAiProviderEntry(family, entryId, parseInput(req.body));
res.json({ success: true });
} catch (error) {
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);
if (!family) return;
const index = parseIndex(req, res);
if (index === null) return;
const entryId = parseEntryId(req, res);
if (!entryId) return;
try {
await deleteAiProviderEntry(family, index);
await deleteAiProviderEntry(family, entryId);
res.json({ success: true });
} catch (error) {
const message = (error as Error).message;
+36 -17
View File
@@ -23,7 +23,6 @@ import {
resumeAccount as resumeAccountFn,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
@@ -53,6 +52,7 @@ import {
isAntigravityResponsibilityBypassEnabled,
} from '../../cliproxy/antigravity-responsibility';
import { createRouteErrorHelpers } from './route-helpers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
@@ -66,6 +66,18 @@ const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
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 {
for (const [state, pending] of pendingManualAuthState.entries()) {
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';
}
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(
provider: CLIProxyProvider,
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 existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
existingAccounts
);
if (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 existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
existingAccounts
);
if (nicknameError) {
res.status(400).json(nicknameError);
@@ -780,7 +795,6 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (oauthState) {
rememberManualAuthState(oauthState, {
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);
}
if (!account) {
res.status(409).json({
error: getManualCallbackRegistrationError(provider as CLIProxyProvider),
});
return;
}
res.json({
success: true,
account: account
? {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
}
: null,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
} catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
@@ -53,6 +53,7 @@ import {
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
@@ -66,6 +67,18 @@ interface 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 {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
return `${clientIp}:${provider}`;
+7 -13
View File
@@ -3,7 +3,7 @@
*/
import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
generateSyncPayload,
generateSyncPreview,
@@ -12,7 +12,6 @@ import {
syncToLocalConfig,
getLocalSyncStatus,
} from '../../cliproxy/sync';
import { saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router();
@@ -130,18 +129,13 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
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 {
config.cliproxy.auto_sync = enabled;
saveUnifiedConfig(config);
mutateUnifiedConfig((config) => {
if (!config.cliproxy) {
throw new Error('CLIProxy config not initialized');
}
config.cliproxy.auto_sync = enabled;
});
} catch (error) {
res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` });
return;
+42 -44
View File
@@ -9,11 +9,7 @@ import { promises as fsp } from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
getCursorConfig,
} from '../../config/unified-config-loader';
import { mutateUnifiedConfig, getCursorConfig } from '../../config/unified-config-loader';
import type { CursorConfig } from '../../config/unified-config-types';
const router = Router();
@@ -184,36 +180,38 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
return;
}
const config = loadOrCreateUnifiedConfig();
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
// Only known fields are merged — unknown properties are ignored
config.cursor = {
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 });
const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG;
await syncRawSettingsFromCursorConfig(cursorConfig);
res.json({ success: true, cursor: cursorConfig });
} catch (error) {
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).
const parsedPort = parseLocalCursorPort(settings);
const config = loadOrCreateUnifiedConfig();
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 ?? DEFAULT_CURSOR_CONFIG),
...(parsedPort !== null ? { port: parsedPort } : {}),
...(model ? { model } : {}),
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
};
saveUnifiedConfig(config);
config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
...(parsedPort !== null ? { port: parsedPort } : {}),
...(model ? { model } : {}),
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
};
});
const stat = fs.statSync(settingsPath);
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 { expandPath } from '../../utils/helpers';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
mutateUnifiedConfig,
getGlobalEnvConfig,
getThinkingConfig,
@@ -24,9 +22,22 @@ import {
THINKING_OFF_VALUES,
} from '../../cliproxy';
import { validateFilePath } from './route-helpers';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
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(
currentProviderOverrides: ThinkingConfig['provider_overrides'] | 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 shouldClearProviderOverrides =
clearProviderOverridesFlag === true || updates.provider_overrides === null;
let normalizedOverride: string | number | undefined = config.thinking?.override as
| string
| number
| undefined;
let normalizedOverride: string | number | undefined;
let normalizedProviderOverrides:
| Record<string, Partial<ThinkingConfig['tier_defaults']>>
| undefined;
@@ -438,28 +445,32 @@ router.put('/thinking', (req: Request, res: Response): void => {
Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined;
}
// Update thinking section
config.thinking = {
mode: updates.mode ?? config.thinking?.mode ?? 'auto',
override: shouldClearOverride
? undefined
: updates.override !== undefined
? normalizedOverride
: config.thinking?.override,
tier_defaults: {
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high',
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium',
haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low',
},
provider_overrides: resolveThinkingProviderOverridesForSave(
config.thinking?.provider_overrides,
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
shouldClearProviderOverrides
),
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true,
};
saveUnifiedConfig(config);
const config = mutateUnifiedConfig((currentConfig) => {
currentConfig.thinking = {
mode: updates.mode ?? currentConfig.thinking?.mode ?? 'auto',
override: shouldClearOverride
? undefined
: updates.override !== undefined
? normalizedOverride
: currentConfig.thinking?.override,
tier_defaults: {
opus:
updates.tier_defaults?.opus ?? currentConfig.thinking?.tier_defaults?.opus ?? 'high',
sonnet:
updates.tier_defaults?.sonnet ??
currentConfig.thinking?.tier_defaults?.sonnet ??
'medium',
haiku:
updates.tier_defaults?.haiku ?? currentConfig.thinking?.tier_defaults?.haiku ?? 'low',
},
provider_overrides: resolveThinkingProviderOverridesForSave(
currentConfig.thinking?.provider_overrides,
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
shouldClearProviderOverrides
),
show_warnings: updates.show_warnings ?? currentConfig.thinking?.show_warnings ?? true,
};
});
// W4: Return new mtime for subsequent requests
let newMtime: number | undefined;
+13
View File
@@ -17,9 +17,22 @@ import {
CliproxyServerConfig,
} from '../../config/unified-config-types';
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
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
*/
+175 -132
View File
@@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
import { listVariants } from '../../cliproxy/services/variant-service';
@@ -20,11 +21,8 @@ import {
import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services';
import {
getDashboardAuthConfig,
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
} from '../../config/unified-config-loader';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
@@ -43,40 +41,25 @@ const MODEL_ENV_KEYS = [
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] 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');
function isLoopbackAddress(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.')
);
function resolvePathWithin(basePath: string, targetPath: string): string {
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) {
throw new Error('Invalid settings path');
}
return resolvedTarget;
}
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
const dashboardAuth = getDashboardAuthConfig();
if (dashboardAuth.enabled) {
return true;
}
// 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;
return requireLocalAccessWhenAuthDisabled(
req,
res,
'Sensitive settings endpoints require localhost access when dashboard auth is disabled.'
);
}
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
*/
function resolveSettingsPath(profileOrVariant: string): string {
if (!SETTINGS_IDENTIFIER_PATTERN.test(profileOrVariant)) {
throw new Error('Invalid profile name');
}
const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir);
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];
if (variant?.settings) {
// 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
return path.join(ccsDir, `${profileOrVariant}.settings.json`);
return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null {
@@ -261,11 +252,29 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
}
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.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(
profileOrVariant: 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)
*/
router.get('/:profile/raw', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile);
@@ -379,6 +390,8 @@ function checkRequiredEnvVars(settings: Settings): string[] {
* PUT /api/settings/:profile - Update settings with conflict detection and backup
*/
router.put('/:profile', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
@@ -407,53 +420,56 @@ router.put('/:profile', (req: Request, res: Response): void => {
const missingFields = checkRequiredEnvVars(normalizedSettings);
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;
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
if (fileExists) {
const existingContent = fs.readFileSync(settingsPath, 'utf8');
// Only create backup if content differs
if (existingContent !== newContent) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
let created = false;
let newMtime = 0;
withSettingsFileLock(settingsPath, () => {
const fileExists = fs.existsSync(settingsPath);
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;
}
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({
profile,
mtime: newStat.mtime.getTime(),
mtime: newMtime,
backupPath,
created: !fileExists,
created,
// Include warning if fields missing (runtime will use defaults)
...(missingFields.length > 0 && {
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
*/
router.post('/:profile/presets', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const { profile } = req.params;
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);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
let persistedPreset:
| {
name: string;
default: string;
opus: string;
sonnet: string;
haiku: string;
}
| undefined;
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
settings.presets = settings.presets || [];
withSettingsFileLock(settingsPath, () => {
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
if (settings.presets.some((p) => p.name === name)) {
res.status(409).json({ error: 'Preset with this name already exists' });
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
settings.presets = settings.presets || [];
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;
}
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 });
} catch (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
*/
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const { profile, name } = req.params;
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
withSettingsFileLock(settingsPath, () => {
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;
}
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 });
} catch (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)
*/
router.get('/auth/tokens', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try {
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
*/
router.put('/auth/tokens', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
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
*/
router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try {
const newSecret = generateSecureToken(32);
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
*/
router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(_req, res)) return;
try {
resetAuthToDefaults();
+40 -58
View File
@@ -3,11 +3,7 @@
*/
import { Router, Request, Response } from 'express';
import {
loadUnifiedConfig,
saveUnifiedConfig,
getWebSearchConfig,
} from '../../config/unified-config-loader';
import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader';
import type { WebSearchConfig } from '../../config/unified-config-types';
import {
getWebSearchReadiness,
@@ -52,59 +48,45 @@ router.put('/', (req: Request, res: Response): void => {
}
try {
// Load existing config and update websearch section
const existingConfig = loadUnifiedConfig();
if (!existingConfig) {
res.status(500).json({ error: 'Failed to load config' });
return;
}
// Merge updates - supports Gemini CLI and Grok CLI
existingConfig.websearch = {
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
providers: providers
? {
gemini: {
enabled:
providers.gemini?.enabled ??
existingConfig.websearch?.providers?.gemini?.enabled ??
true,
model:
providers.gemini?.model ??
existingConfig.websearch?.providers?.gemini?.model ??
'gemini-2.5-flash',
timeout:
providers.gemini?.timeout ??
existingConfig.websearch?.providers?.gemini?.timeout ??
55,
},
grok: {
enabled:
providers.grok?.enabled ??
existingConfig.websearch?.providers?.grok?.enabled ??
false,
timeout:
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
},
opencode: {
enabled:
providers.opencode?.enabled ??
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);
const existingConfig = mutateUnifiedConfig((config) => {
config.websearch = {
enabled: enabled ?? config.websearch?.enabled ?? true,
providers: providers
? {
gemini: {
enabled:
providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true,
model:
providers.gemini?.model ??
config.websearch?.providers?.gemini?.model ??
'gemini-2.5-flash',
timeout:
providers.gemini?.timeout ?? config.websearch?.providers?.gemini?.timeout ?? 55,
},
grok: {
enabled:
providers.grok?.enabled ?? config.websearch?.providers?.grok?.enabled ?? false,
timeout:
providers.grok?.timeout ?? config.websearch?.providers?.grok?.timeout ?? 55,
},
opencode: {
enabled:
providers.opencode?.enabled ??
config.websearch?.providers?.opencode?.enabled ??
false,
model:
providers.opencode?.model ??
config.websearch?.providers?.opencode?.model ??
'opencode/grok-code',
timeout:
providers.opencode?.timeout ??
config.websearch?.providers?.opencode?.timeout ??
60,
},
}
: config.websearch?.providers,
};
});
res.json({
success: true,
+13
View File
@@ -10,9 +10,22 @@ import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path';
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
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_DESCRIPTION_LENGTH = 140;
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()}`);
}
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-'));
try {
return await runWithScopedCcsHome(testDir, fn);
return await runWithScopedCcsHome(testDir, () => fn(testDir));
} finally {
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', () => {
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();
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 () => {
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();
return {
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 () => {
const account = await withIsolatedHome(async () => {
const account = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'ghcp-amazon-XYZ789.json');
const { registerAccount } = await loadAccountManager();
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 () => {
const reauthenticated = await withIsolatedHome(async () => {
const reauthenticated = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
const { registerAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work');
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 () => {
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();
registerAccount('kiro', 'kiro-github-ABC123.json');
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 () => {
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();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789');
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 () => {
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();
registerAccount('kiro', 'kiro-github-ABC123.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 * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand';
import { afterEach, describe, expect, it, mock } from 'bun:test';
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 {
const configPath = path.join(tempDir, 'config.yaml');
const yaml = `version: 2
accounts: {}
profiles: {}
preferences:
theme: system
telemetry: false
auto_update: true
cliproxy:
oauth_accounts: {}
providers:
- gemini
- codex
- agy
variants: {}
cliproxy_server:
local:
port: ${localPort}
`;
fs.writeFileSync(configPath, yaml, 'utf8');
function mockUnifiedConfig(config: MockUnifiedConfig): void {
mock.module('../../../src/config/unified-config-loader', () => ({
loadOrCreateUnifiedConfig: () => config,
}));
}
async function loadResolveLifecyclePort() {
const mod = await import(
`../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}`
);
return mod.resolveLifecyclePort;
}
describe('resolveLifecyclePort', () => {
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-'));
});
afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
mock.restore();
});
it('uses configured cliproxy_server.local.port', async () => {
writeUnifiedConfig(9456);
await runWithScopedConfigDir(tempDir, () => {
expect(resolveLifecyclePort()).toBe(9456);
mockUnifiedConfig({
cliproxy_server: {
local: {
port: 9456,
},
},
});
const resolveLifecyclePort = await loadResolveLifecyclePort();
expect(resolveLifecyclePort()).toBe(9456);
});
it('falls back to default port when configured local port is invalid', async () => {
writeUnifiedConfig(70000);
await runWithScopedConfigDir(tempDir, () => {
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
mockUnifiedConfig({
cliproxy_server: {
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 () => {
await runWithScopedConfigDir(tempDir, () => {
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
});
mockUnifiedConfig({});
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', {
nickname: 'work',
kiroMethod: 'google',
});
nickname: 'work',
kiroMethod: 'google',
});
expect(startResponse.status).toBe(200);
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', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-123',
});
redirectUrl: 'http://localhost/callback?code=abc123&state=state-123',
});
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');
});
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({
mutationFn: ({
family,
index,
entryId,
data,
}: {
family: AiProviderFamilyId;
index: number;
entryId: string;
data: UpsertAiProviderEntryInput;
}) => api.cliproxy.aiProviders.update(family, index, data),
}) => api.cliproxy.aiProviders.update(family, entryId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success('Provider entry updated');
@@ -63,8 +63,8 @@ export function useDeleteCliproxyAiProviderEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ family, index }: { family: AiProviderFamilyId; index: number }) =>
api.cliproxy.aiProviders.delete(family, index),
mutationFn: ({ family, entryId }: { family: AiProviderFamilyId; entryId: string }) =>
api.cliproxy.aiProviders.delete(family, entryId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success('Provider entry removed');
+94 -16
View File
@@ -41,6 +41,8 @@ interface StartAuthOptions {
const POLL_INTERVAL = 3000;
/** Maximum polling duration (5 minutes) */
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>> {
const text = await response.text();
@@ -69,18 +71,26 @@ const INITIAL_STATE: AuthFlowState = {
export function useCliproxyAuthFlow() {
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
const attemptIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollStartRef = useRef<number>(0);
const pollFailureCountRef = useRef(0);
const openedAuthUrlRef = useRef(false);
const queryClient = useQueryClient();
const isActiveAttempt = useCallback(
(attemptId: number) => attemptId === attemptIdRef.current,
[]
);
// Clear polling
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
pollFailureCountRef.current = 0;
}, []);
// Cleanup on unmount
@@ -94,15 +104,21 @@ export function useCliproxyAuthFlow() {
// Poll OAuth status
const pollStatus = useCallback(
async (provider: string, oauthState: string) => {
async (provider: string, oauthState: string, attemptId: number) => {
if (!isActiveAttempt(attemptId)) {
return;
}
// Check timeout
if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) {
stopPolling();
setState((prev) => ({
...prev,
isAuthenticating: false,
error: 'Authentication timed out. Please try again.',
}));
if (isActiveAttempt(attemptId)) {
setState((prev) => ({
...prev,
isAuthenticating: false,
error: 'Authentication timed out. Please try again.',
}));
}
return;
}
@@ -110,6 +126,10 @@ export function useCliproxyAuthFlow() {
const response = await fetch(
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
);
if (!isActiveAttempt(attemptId)) {
return;
}
const data = (await response.json()) as {
status?: string;
error?: string;
@@ -118,6 +138,7 @@ export function useCliproxyAuthFlow() {
verification_url?: string;
user_code?: string;
};
pollFailureCountRef.current = 0;
if (data.status === 'ok') {
stopPolling();
@@ -161,11 +182,30 @@ export function useCliproxyAuthFlow() {
}));
}
// status === 'wait' (or pending) means continue polling
} catch {
// Network error - continue polling
} catch (error) {
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(
@@ -182,9 +222,12 @@ export function useCliproxyAuthFlow() {
abortControllerRef.current?.abort();
stopPolling();
openedAuthUrlRef.current = false;
pollFailureCountRef.current = 0;
// Create fresh controller and capture locally to avoid race with cancelAuth
const controller = new AbortController();
const attemptId = attemptIdRef.current + 1;
attemptIdRef.current = attemptId;
abortControllerRef.current = controller;
const flowType =
@@ -219,7 +262,13 @@ export function useCliproxyAuthFlow() {
signal: controller.signal,
})
.then(async (response) => {
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true;
if (response.ok && success) {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
@@ -240,6 +289,9 @@ export function useCliproxyAuthFlow() {
}
})
.catch((error) => {
if (!isActiveAttempt(attemptId)) {
return;
}
if (error instanceof Error && error.name === 'AbortError') {
// Cancelled - state already reset by cancelAuth
return;
@@ -261,8 +313,14 @@ export function useCliproxyAuthFlow() {
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true;
if (!response.ok || !success) {
@@ -290,11 +348,14 @@ export function useCliproxyAuthFlow() {
if (oauthState) {
pollStartRef.current = Date.now();
pollIntervalRef.current = setInterval(() => {
pollStatus(provider, oauthState);
void pollStatus(provider, oauthState, attemptId);
}, POLL_INTERVAL);
}
}
} catch (error) {
if (!isActiveAttempt(attemptId)) {
return;
}
if (error instanceof Error && error.name === 'AbortError') {
openedAuthUrlRef.current = false;
setState(INITIAL_STATE);
@@ -309,11 +370,12 @@ export function useCliproxyAuthFlow() {
}));
}
},
[pollStatus, stopPolling, queryClient]
[isActiveAttempt, pollStatus, stopPolling, queryClient]
);
const cancelAuth = useCallback(() => {
const currentProvider = state.provider;
attemptIdRef.current += 1;
abortControllerRef.current?.abort();
stopPolling();
openedAuthUrlRef.current = false;
@@ -329,37 +391,53 @@ export function useCliproxyAuthFlow() {
const submitCallback = useCallback(
async (redirectUrl: string) => {
if (!state.provider) return;
const attemptId = attemptIdRef.current;
const currentProvider = state.provider;
setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null }));
try {
const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, {
const response = await fetch(`/api/cliproxy/auth/${currentProvider}/submit-callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectUrl }),
});
if (!isActiveAttempt(attemptId)) {
return;
}
const data = await parseResponseBody(response);
if (!isActiveAttempt(attemptId)) {
return;
}
const success = data.success === true;
const hasAccount = typeof data.account === 'object' && data.account !== null;
if (response.ok && success) {
if (response.ok && success && hasAccount) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
toast.success(`${state.provider} authentication successful`);
toast.success(`${currentProvider} authentication successful`);
setState(INITIAL_STATE);
} else {
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);
}
} catch (error) {
if (!isActiveAttempt(attemptId)) {
return;
}
const message = error instanceof Error ? error.message : 'Failed to submit callback';
toast.error(message);
setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message }));
}
},
[state.provider, queryClient, stopPolling]
[isActiveAttempt, state.provider, queryClient, stopPolling]
);
return useMemo(
+15 -9
View File
@@ -854,15 +854,21 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
update: (family: AiProviderFamilyId, index: number, data: UpsertAiProviderEntryInput) =>
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (family: AiProviderFamilyId, index: number) =>
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
method: 'DELETE',
}),
update: (family: AiProviderFamilyId, entryId: string, data: UpsertAiProviderEntryInput) =>
request(
`/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
),
delete: (family: AiProviderFamilyId, entryId: string) =>
request(
`/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
{
method: 'DELETE',
}
),
},
// Config YAML for Config tab
+3 -3
View File
@@ -1759,7 +1759,7 @@ export function CliproxyAiProvidersPage() {
onSave={async (payload) => {
await updateMutation.mutateAsync({
family: selectedFamily,
index: selectedEntry.index,
entryId: selectedEntry.id,
data: payload,
});
void refetch();
@@ -1793,7 +1793,7 @@ export function CliproxyAiProvidersPage() {
if (editingEntry) {
await updateMutation.mutateAsync({
family: selectedFamily,
index: editingEntry.index,
entryId: editingEntry.id,
data: payload,
});
} else {
@@ -1820,7 +1820,7 @@ export function CliproxyAiProvidersPage() {
if (!deleteEntry) return;
await deleteMutation.mutateAsync({
family: selectedFamily,
index: deleteEntry.index,
entryId: deleteEntry.id,
});
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();
});
});