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/`.
> **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.
**Powered by:**
+3
View File
@@ -28,8 +28,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists,
loadAccountsRegistry,
saveAccountsRegistry,
+3
View File
@@ -21,8 +21,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists,
} from './token-file-ops';
+2 -2
View File
@@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
if (exactMatch) return exactMatch;
// Partial match on nickname or email prefix
const partialMatch = accounts.find(
const partialMatches = accounts.filter(
(a) =>
a.nickname?.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,
getPausedDir,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
hasAccountNameConflict,
validateNickname,
moveTokenToPaused,
moveTokenFromPaused,
@@ -21,10 +23,12 @@ import {
} from './token-file-ops';
/** Default registry structure */
const DEFAULT_REGISTRY: AccountsRegistry = {
version: 1,
providers: {},
};
function createDefaultRegistry(): AccountsRegistry {
return {
version: 1,
providers: {},
};
}
/**
* Load accounts registry
@@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) {
return { ...DEFAULT_REGISTRY };
return createDefaultRegistry();
}
try {
@@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
providers: data.providers || {},
};
} catch {
return { ...DEFAULT_REGISTRY };
return createDefaultRegistry();
}
}
@@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
* Called after successful OAuth to record the account
*
* For providers without email (kiro, ghcp):
* - nickname is REQUIRED and used as accountId
* - Uniqueness is enforced to prevent overwriting
* - internal accountId is derived from token metadata
* - nickname is optional metadata
*
* For providers with email:
* - email is used as accountId
@@ -149,23 +153,22 @@ export function registerAccount(
let accountNickname: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
// For kiro/ghcp: nickname is REQUIRED and used as accountId
if (!nickname || nickname === 'default') {
throw new Error(
`Nickname is required when adding ${provider} accounts. ` +
`Use --nickname <name> or provide a nickname in the UI.`
);
}
accountId = email
? extractAccountIdFromTokenFile(tokenFile, email)
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
const existingAccount = providerAccounts.accounts[accountId];
// Validate nickname format
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
if (nickname) {
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
// Check uniqueness
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
if (existingId.toLowerCase() === nickname.toLowerCase()) {
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.`
@@ -173,8 +176,8 @@ export function registerAccount(
}
}
accountId = nickname;
accountNickname = nickname;
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
// For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email);
@@ -184,11 +187,12 @@ export function registerAccount(
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
const existingAccount = providerAccounts.accounts[accountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: accountNickname,
tokenFile,
createdAt: new Date().toISOString(),
createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
@@ -339,11 +343,12 @@ export function renameAccount(
return false;
}
// Check if nickname is already used by another account
for (const [id, account] of Object.entries(providerAccounts.accounts)) {
if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) {
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;
@@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void {
continue;
}
// Determine accountId based on provider type
let accountId: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !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);
}
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]) {
@@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void {
const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: generateNickname(email),
nickname: email ? generateNickname(email) : accountId,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
+86
View File
@@ -5,6 +5,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir, getAuthDir } from '../config-generator';
import type { CLIProxyProvider } from '../types';
import { AccountInfo } from './types';
/**
@@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string):
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
* Takes prefix before @ symbol, sanitizes whitespace
@@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | 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,
getDefaultAccount,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../account-manager';
@@ -88,6 +90,28 @@ export async function requestPasteCallbackStart(
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> {
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
*/
@@ -350,7 +306,8 @@ async function handlePasteCallbackMode(
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string
nickname?: string,
expectedAccountId?: string
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -474,7 +431,13 @@ async function handlePasteCallbackMode(
}
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
if (account?.email) {
@@ -509,7 +472,7 @@ export async function triggerOAuth(
warnOAuthBanRisk(provider);
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
const acceptAgyRisk = options.acceptAgyRisk === true;
let { nickname } = options;
const { nickname } = options;
const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
@@ -533,21 +496,26 @@ export async function triggerOAuth(
// Check for existing accounts
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
if (options.pasteCallback) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
const promptedNickname = await promptNickname(provider, existingAccounts);
if (!promptedNickname) {
console.log(info('Cancelled'));
return null;
}
nickname = promptedNickname;
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// Handle --import flag: skip OAuth and import from Kiro IDE directly
@@ -555,7 +523,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose);
if (success) {
return registerAccountFromToken(provider, tokenDir, nickname);
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
}
return null;
}
@@ -589,12 +557,26 @@ export async function triggerOAuth(
// Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) {
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);
if (mode === 'paste') {
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
}
@@ -678,6 +660,7 @@ export async function triggerOAuth(
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
});
// 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;
isCLI: boolean;
nickname?: string;
expectedAccountId?: string;
}
/** Internal state for OAuth process */
@@ -276,6 +277,7 @@ async function handleTokenNotFound(
callbackPort: number | null,
tokenDir: string,
nickname: string | undefined,
expectedAccountId: string | undefined,
verbose: boolean,
failureReason?: string
): Promise<AccountInfo | null> {
@@ -289,7 +291,7 @@ async function handleTokenNotFound(
if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
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}`));
@@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
headless,
verbose,
nickname,
expectedAccountId,
} = options;
const log = (msg: string) => {
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
}
resolve(registerAccountFromToken(provider, tokenDir, nickname));
resolve(
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
@@ -556,6 +561,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
callbackPort,
tokenDir,
nickname,
expectedAccountId,
verbose,
failureReason || undefined
);
+27 -11
View File
@@ -11,6 +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 {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -202,14 +203,15 @@ export function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string,
nickname?: string,
verbose = false
verbose = false,
expectedAccountId?: string
): import('../account-manager').AccountInfo | null {
const { registerAccount, generateNickname } = require('../account-manager');
const { registerAccount } = require('../account-manager');
let newestFile: string | null = null;
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
@@ -233,19 +235,33 @@ export function registerAccountFromToken(
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const account = registerAccount(
provider,
newestFile,
email,
nickname || generateNickname(email),
projectId
);
const account = registerAccount(provider, newestFile, email, nickname, projectId);
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose);
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;
}
}
+1 -1
View File
@@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy(
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
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);
}
+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)',
],
['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 agy --accept-agr-risk',
+150 -36
View File
@@ -22,6 +22,8 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
@@ -33,7 +35,7 @@ import {
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -53,12 +55,55 @@ import {
import { createRouteErrorHelpers } from './route-helpers';
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
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
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 } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
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
* 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
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
res.status(400).json({
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
code: 'NICKNAME_REQUIRED',
});
return;
}
const validationError = validateNickname(nickname);
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;
}
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;
}
// 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> => {
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);
// Check remote mode
@@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
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 {
const authUrlProvider =
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;
};
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.
if (!authUrl && !data.state) {
if (!authUrl && !oauthState) {
res
.status(500)
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
return;
}
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
expectedAccountId: existingNameMatch?.id,
});
}
res.json({
success: true,
authUrl: authUrl || null,
state: data.state || null,
state: oauthState,
method: data.method || null,
});
} 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
* 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' });
return;
}
const pendingAuth = getManualAuthState(parsed.state);
try {
const callbackProvider =
@@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
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) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}
@@ -41,7 +41,6 @@ import {
DEFAULT_KIRO_AUTH_METHOD,
getKiroAuthMethodOption,
isDeviceCodeProvider,
isNicknameRequiredProvider,
KIRO_AUTH_METHOD_OPTIONS,
} from '@/lib/provider-config';
import type { KiroAuthMethod } from '@/lib/provider-config';
@@ -89,7 +88,6 @@ export function AddAccountDialog({
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
const defaultDeviceCode = isDeviceCodeProvider(provider);
const requiresNickname = isNicknameRequiredProvider(provider);
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
@@ -266,10 +264,6 @@ export function AddAccountDialog({
);
return;
}
if (requiresNickname && !nicknameTrimmed) {
setLocalError(`Nickname is required for ${displayName} accounts.`);
return;
}
setLocalError(null);
wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, {
@@ -394,11 +388,7 @@ export function AddAccountDialog({
{/* Nickname input - only show before auth starts */}
{!showAuthUI && (
<div className="space-y-2">
<Label htmlFor="nickname">
{requiresNickname
? t('addAccountDialog.nicknameRequired')
: t('addAccountDialog.nicknameOptional')}
</Label>
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<Input
@@ -414,9 +404,7 @@ export function AddAccountDialog({
/>
</div>
<p className="text-xs text-muted-foreground">
{requiresNickname
? t('addAccountDialog.nicknameRequiredHint')
: t('addAccountDialog.nicknameOptionalHint')}
{t('addAccountDialog.nicknameOptionalHint')}
</p>
</div>
)}
@@ -554,7 +542,6 @@ export function AddAccountDialog({
disabled={
isPending ||
isAgyBypassStatePending ||
(requiresNickname && !nicknameTrimmed) ||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
}
+4 -4
View File
@@ -473,7 +473,7 @@ const resources = {
nicknameRequiredHint:
'Required for this provider. Use a unique friendly name (e.g., work, personal).',
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...',
deviceCodeHint:
'A verification code dialog will appear shortly. Enter the code on the provider website.',
@@ -1632,7 +1632,7 @@ const resources = {
nicknameOptional: '昵称(选填)',
nicknamePlaceholder: '例如:工作、个人',
nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。',
nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。',
nicknameOptionalHint: '用于区分账号的友好名称。留空将自动生成安全标识。',
waitingForAuth: '等待认证中...',
deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。',
browserHint: '在浏览器中完成认证后,本对话框将自动关闭。',
@@ -2804,7 +2804,7 @@ const resources = {
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).',
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...',
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.',
@@ -4004,7 +4004,7 @@ const resources = {
nicknameRequiredHint:
'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。',
nicknameOptionalHint:
'このアカウントを識別しやすい名前です。空欄ならメールアドレスから自動生成されます。',
'このアカウントを識別しやすい名前です。空欄の場合は安全な識別子を自動生成ます。',
waitingForAuth: '認証を待機中...',
deviceCodeHint:
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
-9
View File
@@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
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). */
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];