mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
Merge pull request #263 from kaitranntt/dev
feat: cliproxy improvements and validation enhancements
This commit is contained in:
22 files changed
+484
-30
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.13.0",
|
||||
"version": "7.13.0-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Extracted from api-command.ts for reuse and testability.
|
||||
*/
|
||||
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
|
||||
|
||||
/**
|
||||
* Validate API profile name
|
||||
@@ -24,6 +24,9 @@ export function validateApiName(name: string): string | null {
|
||||
if (isReservedName(name)) {
|
||||
return `'${name}' is a reserved name`;
|
||||
}
|
||||
if (isWindowsReservedName(name)) {
|
||||
return `'${name}' is a Windows reserved device name and cannot be used`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,25 @@ const DEFAULT_REGISTRY: AccountsRegistry = {
|
||||
providers: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract unique account ID from token filename when email is unavailable
|
||||
* For Kiro/GHCP OAuth, filenames are: <provider>-<oauth>-<profile_id>.json
|
||||
* Extracts: <oauth>-<profile_id> as unique identifier
|
||||
* @example kiro-github-ABC123.json → github-ABC123
|
||||
* @example ghcp-amazon-XYZ789.json → amazon-XYZ789
|
||||
* @example kiro-nomail.json → default (no OAuth structure)
|
||||
*/
|
||||
export function extractAccountIdFromTokenFile(filename: string, email?: string): string {
|
||||
if (email) return email;
|
||||
|
||||
// Pattern: <provider>-<oauth>-<profile_id>.json → extract <oauth>-<profile_id>
|
||||
// Requires at least 2 hyphens to distinguish from simple filenames like "kiro-nomail.json"
|
||||
const match = filename.match(/^[^-]+-([^-]+-[^.]+)\.json$/);
|
||||
if (match) return match[1];
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nickname from email
|
||||
* Takes prefix before @ symbol, sanitizes whitespace
|
||||
@@ -260,8 +279,8 @@ export function registerAccount(
|
||||
throw new Error('Failed to initialize provider accounts');
|
||||
}
|
||||
|
||||
// Determine account ID
|
||||
const accountId = email || 'default';
|
||||
// Determine account ID - use email if available, otherwise extract from filename
|
||||
const accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
// Generate nickname if not provided
|
||||
@@ -448,7 +467,7 @@ export function discoverExistingAccounts(): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email if available, fallback to filename
|
||||
// Extract email if available, fallback to filename-based ID
|
||||
let email = data.email || undefined;
|
||||
|
||||
// Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json")
|
||||
@@ -459,7 +478,8 @@ export function discoverExistingAccounts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const accountId = email || 'default';
|
||||
// Use unified ID extraction: email or filename-based unique ID
|
||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Gemini Token Refresh
|
||||
*
|
||||
* Handles proactive token validation and refresh for Gemini OAuth tokens.
|
||||
* Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* Gemini OAuth credentials - PUBLIC from official Gemini CLI source code
|
||||
* These are not secrets - they're public OAuth client credentials that Google
|
||||
* distributes with their official applications. See:
|
||||
* https://github.com/google/generative-ai-python (Gemini CLI source)
|
||||
*
|
||||
* GitHub secret scanning may flag these, but they are intentionally hardcoded
|
||||
* as they're required for OAuth token refresh and are publicly documented.
|
||||
*/
|
||||
|
||||
const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||
|
||||
const GEMINI_CLIENT_SECRET = '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';
|
||||
|
||||
/** Google OAuth token endpoint */
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/** Refresh tokens 5 minutes before expiry */
|
||||
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Gemini oauth_creds.json structure */
|
||||
interface GeminiOAuthCreds {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expiry_date?: number; // Unix timestamp in milliseconds
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
id_token?: string;
|
||||
}
|
||||
|
||||
/** Token refresh response from Google */
|
||||
interface TokenRefreshResponse {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to Gemini OAuth credentials file
|
||||
*/
|
||||
export function getGeminiOAuthPath(): string {
|
||||
return path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Gemini OAuth credentials
|
||||
*/
|
||||
function readGeminiCreds(): GeminiOAuthCreds | null {
|
||||
const oauthPath = getGeminiOAuthPath();
|
||||
if (!fs.existsSync(oauthPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(oauthPath, 'utf8');
|
||||
return JSON.parse(content) as GeminiOAuthCreds;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Gemini OAuth credentials
|
||||
* @returns error message if write failed, undefined on success
|
||||
*/
|
||||
function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined {
|
||||
const oauthPath = getGeminiOAuthPath();
|
||||
const dir = path.dirname(oauthPath);
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(oauthPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Failed to write credentials';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini token is expired or expiring soon
|
||||
*/
|
||||
export function isGeminiTokenExpiringSoon(): boolean {
|
||||
const creds = readGeminiCreds();
|
||||
if (!creds || !creds.access_token) {
|
||||
return true; // No token = needs auth
|
||||
}
|
||||
if (!creds.expiry_date) {
|
||||
return false; // No expiry info = assume valid
|
||||
}
|
||||
const expiresIn = creds.expiry_date - Date.now();
|
||||
return expiresIn < REFRESH_LEAD_TIME_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Gemini access token using refresh_token
|
||||
* @returns true if refresh succeeded, false otherwise
|
||||
*/
|
||||
export async function refreshGeminiToken(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const creds = readGeminiCreds();
|
||||
if (!creds || !creds.refresh_token) {
|
||||
return { success: false, error: 'No refresh token available' };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: creds.refresh_token,
|
||||
client_id: GEMINI_CLIENT_ID,
|
||||
client_secret: GEMINI_CLIENT_SECRET,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const data = (await response.json()) as TokenRefreshResponse;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error_description || data.error || `OAuth error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
return { success: false, error: 'No access_token in response' };
|
||||
}
|
||||
|
||||
// Update credentials file with new token
|
||||
const updatedCreds: GeminiOAuthCreds = {
|
||||
...creds,
|
||||
access_token: data.access_token,
|
||||
expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
const writeError = writeGeminiCreds(updatedCreds);
|
||||
if (writeError) {
|
||||
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { success: false, error: 'Token refresh timeout' };
|
||||
}
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Gemini token is valid, refreshing if needed
|
||||
* @param verbose Log progress if true
|
||||
* @returns true if token is valid (or was refreshed), false if refresh failed
|
||||
*/
|
||||
export async function ensureGeminiTokenValid(verbose = false): Promise<{
|
||||
valid: boolean;
|
||||
refreshed: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const creds = readGeminiCreds();
|
||||
if (!creds || !creds.access_token) {
|
||||
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
|
||||
}
|
||||
|
||||
if (!isGeminiTokenExpiringSoon()) {
|
||||
return { valid: true, refreshed: false };
|
||||
}
|
||||
|
||||
// Token is expired or expiring soon - try to refresh
|
||||
if (verbose) {
|
||||
console.log('[i] Gemini token expired or expiring soon, refreshing...');
|
||||
}
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
if (result.success) {
|
||||
if (verbose) {
|
||||
console.log('[OK] Gemini token refreshed successfully');
|
||||
}
|
||||
return { valid: true, refreshed: true };
|
||||
}
|
||||
|
||||
return { valid: false, refreshed: false, error: result.error };
|
||||
}
|
||||
@@ -258,3 +258,27 @@ export function displayAuthStatus(): void {
|
||||
console.log('To authenticate: ccs <provider> --auth');
|
||||
console.log('To logout: ccs <provider> --logout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure OAuth token is valid for provider, refreshing if expired or expiring soon.
|
||||
* This prevents UND_ERR_SOCKET errors caused by expired tokens during API calls.
|
||||
*
|
||||
* @param provider The CLIProxy provider
|
||||
* @param verbose Log progress if true
|
||||
* @returns Object with valid status and whether refresh occurred
|
||||
*/
|
||||
export async function ensureTokenValid(
|
||||
provider: CLIProxyProvider,
|
||||
verbose = false
|
||||
): Promise<{ valid: boolean; refreshed: boolean; error?: string }> {
|
||||
// Currently only Gemini uses oauth_creds.json with expiry_date
|
||||
// Other providers (agy, codex) use CLIProxyAPI's internal auth management
|
||||
if (provider === 'gemini') {
|
||||
const { ensureGeminiTokenValid } = await import('./gemini-token-refresh');
|
||||
return ensureGeminiTokenValid(verbose);
|
||||
}
|
||||
|
||||
// For other providers, assume token is valid if authenticated
|
||||
// CLIProxyAPI handles token refresh internally for antigravity/codex
|
||||
return { valid: isAuthenticated(provider), refreshed: false };
|
||||
}
|
||||
@@ -444,6 +444,23 @@ export async function execClaudeWithCLIProxy(
|
||||
} else {
|
||||
log(`${provider} already authenticated`);
|
||||
}
|
||||
|
||||
// 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens
|
||||
// CLIProxyAPI may fail mid-request if token expires during use
|
||||
const { ensureTokenValid } = await import('./auth/token-manager');
|
||||
const tokenResult = await ensureTokenValid(provider, verbose);
|
||||
if (!tokenResult.valid) {
|
||||
// Token expired and refresh failed - trigger re-auth
|
||||
console.error(warn('OAuth token expired and refresh failed'));
|
||||
if (tokenResult.error) {
|
||||
console.error(` ${tokenResult.error}`);
|
||||
}
|
||||
console.error(` Run "ccs ${provider} --auth" to re-authenticate`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (tokenResult.refreshed) {
|
||||
log('Token was refreshed proactively');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. First-run model configuration (interactive)
|
||||
|
||||
@@ -22,8 +22,10 @@ export interface ProxyTarget {
|
||||
port: number;
|
||||
/** Protocol (http/https) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Optional auth token - only send header if defined and non-empty */
|
||||
/** Optional auth token for API endpoints - only send header if defined and non-empty */
|
||||
authToken?: string;
|
||||
/** Optional management key for management API endpoints (/v0/management/*) */
|
||||
managementKey?: string;
|
||||
/** True if targeting remote server, false if local */
|
||||
isRemote: boolean;
|
||||
}
|
||||
@@ -56,6 +58,7 @@ export function getProxyTarget(): ProxyTarget {
|
||||
port,
|
||||
protocol,
|
||||
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
|
||||
managementKey: config.remote.management_key || undefined, // Empty string -> undefined
|
||||
isRemote: true,
|
||||
};
|
||||
}
|
||||
@@ -102,3 +105,29 @@ export function buildProxyHeaders(
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build request headers for management API endpoints (/v0/management/*).
|
||||
* Uses management_key if configured, otherwise falls back to authToken.
|
||||
*
|
||||
* @param target Resolved proxy target
|
||||
* @param additionalHeaders Extra headers to merge
|
||||
*/
|
||||
export function buildManagementHeaders(
|
||||
target: ProxyTarget,
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
// Use management key for management API, fallback to authToken
|
||||
const authKey = target.managementKey ?? target.authToken;
|
||||
|
||||
if (authKey) {
|
||||
headers['Authorization'] = `Bearer ${authKey}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
buildProxyHeaders,
|
||||
buildManagementHeaders,
|
||||
ProxyTarget,
|
||||
} from './proxy-target-resolver';
|
||||
|
||||
@@ -51,6 +51,11 @@ const PROVIDER_MAP: Record<string, string> = {
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
kiro: 'kiro',
|
||||
codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro
|
||||
ghcp: 'ghcp',
|
||||
'github-copilot': 'ghcp',
|
||||
copilot: 'ghcp',
|
||||
};
|
||||
|
||||
/** Display names for providers */
|
||||
@@ -60,6 +65,8 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
qwen: 'Qwen',
|
||||
iflow: 'iFlow',
|
||||
kiro: 'Kiro (AWS)',
|
||||
ghcp: 'GitHub Copilot (OAuth)',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,6 +81,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
|
||||
}
|
||||
|
||||
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
|
||||
const headers = buildManagementHeaders(proxyTarget);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
|
||||
@@ -81,7 +89,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: buildProxyHeaders(proxyTarget),
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -84,10 +84,10 @@ export async function checkLatestVersion(): Promise<LatestVersionResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate version format
|
||||
* Validate version format (supports X.Y.Z or X.Y.Z-N suffix)
|
||||
*/
|
||||
export function isValidVersionFormat(version: string): boolean {
|
||||
return /^\d+\.\d+\.\d+$/.test(version);
|
||||
return /^\d+\.\d+\.\d+(-\d+)?$/.test(version);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProfileName } from '../../auth/profile-detector';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { deleteConfigForPort } from '../config-generator';
|
||||
import { deleteSessionLockForPort } from '../session-tracker';
|
||||
@@ -58,6 +58,9 @@ export function validateProfileName(name: string): string | null {
|
||||
if (isReservedName(name)) {
|
||||
return `'${name}' is a reserved name`;
|
||||
}
|
||||
if (isWindowsReservedName(name)) {
|
||||
return `'${name}' is a Windows reserved device name and cannot be used`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*/
|
||||
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
|
||||
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
buildProxyHeaders,
|
||||
buildManagementHeaders,
|
||||
} from './proxy-target-resolver';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
@@ -109,9 +114,9 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/usage');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
// For management endpoints, use management key for remote, local management secret for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
? buildManagementHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -326,9 +331,9 @@ export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErr
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
// For management endpoints, use management key for remote, local management secret for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
? buildManagementHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -374,9 +379,9 @@ export async function fetchCliproxyErrorLogContent(
|
||||
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
|
||||
);
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
// For management endpoints, use management key for remote, local management secret for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
? buildManagementHeaders(target)
|
||||
: { Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
||||
@@ -619,13 +619,15 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
|
||||
const installIdx = args.indexOf('--install');
|
||||
if (installIdx !== -1) {
|
||||
const version = args[installIdx + 1];
|
||||
let version = args[installIdx + 1];
|
||||
if (!version || version.startsWith('-')) {
|
||||
console.error(fail('Missing version argument for --install'));
|
||||
console.error(' Usage: ccs cliproxy --install <version>');
|
||||
console.error(' Example: ccs cliproxy --install 6.5.53');
|
||||
console.error(' Example: ccs cliproxy --install 6.6.80-0');
|
||||
process.exit(1);
|
||||
}
|
||||
// Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ")
|
||||
version = version.trim().replace(/^v/, '');
|
||||
await handleInstallVersion(version, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,35 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
|
||||
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Windows reserved device names - cannot be used as filenames on Windows.
|
||||
* Case-insensitive on Windows filesystem.
|
||||
*/
|
||||
export const WINDOWS_RESERVED_NAMES = [
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if a name is reserved and cannot be used for user profiles.
|
||||
* @param name - The profile name to check
|
||||
@@ -28,6 +57,18 @@ export function isReservedName(name: string): boolean {
|
||||
return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a name is a Windows reserved device name.
|
||||
* These cause filesystem errors on Windows systems.
|
||||
* @param name - The name to check
|
||||
* @returns true if the name is a Windows reserved name
|
||||
*/
|
||||
export function isWindowsReservedName(name: string): boolean {
|
||||
return WINDOWS_RESERVED_NAMES.includes(
|
||||
name.toUpperCase() as (typeof WINDOWS_RESERVED_NAMES)[number]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a profile name and throw if reserved.
|
||||
* @param name - The profile name to validate
|
||||
|
||||
@@ -194,6 +194,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
auth_token:
|
||||
partial.cliproxy_server?.remote?.auth_token ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
|
||||
// management_key is optional - falls back to auth_token when not set
|
||||
management_key: partial.cliproxy_server?.remote?.management_key,
|
||||
},
|
||||
fallback: {
|
||||
enabled:
|
||||
|
||||
@@ -225,8 +225,15 @@ export interface ProxyRemoteConfig {
|
||||
port?: number;
|
||||
/** Protocol for remote connection */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy (optional, sent as header) */
|
||||
/** Auth token for remote proxy API endpoints (optional, sent as header) */
|
||||
auth_token: string;
|
||||
/**
|
||||
* Management key for remote proxy management API endpoints.
|
||||
* CLIProxyAPI uses separate authentication for management endpoints
|
||||
* (/v0/management/*) via 'secret-key' config.
|
||||
* If not set, falls back to auth_token for backwards compatibility.
|
||||
*/
|
||||
management_key?: string;
|
||||
/** Connection timeout in milliseconds (default: 2000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Loader2, Code2 } from 'lucide-react';
|
||||
@@ -93,11 +94,23 @@ export function ProviderEditor({
|
||||
|
||||
const accounts = authStatus.accounts || [];
|
||||
|
||||
// Fetch effective API key for presets (uses configured value, not hardcoded)
|
||||
const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({
|
||||
queryKey: ['auth-tokens-raw'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } };
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
});
|
||||
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
|
||||
|
||||
const handleApplyPreset = (updates: Record<string, string>) => {
|
||||
const effectivePort = port ?? CLIPROXY_PORT;
|
||||
updateEnvValues({
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
||||
...updates,
|
||||
});
|
||||
toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`);
|
||||
@@ -107,7 +120,7 @@ export function ProviderEditor({
|
||||
const effectivePort = port ?? CLIPROXY_PORT;
|
||||
updateEnvValues({
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
||||
ANTHROPIC_MODEL: values.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
|
||||
@@ -189,6 +202,7 @@ export function ProviderEditor({
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
isRemoteMode={isRemoteMode}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
|
||||
@@ -38,6 +38,8 @@ interface ModelConfigTabProps {
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** True if connected to remote CLIProxy (quota not available) */
|
||||
isRemoteMode?: boolean;
|
||||
}
|
||||
|
||||
export function ModelConfigTab({
|
||||
@@ -60,6 +62,7 @@ export function ModelConfigTab({
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
privacyMode,
|
||||
isRemoteMode,
|
||||
}: ModelConfigTabProps) {
|
||||
// Kiro-specific: no-incognito setting (defaults to true = normal browser)
|
||||
const isKiro = provider === 'kiro';
|
||||
@@ -133,7 +136,7 @@ export function ModelConfigTab({
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy'}
|
||||
showQuota={provider === 'agy' && !isRemoteMode}
|
||||
isKiro={isKiro}
|
||||
kiroNoIncognito={kiroNoIncognito}
|
||||
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
||||
|
||||
@@ -193,6 +193,8 @@ export interface ProxyRemoteConfig {
|
||||
port?: number;
|
||||
protocol: 'http' | 'https';
|
||||
auth_token: string;
|
||||
/** Management key for /v0/management/* endpoints (optional, falls back to auth_token) */
|
||||
management_key?: string;
|
||||
}
|
||||
|
||||
/** Fallback configuration */
|
||||
@@ -278,7 +280,9 @@ export const api = {
|
||||
cliproxy: {
|
||||
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
||||
getAuthStatus: () =>
|
||||
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'),
|
||||
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local'; error?: string }>(
|
||||
'/cliproxy/auth'
|
||||
),
|
||||
create: (data: CreateVariant) =>
|
||||
request('/cliproxy', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -8,6 +8,24 @@ import { MODEL_CATALOGS } from './model-catalogs';
|
||||
/** CLIProxy port - should match the backend configuration */
|
||||
export const CLIPROXY_PORT = 8317;
|
||||
|
||||
/** Default fallback API key if fetch fails */
|
||||
const DEFAULT_API_KEY = 'ccs-internal-managed';
|
||||
|
||||
/**
|
||||
* Fetch effective API key from backend
|
||||
* Falls back to default if fetch fails
|
||||
*/
|
||||
async function fetchEffectiveApiKey(): Promise<string> {
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) return DEFAULT_API_KEY;
|
||||
const data = await response.json();
|
||||
return data?.apiKey?.value ?? DEFAULT_API_KEY;
|
||||
} catch {
|
||||
return DEFAULT_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default preset for a provider to its settings
|
||||
* Uses the first model's presetMapping or falls back to using defaultModel for all tiers
|
||||
@@ -32,11 +50,14 @@ export async function applyDefaultPreset(
|
||||
haiku: catalog.defaultModel,
|
||||
};
|
||||
|
||||
// Fetch effective API key (respects user customization)
|
||||
const effectiveApiKey = await fetchEffectiveApiKey();
|
||||
|
||||
const effectivePort = port ?? CLIPROXY_PORT;
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useProxyConfig() {
|
||||
const [editedHost, setEditedHost] = useState<string | null>(null);
|
||||
const [editedPort, setEditedPort] = useState<string | null>(null);
|
||||
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
|
||||
const [editedManagementKey, setEditedManagementKey] = useState<string | null>(null);
|
||||
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
@@ -108,6 +109,8 @@ export function useProxyConfig() {
|
||||
setEditedPort,
|
||||
editedAuthToken,
|
||||
setEditedAuthToken,
|
||||
editedManagementKey,
|
||||
setEditedManagementKey,
|
||||
editedLocalPort,
|
||||
setEditedLocalPort,
|
||||
fetchConfig,
|
||||
|
||||
@@ -28,6 +28,8 @@ export default function ProxySection() {
|
||||
setEditedPort,
|
||||
editedAuthToken,
|
||||
setEditedAuthToken,
|
||||
editedManagementKey,
|
||||
setEditedManagementKey,
|
||||
editedLocalPort,
|
||||
setEditedLocalPort,
|
||||
fetchConfig,
|
||||
@@ -62,11 +64,13 @@ export default function ProxySection() {
|
||||
const hostInput = config.remote.host ?? '';
|
||||
const portInput = config.remote.port !== undefined ? config.remote.port.toString() : '';
|
||||
const authTokenInput = config.remote.auth_token ?? '';
|
||||
const managementKeyInput = config.remote.management_key ?? '';
|
||||
const localPortInput = (config.local.port ?? 8317).toString();
|
||||
|
||||
const displayHost = editedHost ?? hostInput;
|
||||
const displayPort = editedPort ?? portInput;
|
||||
const displayAuthToken = editedAuthToken ?? authTokenInput;
|
||||
const displayManagementKey = editedManagementKey ?? managementKeyInput;
|
||||
const displayLocalPort = editedLocalPort ?? localPortInput;
|
||||
|
||||
// Save functions for blur events
|
||||
@@ -97,6 +101,14 @@ export default function ProxySection() {
|
||||
setEditedAuthToken(null);
|
||||
};
|
||||
|
||||
const saveManagementKey = () => {
|
||||
const value = editedManagementKey ?? displayManagementKey;
|
||||
if (value !== config.remote.management_key) {
|
||||
saveConfig({ remote: { ...remoteConfig, management_key: value || undefined } });
|
||||
}
|
||||
setEditedManagementKey(null);
|
||||
};
|
||||
|
||||
const saveLocalPort = () => {
|
||||
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
|
||||
if (!isNaN(port) && port !== config.local.port) {
|
||||
@@ -203,12 +215,15 @@ export default function ProxySection() {
|
||||
displayHost={displayHost}
|
||||
displayPort={displayPort}
|
||||
displayAuthToken={displayAuthToken}
|
||||
displayManagementKey={displayManagementKey}
|
||||
setEditedHost={setEditedHost}
|
||||
setEditedPort={setEditedPort}
|
||||
setEditedAuthToken={setEditedAuthToken}
|
||||
setEditedManagementKey={setEditedManagementKey}
|
||||
onSaveHost={saveHost}
|
||||
onSavePort={savePort}
|
||||
onSaveAuthToken={saveAuthToken}
|
||||
onSaveManagementKey={saveManagementKey}
|
||||
onSaveConfig={saveConfig}
|
||||
onTestConnection={handleTestConnection}
|
||||
/>
|
||||
|
||||
@@ -23,12 +23,15 @@ interface RemoteProxyCardProps {
|
||||
displayHost: string;
|
||||
displayPort: string;
|
||||
displayAuthToken: string;
|
||||
displayManagementKey: string;
|
||||
setEditedHost: (value: string | null) => void;
|
||||
setEditedPort: (value: string | null) => void;
|
||||
setEditedAuthToken: (value: string | null) => void;
|
||||
setEditedManagementKey: (value: string | null) => void;
|
||||
onSaveHost: () => void;
|
||||
onSavePort: () => void;
|
||||
onSaveAuthToken: () => void;
|
||||
onSaveManagementKey: () => void;
|
||||
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
|
||||
onTestConnection: () => void;
|
||||
}
|
||||
@@ -41,12 +44,15 @@ export function RemoteProxyCard({
|
||||
displayHost,
|
||||
displayPort,
|
||||
displayAuthToken,
|
||||
displayManagementKey,
|
||||
setEditedHost,
|
||||
setEditedPort,
|
||||
setEditedAuthToken,
|
||||
setEditedManagementKey,
|
||||
onSaveHost,
|
||||
onSavePort,
|
||||
onSaveAuthToken,
|
||||
onSaveManagementKey,
|
||||
onSaveConfig,
|
||||
onTestConnection,
|
||||
}: RemoteProxyCardProps) {
|
||||
@@ -115,18 +121,38 @@ export function RemoteProxyCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token */}
|
||||
{/* Auth Token (API Key) */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
|
||||
<label className="text-sm text-muted-foreground">API Key (optional)</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={displayAuthToken}
|
||||
onChange={(e) => setEditedAuthToken(e.target.value)}
|
||||
onBlur={onSaveAuthToken}
|
||||
placeholder="Bearer token for authentication"
|
||||
placeholder="For /v1/* API endpoints"
|
||||
className="font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for API requests to /v1/chat/completions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Management Key */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Management Key (optional)</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={displayManagementKey}
|
||||
onChange={(e) => setEditedManagementKey(e.target.value)}
|
||||
onBlur={onSaveManagementKey}
|
||||
placeholder="For /v0/management/* endpoints"
|
||||
className="font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for dashboard management APIs. Falls back to API Key if not set.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
|
||||
Reference in new issue
Block a user