feat(cliproxy): allow optional provider nicknames

- stop requiring manual unique nicknames for no-email providers by default

- preserve explicit nickname validation and same-account reauth semantics

- persist optional nicknames through dashboard manual callback flows
This commit is contained in:
Tam Nhu Tran
2026-03-23 11:02:08 -04:00
parent 185f7f469e
commit bdb7ac2937
15 changed files with 398 additions and 220 deletions
+2
View File
@@ -156,6 +156,8 @@ The dashboard provides visual management for all account types:
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later.
> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. > **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles.
**Powered by:** **Powered by:**
+3
View File
@@ -28,8 +28,11 @@ export {
getPausedDir, getPausedDir,
getAccountTokenPath, getAccountTokenPath,
extractAccountIdFromTokenFile, extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname, generateNickname,
validateNickname, validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists, tokenFileExists,
loadAccountsRegistry, loadAccountsRegistry,
saveAccountsRegistry, saveAccountsRegistry,
+3
View File
@@ -21,8 +21,11 @@ export {
getPausedDir, getPausedDir,
getAccountTokenPath, getAccountTokenPath,
extractAccountIdFromTokenFile, extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname, generateNickname,
validateNickname, validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists, tokenFileExists,
} from './token-file-ops'; } from './token-file-ops';
+2 -2
View File
@@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
if (exactMatch) return exactMatch; if (exactMatch) return exactMatch;
// Partial match on nickname or email prefix // Partial match on nickname or email prefix
const partialMatch = accounts.find( const partialMatches = accounts.filter(
(a) => (a) =>
a.nickname?.toLowerCase().startsWith(lowerQuery) || a.nickname?.toLowerCase().startsWith(lowerQuery) ||
a.email?.toLowerCase().startsWith(lowerQuery) a.email?.toLowerCase().startsWith(lowerQuery)
); );
return partialMatch || null; return partialMatches.length === 1 ? partialMatches[0] : null;
} }
/** /**
+41 -54
View File
@@ -13,7 +13,9 @@ import {
getAccountsRegistryPath, getAccountsRegistryPath,
getPausedDir, getPausedDir,
extractAccountIdFromTokenFile, extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname, generateNickname,
hasAccountNameConflict,
validateNickname, validateNickname,
moveTokenToPaused, moveTokenToPaused,
moveTokenFromPaused, moveTokenFromPaused,
@@ -21,10 +23,12 @@ import {
} from './token-file-ops'; } from './token-file-ops';
/** Default registry structure */ /** Default registry structure */
const DEFAULT_REGISTRY: AccountsRegistry = { function createDefaultRegistry(): AccountsRegistry {
version: 1, return {
providers: {}, version: 1,
}; providers: {},
};
}
/** /**
* Load accounts registry * Load accounts registry
@@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
const registryPath = getAccountsRegistryPath(); const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) { if (!fs.existsSync(registryPath)) {
return { ...DEFAULT_REGISTRY }; return createDefaultRegistry();
} }
try { try {
@@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
providers: data.providers || {}, providers: data.providers || {},
}; };
} catch { } catch {
return { ...DEFAULT_REGISTRY }; return createDefaultRegistry();
} }
} }
@@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
* Called after successful OAuth to record the account * Called after successful OAuth to record the account
* *
* For providers without email (kiro, ghcp): * For providers without email (kiro, ghcp):
* - nickname is REQUIRED and used as accountId * - internal accountId is derived from token metadata
* - Uniqueness is enforced to prevent overwriting * - nickname is optional metadata
* *
* For providers with email: * For providers with email:
* - email is used as accountId * - email is used as accountId
@@ -149,23 +153,22 @@ export function registerAccount(
let accountNickname: string; let accountNickname: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
// For kiro/ghcp: nickname is REQUIRED and used as accountId accountId = email
if (!nickname || nickname === 'default') { ? extractAccountIdFromTokenFile(tokenFile, email)
throw new Error( : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
`Nickname is required when adding ${provider} accounts. ` + const existingAccount = providerAccounts.accounts[accountId];
`Use --nickname <name> or provide a nickname in the UI.`
);
}
// Validate nickname format if (nickname) {
const validationError = validateNickname(nickname); const validationError = validateNickname(nickname);
if (validationError) { if (validationError) {
throw new Error(validationError); throw new Error(validationError);
} }
// Check uniqueness const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { id,
if (existingId.toLowerCase() === nickname.toLowerCase()) { nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error( throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` + `An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.` `Choose a different nickname.`
@@ -173,8 +176,8 @@ export function registerAccount(
} }
} }
accountId = nickname; accountNickname =
accountNickname = nickname; nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else { } else {
// For other providers: use email as accountId, fallback to filename extraction // For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email); accountId = extractAccountIdFromTokenFile(tokenFile, email);
@@ -184,11 +187,12 @@ export function registerAccount(
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account // Create or update account
const existingAccount = providerAccounts.accounts[accountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = { const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email, email,
nickname: accountNickname, nickname: accountNickname,
tokenFile, tokenFile,
createdAt: new Date().toISOString(), createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(),
}; };
@@ -339,11 +343,12 @@ export function renameAccount(
return false; return false;
} }
// Check if nickname is already used by another account const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
for (const [id, account] of Object.entries(providerAccounts.accounts)) { id,
if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { nickname: account.nickname,
throw new Error(`Nickname "${newNickname}" is already used by another account`); }));
} if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
throw new Error(`Nickname "${newNickname}" is already used by another account`);
} }
providerAccounts.accounts[accountId].nickname = newNickname; providerAccounts.accounts[accountId].nickname = newNickname;
@@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void {
continue; continue;
} }
// Determine accountId based on provider type const accountId =
let accountId: string; PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { : extractAccountIdFromTokenFile(file, email);
// For kiro/ghcp without email: extract from filename or generate unique
// Pattern: kiro-github-ABC123.json -> github-ABC123
const filenameId = extractAccountIdFromTokenFile(file, undefined);
if (filenameId !== 'default') {
accountId = filenameId;
} else {
// Generate unique ID: provider + incrementing index
let index = 1;
while (providerAccounts.accounts[`${provider}-${index}`]) {
index++;
}
accountId = `${provider}-${index}`;
}
} else {
// For providers with email: use email or filename extraction
accountId = extractAccountIdFromTokenFile(file, email);
}
// Skip if account already registered // Skip if account already registered
if (providerAccounts.accounts[accountId]) { if (providerAccounts.accounts[accountId]) {
@@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void {
const lastModified = stats.mtime || stats.birthtime || new Date(); const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = { const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email, email,
nickname: generateNickname(email), nickname: email ? generateNickname(email) : accountId,
tokenFile: file, tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(), lastUsedAt: lastModified.toISOString(),
+86
View File
@@ -5,6 +5,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getCliproxyDir, getAuthDir } from '../config-generator'; import { getCliproxyDir, getAuthDir } from '../config-generator';
import type { CLIProxyProvider } from '../types';
import { AccountInfo } from './types'; import { AccountInfo } from './types';
/** /**
@@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string):
return 'default'; return 'default';
} }
/**
* Derive a collision-safe internal account ID for providers that may not expose email.
* Reuses the existing entry when the token file is already known, otherwise prefers the
* filename-derived ID before falling back to provider-scoped sequential IDs.
*/
export function deriveNoEmailProviderAccountId(
provider: CLIProxyProvider,
tokenFile: string,
existingAccounts: Record<string, Pick<AccountInfo, 'tokenFile' | 'nickname'>>
): string {
const existingEntries = Object.entries(existingAccounts);
const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile);
if (existingEntry) {
return existingEntry[0];
}
const extractedId = extractAccountIdFromTokenFile(tokenFile);
const lowerExtractedId = extractedId.toLowerCase();
if (
extractedId !== 'default' &&
!existingEntries.some(
([existingId, account]) =>
existingId.toLowerCase() === lowerExtractedId ||
account.nickname?.toLowerCase() === lowerExtractedId
)
) {
return extractedId;
}
let index = 1;
while (
existingEntries.some(
([existingId, account]) =>
existingId.toLowerCase() === `${provider}-${index}` ||
account.nickname?.toLowerCase() === `${provider}-${index}`
)
) {
index++;
}
return `${provider}-${index}`;
}
/** /**
* Generate nickname from email * Generate nickname from email
* Takes prefix before @ symbol, sanitizes whitespace * Takes prefix before @ symbol, sanitizes whitespace
@@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | null {
} }
return null; return null;
} }
/**
* Check whether a nickname would collide with any existing account ID or nickname.
*/
export function hasAccountNameConflict(
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
candidateName: string,
excludeAccountId?: string
): boolean {
const normalizedCandidate = candidateName.toLowerCase();
const normalizedExcludedId = excludeAccountId?.toLowerCase();
return accounts.some((account) => {
if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) {
return false;
}
return (
account.id.toLowerCase() === normalizedCandidate ||
account.nickname?.toLowerCase() === normalizedCandidate
);
});
}
/**
* Find the existing account that already owns the supplied id/nickname.
*/
export function findAccountNameMatch(
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
candidateName: string
): Pick<AccountInfo, 'id' | 'nickname'> | null {
const normalizedCandidate = candidateName.toLowerCase();
return (
accounts.find(
(account) =>
account.id.toLowerCase() === normalizedCandidate ||
account.nickname?.toLowerCase() === normalizedCandidate
) || null
);
}
+68 -85
View File
@@ -20,6 +20,8 @@ import {
getProviderAccounts, getProviderAccounts,
getDefaultAccount, getDefaultAccount,
touchAccount, touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL, PROVIDERS_WITHOUT_EMAIL,
validateNickname, validateNickname,
} from '../account-manager'; } from '../account-manager';
@@ -88,6 +90,28 @@ export async function requestPasteCallbackStart(
return (await response.json()) as PasteCallbackStartData; return (await response.json()) as PasteCallbackStartData;
} }
export function getCliAuthNicknameError(
provider: CLIProxyProvider,
nickname: string | undefined,
existingAccounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
allowExistingAccountId?: string
): string | null {
if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
return null;
}
const validationError = validateNickname(nickname);
if (validationError) {
return validationError;
}
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
return `Nickname "${nickname}" is already in use. Choose a different one.`;
}
return null;
}
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past
}); });
} }
/**
* Prompt user for account nickname (required for kiro/ghcp)
* Returns null if user cancels
*/
async function promptNickname(
provider: CLIProxyProvider,
existingAccounts: AccountInfo[]
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
console.log('');
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
if (existingNicknames.length > 0) {
console.log(` Existing: ${existingNicknames.join(', ')}`);
}
return new Promise<string | null>((resolve) => {
let resolved = false;
// Handle Ctrl+C gracefully (only if not already resolved)
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
const askForNickname = () => {
rl.question('[?] Enter a nickname for this account: ', (answer) => {
const nickname = answer.trim();
if (!nickname) {
console.log(fail('Nickname cannot be empty'));
askForNickname();
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
console.log(fail(validationError));
askForNickname();
return;
}
if (existingNicknames.includes(nickname.toLowerCase())) {
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
askForNickname();
return;
}
resolved = true;
rl.close();
resolve(nickname);
});
};
askForNickname();
});
}
/** /**
* Run pre-flight OAuth checks * Run pre-flight OAuth checks
*/ */
@@ -350,7 +306,8 @@ async function handlePasteCallbackMode(
oauthConfig: ProviderOAuthConfig, oauthConfig: ProviderOAuthConfig,
verbose: boolean, verbose: boolean,
tokenDir: string, tokenDir: string,
nickname?: string nickname?: string,
expectedAccountId?: string
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config) // Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget(); const target = getProxyTarget();
@@ -474,7 +431,13 @@ async function handlePasteCallbackMode(
} }
console.log(ok('Authentication successful!')); console.log(ok('Authentication successful!'));
const account = registerAccountFromToken(provider, tokenDir, nickname); const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId
);
// Account safety: check for cross-provider conflicts // Account safety: check for cross-provider conflicts
if (account?.email) { if (account?.email) {
@@ -509,7 +472,7 @@ export async function triggerOAuth(
warnOAuthBanRisk(provider); warnOAuthBanRisk(provider);
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
const acceptAgyRisk = options.acceptAgyRisk === true; const acceptAgyRisk = options.acceptAgyRisk === true;
let { nickname } = options; const { nickname } = options;
const resolvedKiroMethod = const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
@@ -533,21 +496,26 @@ export async function triggerOAuth(
// Check for existing accounts // Check for existing accounts
const existingAccounts = getProviderAccounts(provider); const existingAccounts = getProviderAccounts(provider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = !fromUI
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
: null;
if (nicknameError) {
console.log(fail(nicknameError));
return null;
}
// Handle paste-callback mode // Handle paste-callback mode
if (options.pasteCallback) { if (options.pasteCallback) {
const tokenDir = getProviderTokenDir(provider); const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); return handlePasteCallbackMode(
} provider,
oauthConfig,
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI) verbose,
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) { tokenDir,
const promptedNickname = await promptNickname(provider, existingAccounts); nickname,
if (!promptedNickname) { existingNameMatch?.id
console.log(info('Cancelled')); );
return null;
}
nickname = promptedNickname;
} }
// Handle --import flag: skip OAuth and import from Kiro IDE directly // Handle --import flag: skip OAuth and import from Kiro IDE directly
@@ -555,7 +523,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider); const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose); const success = await importKiroToken(verbose);
if (success) { if (success) {
return registerAccountFromToken(provider, tokenDir, nickname); return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
} }
return null; return null;
} }
@@ -589,12 +557,26 @@ export async function triggerOAuth(
// Non-interactive environment (piped input) - default to paste mode // Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider); const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
} }
const mode = await promptOAuthModeChoice(callbackPort); const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') { if (mode === 'paste') {
const tokenDir = getProviderTokenDir(provider); const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
} }
// mode === 'forward' continues to existing port-forwarding flow below // mode === 'forward' continues to existing port-forwarding flow below
} }
@@ -678,6 +660,7 @@ export async function triggerOAuth(
verbose, verbose,
isCLI, isCLI,
nickname, nickname,
expectedAccountId: existingNameMatch?.id,
}); });
// Show hint for Kiro users about --no-incognito option (first-time auth only) // Show hint for Kiro users about --no-incognito option (first-time auth only)
+8 -2
View File
@@ -50,6 +50,7 @@ export interface OAuthProcessOptions {
verbose: boolean; verbose: boolean;
isCLI: boolean; isCLI: boolean;
nickname?: string; nickname?: string;
expectedAccountId?: string;
} }
/** Internal state for OAuth process */ /** Internal state for OAuth process */
@@ -276,6 +277,7 @@ async function handleTokenNotFound(
callbackPort: number | null, callbackPort: number | null,
tokenDir: string, tokenDir: string,
nickname: string | undefined, nickname: string | undefined,
expectedAccountId: string | undefined,
verbose: boolean, verbose: boolean,
failureReason?: string failureReason?: string
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
@@ -289,7 +291,7 @@ async function handleTokenNotFound(
if (result.success) { if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
return registerAccountFromToken(provider, tokenDir, nickname); return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId);
} }
console.log(fail(`Auto-import failed: ${result.error}`)); console.log(fail(`Auto-import failed: ${result.error}`));
@@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
headless, headless,
verbose, verbose,
nickname, nickname,
expectedAccountId,
} = options; } = options;
const log = (msg: string) => { const log = (msg: string) => {
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
deviceCodeEvents.emit('deviceCode:completed', state.sessionId); deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
} }
resolve(registerAccountFromToken(provider, tokenDir, nickname)); resolve(
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else { } else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData); const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
@@ -556,6 +561,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
callbackPort, callbackPort,
tokenDir, tokenDir,
nickname, nickname,
expectedAccountId,
verbose, verbose,
failureReason || undefined failureReason || undefined
); );
+27 -11
View File
@@ -11,6 +11,7 @@ import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator'; import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile } from '../accounts/token-file-ops';
import { import {
AuthStatus, AuthStatus,
PROVIDER_AUTH_PREFIXES, PROVIDER_AUTH_PREFIXES,
@@ -202,14 +203,15 @@ export function registerAccountFromToken(
provider: CLIProxyProvider, provider: CLIProxyProvider,
tokenDir: string, tokenDir: string,
nickname?: string, nickname?: string,
verbose = false verbose = false,
expectedAccountId?: string
): import('../account-manager').AccountInfo | null { ): import('../account-manager').AccountInfo | null {
const { registerAccount, generateNickname } = require('../account-manager'); const { registerAccount } = require('../account-manager');
let newestFile: string | null = null;
try { try {
const files = fs.readdirSync(tokenDir); const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json')); const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
let newestFile: string | null = null;
let newestMtime = 0; let newestMtime = 0;
for (const file of jsonFiles) { for (const file of jsonFiles) {
@@ -233,19 +235,33 @@ export function registerAccountFromToken(
const email = data.email || undefined; const email = data.email || undefined;
const projectId = data.project_id || undefined; const projectId = data.project_id || undefined;
const account = registerAccount( const account = registerAccount(provider, newestFile, email, nickname, projectId);
provider,
newestFile,
email,
nickname || generateNickname(email),
projectId
);
// Upload token to remote server if configured (async, don't block) // Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose); uploadTokenToRemoteAsync(tokenPath, verbose);
return account; return account;
} catch { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (verbose) {
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 (expectedAccountId && verbose) {
console.error(
`[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token`
);
}
return null; return null;
} }
} }
+1 -1
View File
@@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy(
const nickname = acct.nickname ? `[${acct.nickname}]` : ''; const nickname = acct.nickname ? `[${acct.nickname}]` : '';
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
} }
console.log(`\n Use "ccs ${provider} --use <nickname>" to switch accounts`); console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
} }
process.exit(0); process.exit(0);
} }
+1 -1
View File
@@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'Show auth URL and prompt for callback paste (cross-browser)', 'Show auth URL and prompt for callback paste (cross-browser)',
], ],
['ccs <provider> --accounts', 'List all accounts'], ['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'], ['ccs <provider> --use <nickname-or-id>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'], ['ccs <provider> --config', 'Change model (agy, gemini)'],
[ [
'ccs agy --accept-agr-risk', 'ccs agy --accept-agr-risk',
+150 -36
View File
@@ -22,6 +22,8 @@ import {
pauseAccount as pauseAccountFn, pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn, resumeAccount as resumeAccountFn,
touchAccount, touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL, PROVIDERS_WITHOUT_EMAIL,
validateNickname, validateNickname,
} from '../../cliproxy/account-manager'; } from '../../cliproxy/account-manager';
@@ -33,7 +35,7 @@ import {
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
import { import {
CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -53,12 +55,55 @@ import {
import { createRouteErrorHelpers } from './route-helpers'; import { createRouteErrorHelpers } from './route-helpers';
const router = Router(); const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const pendingManualAuthState = new Map<
string,
{ nickname?: string; expectedAccountId?: string; createdAt: number }
>();
// Valid providers list - derived from canonical CLIPROXY_PROFILES // Valid providers list - derived from canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
function pruneExpiredManualAuthState(now = Date.now()): void {
for (const [state, pending] of pendingManualAuthState.entries()) {
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
pendingManualAuthState.delete(state);
}
}
}
function rememberManualAuthState(
state: string,
pending: { nickname?: string; expectedAccountId?: string }
): void {
pruneExpiredManualAuthState();
pendingManualAuthState.set(state, {
...pending,
createdAt: Date.now(),
});
}
function getManualAuthState(
state: string | undefined
): { nickname?: string; expectedAccountId?: string } | null {
if (!state) {
return null;
}
pruneExpiredManualAuthState();
const pending = pendingManualAuthState.get(state);
if (!pending) {
return null;
}
return {
nickname: pending.nickname,
expectedAccountId: pending.expectedAccountId,
};
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) { if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false }; return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled'; return 'Authentication failed or was cancelled';
} }
export function getStartAuthNicknameError(
provider: CLIProxyProvider,
nickname: string | undefined,
existingAccounts: Array<{ id: string; nickname?: string }>,
allowExistingAccountId?: string
): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null {
if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) {
return null;
}
const validationError = validateNickname(nickname);
if (validationError) {
return {
error: validationError,
code: 'INVALID_NICKNAME',
};
}
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
return {
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
};
}
return null;
}
/** /**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
} }
} }
// For kiro/ghcp: nickname is required const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) { const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
if (!nickname) { const nicknameError = getStartAuthNicknameError(
res.status(400).json({ provider as CLIProxyProvider,
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`, nickname,
code: 'NICKNAME_REQUIRED', existingAccounts,
}); existingNameMatch?.id
return; );
} if (nicknameError) {
res.status(400).json(nicknameError);
const validationError = validateNickname(nickname); return;
if (validationError) {
res.status(400).json({
error: validationError,
code: 'INVALID_NICKNAME',
});
return;
}
// Check uniqueness
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
if (existingNicknames.includes(nickname.toLowerCase())) {
res.status(400).json({
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
});
return;
}
} }
// Check Kiro no-incognito setting from config (or request body) // Check Kiro no-incognito setting from config (or request body)
@@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/ */
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => { router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params; const { provider } = req.params;
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {}; const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
// Check remote mode // Check remote mode
@@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return; return;
} }
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
);
if (nicknameError) {
res.status(400).json(nicknameError);
return;
}
try { try {
const authUrlProvider = const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
@@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
method?: string; method?: string;
}; };
const authUrl = data.url || data.auth_url; const authUrl = data.url || data.auth_url;
const oauthState = data.state || parseAuthUrlState(authUrl);
// Some upstream flows return state first and provide auth_url in subsequent status polling. // Some upstream flows return state first and provide auth_url in subsequent status polling.
if (!authUrl && !data.state) { if (!authUrl && !oauthState) {
res res
.status(500) .status(500)
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' }); .json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
return; return;
} }
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
expectedAccountId: existingNameMatch?.id,
});
}
res.json({ res.json({
success: true, success: true,
authUrl: authUrl || null, authUrl: authUrl || null,
state: data.state || null, state: oauthState,
method: data.method || null, method: data.method || null,
}); });
} catch (error) { } catch (error) {
@@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } {
} }
} }
function parseAuthUrlState(url: string | null | undefined): string | null {
if (!url) {
return null;
}
try {
return new URL(url).searchParams.get('state');
} catch {
return null;
}
}
/** /**
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually * POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
* For cross-browser OAuth flows where callback cannot redirect directly * For cross-browser OAuth flows where callback cannot redirect directly
@@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' }); res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
return; return;
} }
const pendingAuth = getManualAuthState(parsed.state);
try { try {
const callbackProvider = const callbackProvider =
@@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return; return;
} }
res.json({ success: true }); const account = registerAccountFromToken(
provider as CLIProxyProvider,
getProviderTokenDir(provider as CLIProxyProvider),
pendingAuth?.nickname,
false,
pendingAuth?.expectedAccountId
);
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.json({
success: true,
account: account
? {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
}
: null,
});
} catch (error) { } catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
} }
@@ -41,7 +41,6 @@ import {
DEFAULT_KIRO_AUTH_METHOD, DEFAULT_KIRO_AUTH_METHOD,
getKiroAuthMethodOption, getKiroAuthMethodOption,
isDeviceCodeProvider, isDeviceCodeProvider,
isNicknameRequiredProvider,
KIRO_AUTH_METHOD_OPTIONS, KIRO_AUTH_METHOD_OPTIONS,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
import type { KiroAuthMethod } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config';
@@ -89,7 +88,6 @@ export function AddAccountDialog({
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
const defaultDeviceCode = isDeviceCodeProvider(provider); const defaultDeviceCode = isDeviceCodeProvider(provider);
const requiresNickname = isNicknameRequiredProvider(provider);
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
@@ -266,10 +264,6 @@ export function AddAccountDialog({
); );
return; return;
} }
if (requiresNickname && !nicknameTrimmed) {
setLocalError(`Nickname is required for ${displayName} accounts.`);
return;
}
setLocalError(null); setLocalError(null);
wasAuthenticatingRef.current = true; wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, { authFlow.startAuth(provider, {
@@ -394,11 +388,7 @@ export function AddAccountDialog({
{/* Nickname input - only show before auth starts */} {/* Nickname input - only show before auth starts */}
{!showAuthUI && ( {!showAuthUI && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nickname"> <Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
{requiresNickname
? t('addAccountDialog.nicknameRequired')
: t('addAccountDialog.nicknameOptional')}
</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -414,9 +404,7 @@ export function AddAccountDialog({
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{requiresNickname {t('addAccountDialog.nicknameOptionalHint')}
? t('addAccountDialog.nicknameRequiredHint')
: t('addAccountDialog.nicknameOptionalHint')}
</p> </p>
</div> </div>
)} )}
@@ -554,7 +542,6 @@ export function AddAccountDialog({
disabled={ disabled={
isPending || isPending ||
isAgyBypassStatePending || isAgyBypassStatePending ||
(requiresNickname && !nicknameTrimmed) ||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
} }
+4 -4
View File
@@ -473,7 +473,7 @@ const resources = {
nicknameRequiredHint: nicknameRequiredHint:
'Required for this provider. Use a unique friendly name (e.g., work, personal).', 'Required for this provider. Use a unique friendly name (e.g., work, personal).',
nicknameOptionalHint: nicknameOptionalHint:
'A friendly name to identify this account. Auto-generated from email if left empty.', 'A friendly name to identify this account. Leave blank to use a safe generated identifier.',
waitingForAuth: 'Waiting for authentication...', waitingForAuth: 'Waiting for authentication...',
deviceCodeHint: deviceCodeHint:
'A verification code dialog will appear shortly. Enter the code on the provider website.', 'A verification code dialog will appear shortly. Enter the code on the provider website.',
@@ -1632,7 +1632,7 @@ const resources = {
nicknameOptional: '昵称(选填)', nicknameOptional: '昵称(选填)',
nicknamePlaceholder: '例如:工作、个人', nicknamePlaceholder: '例如:工作、个人',
nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。', nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。',
nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。', nicknameOptionalHint: '用于区分账号的友好名称。留空将自动生成安全标识。',
waitingForAuth: '等待认证中...', waitingForAuth: '等待认证中...',
deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。', deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。',
browserHint: '在浏览器中完成认证后,本对话框将自动关闭。', browserHint: '在浏览器中完成认证后,本对话框将自动关闭。',
@@ -2804,7 +2804,7 @@ const resources = {
nicknameRequiredHint: nicknameRequiredHint:
'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).', 'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).',
nicknameOptionalHint: nicknameOptionalHint:
'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.', 'Tên thân thiện để nhận biết tài khoản này. Để trống để dùng mã nhận dạng an toàn do hệ thống tạo.',
waitingForAuth: 'Đang chờ xác thực...', waitingForAuth: 'Đang chờ xác thực...',
deviceCodeHint: deviceCodeHint:
'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.', 'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.',
@@ -4004,7 +4004,7 @@ const resources = {
nicknameRequiredHint: nicknameRequiredHint:
'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。', 'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。',
nicknameOptionalHint: nicknameOptionalHint:
'このアカウントを識別しやすい名前です。空欄ならメールアドレスから自動生成されます。', 'このアカウントを識別しやすい名前です。空欄の場合は安全な識別子を自動生成ます。',
waitingForAuth: '認証を待機中...', waitingForAuth: '認証を待機中...',
deviceCodeHint: deviceCodeHint:
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。', '確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
-9
View File
@@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
return 'Complete the authorization in your browser.'; return 'Complete the authorization in your browser.';
} }
/** Providers that require nickname because token payload may not include email. */
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
/** Check if provider requires user-supplied nickname in auth flow */
export function isNicknameRequiredProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
}
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];