From d96c67ba810fb933f4a26bf43b6c011e44ed5d47 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:01:38 -0500 Subject: [PATCH 1/6] fix(cliproxy): use nickname as accountId for kiro/ghcp providers Kiro/GHCP OAuth tokens have empty email field, causing all accounts to use accountId='default' and overwrite each other. This fix: - Add PROVIDERS_WITHOUT_EMAIL constant for kiro/ghcp identification - Require nickname for kiro/ghcp during registration (CLI + web UI) - Use nickname as accountId instead of email for these providers - Enforce nickname uniqueness to prevent collisions - Update discoverExistingAccounts() to generate unique IDs (kiro-1, etc.) Closes #258, #267 --- src/cliproxy/account-manager.ts | 99 +++++++++++++++++-- src/cliproxy/auth/oauth-handler.ts | 80 ++++++++++++++- src/web-server/routes/cliproxy-auth-routes.ts | 35 +++++++ .../cliproxy/account-manager-discover.test.js | 71 ++++++++++++- 4 files changed, 271 insertions(+), 14 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index a65947c9..2fced28c 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -14,6 +14,12 @@ import { CLIProxyProvider } from './types'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; +/** + * Providers that typically have empty email in OAuth token files. + * For these providers, nickname is used as accountId instead of email. + */ +export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; + /** Account information */ export interface AccountInfo { /** Account identifier (email or custom name) */ @@ -257,6 +263,14 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A /** * Register a new account * 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 + * + * For providers with email: + * - email is used as accountId + * - nickname is auto-generated from email if not provided */ export function registerAccount( provider: CLIProxyProvider, @@ -279,12 +293,44 @@ export function registerAccount( throw new Error('Failed to initialize provider accounts'); } - // Determine account ID - use email if available, otherwise extract from filename - const accountId = extractAccountIdFromTokenFile(tokenFile, email); - const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + // Determine account ID based on provider type + let accountId: string; + let accountNickname: string; - // Generate nickname if not provided - const accountNickname = nickname || generateNickname(email); + 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 or provide a nickname in the UI.` + ); + } + + // Validate nickname format + 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()) { + throw new Error( + `An account with nickname "${nickname}" already exists for ${provider}. ` + + `Choose a different nickname.` + ); + } + } + + accountId = nickname; + accountNickname = nickname; + } else { + // For other providers: use email as accountId, fallback to filename extraction + accountId = extractAccountIdFromTokenFile(tokenFile, email); + accountNickname = nickname || generateNickname(email); + } + + const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Create or update account providerAccounts.accounts[accountId] = { @@ -428,6 +474,10 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri /** * Auto-discover accounts from existing token files * Called during migration or first run to populate accounts registry + * + * For kiro/ghcp providers without email, generates unique accountId from: + * 1. OAuth provider + profile ID from filename (e.g., github-ABC123) + * 2. Fallback: provider + index (e.g., kiro-1, kiro-2) */ export function discoverExistingAccounts(): void { const authDir = getAuthDir(); @@ -478,13 +528,10 @@ export function discoverExistingAccounts(): void { } } - // Use unified ID extraction: email or filename-based unique ID - const accountId = extractAccountIdFromTokenFile(file, email); - // Initialize provider section if needed if (!registry.providers[provider]) { registry.providers[provider] = { - default: accountId, + default: 'default', accounts: {}, }; } @@ -492,11 +539,45 @@ export function discoverExistingAccounts(): void { 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)) { + 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); + } + // 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); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 34ecabbc..fea5b39f 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -20,6 +20,8 @@ import { getProviderAccounts, getDefaultAccount, touchAccount, + PROVIDERS_WITHOUT_EMAIL, + validateNickname, } from '../account-manager'; import { enhancedPreflightOAuthCheck, @@ -49,6 +51,66 @@ async function promptAddAccount(): Promise { }); } +/** + * Prompt user for account nickname (required for kiro/ghcp) + * Returns null if user cancels + */ +async function promptNickname( + provider: CLIProxyProvider, + existingAccounts: AccountInfo[] +): Promise { + 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((resolve) => { + // Handle Ctrl+C gracefully + rl.on('close', () => 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; + } + + rl.close(); + resolve(nickname); + }); + }; + + askForNickname(); + }); +} + /** * Run pre-flight OAuth checks */ @@ -127,7 +189,21 @@ export async function triggerOAuth( options: OAuthOptions = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options; + const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; + let { nickname } = options; + + // Check for existing accounts + const existingAccounts = getProviderAccounts(provider); + + // 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; + } // Handle --import flag: skip OAuth and import from Kiro IDE directly if (options.import && provider === 'kiro') { @@ -144,8 +220,6 @@ export async function triggerOAuth( const headless = options.headless ?? isHeadlessEnvironment(); const isDeviceCodeFlow = callbackPort === null; - // Check for existing accounts - const existingAccounts = getProviderAccounts(provider); if (existingAccounts.length > 0 && !add) { console.log(''); console.log( diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index baa5d167..306b30ac 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -24,6 +24,8 @@ import { setDefaultAccount as setDefaultAccountFn, removeAccount as removeAccountFn, touchAccount, + PROVIDERS_WITHOUT_EMAIL, + validateNickname, } from '../../cliproxy/account-manager'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; @@ -283,6 +285,39 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise 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) // Default to true (use normal browser) for reliability - incognito often fails let noIncognito = true; diff --git a/tests/unit/cliproxy/account-manager-discover.test.js b/tests/unit/cliproxy/account-manager-discover.test.js index ae3cf72a..3f48f1e1 100644 --- a/tests/unit/cliproxy/account-manager-discover.test.js +++ b/tests/unit/cliproxy/account-manager-discover.test.js @@ -164,7 +164,7 @@ describe('Account Manager - discoverExistingAccounts', () => { assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available'); }); - it('falls back to "default" when no email in file or filename', () => { + it('generates unique ID for kiro/ghcp when no email in file or filename', () => { createAuthFile('kiro-nomail.json', { type: 'kiro', email: '', @@ -174,7 +174,8 @@ describe('Account Manager - discoverExistingAccounts', () => { const accounts = getAccountsFile(); const accountIds = Object.keys(accounts.providers.kiro.accounts); - assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"'); + // For kiro/ghcp without email, generates unique ID like "kiro-1" + assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email'); }); it('handles dots in email local part', () => { @@ -274,6 +275,72 @@ describe('Account Manager - discoverExistingAccounts', () => { 'First account should be default' ); }); + + it('generates sequential IDs for multiple kiro files without email', () => { + // Create multiple kiro files without email in filename + // Files like "kiro-nomail.json" don't match oauth pattern (need 2+ hyphens) + createAuthFile('kiro-account1.json', { + type: 'kiro', + email: '', + }); + createAuthFile('kiro-account2.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts'); + // Both should have kiro-N format since filenames don't match oauth pattern (1 hyphen only) + assert(accountIds.includes('kiro-1'), 'Should have kiro-1'); + assert(accountIds.includes('kiro-2'), 'Should have kiro-2'); + }); + + it('skips to next ID when collision exists', () => { + // Pre-create accounts.json with kiro-1 already registered + const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json'); + const existingRegistry = { + version: 1, + providers: { + kiro: { + default: 'kiro-1', + accounts: { + 'kiro-1': { + tokenFile: 'kiro-existing.json', + createdAt: new Date().toISOString(), + }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(existingRegistry)); + + // Create the existing token file (matches registry) + createAuthFile('kiro-existing.json', { + type: 'kiro', + email: '', + }); + + // Create new file that will need auto-generated ID (single hyphen = no oauth pattern match) + createAuthFile('kiro-newaccount.json', { + type: 'kiro', + email: '', + }); + + // Reload module to pick up pre-existing accounts.json + delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')]; + accountManager = require('../../../dist/cliproxy/account-manager'); + accountManager.discoverExistingAccounts(); + + const accounts = getAccountsFile(); + const accountIds = Object.keys(accounts.providers.kiro.accounts); + + assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts'); + assert(accountIds.includes('kiro-1'), 'Should keep existing kiro-1'); + assert(accountIds.includes('kiro-2'), 'New account should be kiro-2 (skipped kiro-1)'); + }); }); // ========================================================================= From b55cd795ab5da18ea5363aa378712b467a17bf22 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 12:17:40 -0500 Subject: [PATCH 2/6] fix(cliproxy): update lastUsedAt on normal execution Previously touchAccount was only called when switching accounts or auto- switching due to quota exhaustion. Now lastUsedAt is updated for every OAuth provider execution, ensuring dashboard shows accurate timestamps. --- src/cliproxy/cliproxy-executor.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6a031402..5775dedd 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -462,6 +462,13 @@ export async function execClaudeWithCLIProxy( if (tokenResult.refreshed) { log('Token was refreshed proactively'); } + + // 3a-1. Update lastUsedAt for the account being used + // This ensures dashboard shows accurate "Last used" timestamps + const usedAccount = getDefaultAccount(provider); + if (usedAccount) { + touchAccount(provider, usedAccount.id); + } } // 3b. Preflight quota check - auto-switch to account with quota before launch From dde0b9fb6ffbc11c13a72612e21862f545e8546d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 17:51:26 +0000 Subject: [PATCH 3/6] chore(release): 7.14.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7789ae65..813a158c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0", + "version": "7.14.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5970e70e2641e7d77b6f77d9624cd6990a1b81ba Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 13:00:50 -0500 Subject: [PATCH 4/6] fix(cliproxy): harden nickname validation and race condition handling - Trim nickname in API route for consistency with CLI - Block URL-unsafe chars (%, /, &, ?, #) in nickname - Block reserved patterns (kiro-N, ghcp-N) used by auto-discovery - Add reload-merge pattern in discovery to reduce race condition --- src/cliproxy/account-manager.ts | 35 +++++++++++++++++-- src/web-server/routes/cliproxy-auth-routes.ts | 4 ++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 2fced28c..393b3c0a 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -95,7 +95,7 @@ export function generateNickname(email?: string): string { /** * Validate nickname - * Rules: 1-50 chars, any non-whitespace allowed (permissive) + * Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns * @returns null if valid, error message if invalid */ export function validateNickname(nickname: string): string | null { @@ -108,6 +108,14 @@ export function validateNickname(nickname: string): string | null { if (/\s/.test(nickname)) { return 'Nickname cannot contain whitespace'; } + // Block URL-unsafe chars that break routing + if (/[%\/&?#]/.test(nickname)) { + return 'Nickname cannot contain special URL characters (%, /, &, ?, #)'; + } + // Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.) + if (/^(kiro|ghcp)-\d+$/i.test(nickname)) { + return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)'; + } return null; } @@ -597,7 +605,30 @@ export function discoverExistingAccounts(): void { } } - saveAccountsRegistry(registry); + // 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; + const prov = providerName as CLIProxyProvider; + if (!freshRegistry.providers[prov]) { + freshRegistry.providers[prov] = discovered; + } else { + // Merge accounts, preferring fresh registry's existing entries + 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; + } + } + } + } + } + saveAccountsRegistry(freshRegistry); } /** diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 306b30ac..e7570a8f 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -277,7 +277,9 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname, noIncognito: noIncognitoBody } = req.body; + const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body; + // Trim nickname for consistency with CLI (oauth-handler.ts trims input) + const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { From 107e2813f96624c105bab7d227336b0779648f12 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 13:52:14 -0500 Subject: [PATCH 5/6] fix(cliproxy): prevent race in promptNickname close handler --- src/cliproxy/auth/oauth-handler.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index fea5b39f..f4944e16 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -76,8 +76,15 @@ async function promptNickname( } return new Promise((resolve) => { - // Handle Ctrl+C gracefully - rl.on('close', () => resolve(null)); + 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) => { @@ -102,6 +109,7 @@ async function promptNickname( return; } + resolved = true; rl.close(); resolve(nickname); }); From e8716db0a1f8bfc446f3fbc06d59a3aeeceda159 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 19:20:53 +0000 Subject: [PATCH 6/6] chore(release): 7.14.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 813a158c..ef35d8ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.14.0-dev.1", + "version": "7.14.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",