mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 20:22:03 +00:00
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:
@@ -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:**
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ export {
|
|||||||
getPausedDir,
|
getPausedDir,
|
||||||
getAccountTokenPath,
|
getAccountTokenPath,
|
||||||
extractAccountIdFromTokenFile,
|
extractAccountIdFromTokenFile,
|
||||||
|
deriveNoEmailProviderAccountId,
|
||||||
generateNickname,
|
generateNickname,
|
||||||
validateNickname,
|
validateNickname,
|
||||||
|
hasAccountNameConflict,
|
||||||
|
findAccountNameMatch,
|
||||||
tokenFileExists,
|
tokenFileExists,
|
||||||
loadAccountsRegistry,
|
loadAccountsRegistry,
|
||||||
saveAccountsRegistry,
|
saveAccountsRegistry,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
|
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user