Merge pull request #343 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-15 14:31:00 -05:00
committed by GitHub
19 changed files with 1999 additions and 44 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.21.0",
"version": "7.21.0-dev.3",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -39,7 +39,7 @@
"LICENSE"
],
"engines": {
"node": ">=14.0.0",
"node": ">=18.0.0",
"bun": ">=1.0.0"
},
"packageManager": "bun@1.2.21",
+133 -8
View File
@@ -6,6 +6,7 @@
*
* Account storage: ~/.ccs/cliproxy/accounts.json
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
* Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path)
*/
import * as fs from 'fs';
@@ -47,6 +48,8 @@ export interface AccountInfo {
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: AccountTier;
/** GCP Project ID (Antigravity only) - read-only, fetched from auth token */
projectId?: string;
}
/** Provider accounts configuration */
@@ -135,6 +138,19 @@ export function getAccountsRegistryPath(): string {
return path.join(getCliproxyDir(), 'accounts.json');
}
/**
* Get path to paused tokens directory
* Paused tokens are moved here so CLIProxyAPI won't discover them
*
* Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/)
* because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans
* all subdirectories of auth/. A sibling directory is completely outside
* CLIProxyAPI's scan path, preventing token refresh loops.
*/
export function getPausedDir(): string {
return path.join(getCliproxyDir(), 'auth-paused');
}
/**
* Load accounts registry
*/
@@ -176,10 +192,12 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
/**
* Sync registry with actual token files
* Removes stale entries where token file no longer exists
* For paused accounts, checks both auth/ and paused/ directories
* Called automatically when loading accounts
*/
function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
const authDir = getAuthDir();
const pausedDir = getPausedDir();
let modified = false;
for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) {
@@ -189,7 +207,14 @@ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) {
const tokenPath = path.join(authDir, meta.tokenFile);
if (!fs.existsSync(tokenPath)) {
const pausedPath = path.join(pausedDir, meta.tokenFile);
// For paused accounts, check paused dir; for active accounts, check auth dir
const expectedPath = meta.paused ? pausedPath : tokenPath;
// Also accept if file exists in either location (handles edge cases)
const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath);
if (!fs.existsSync(expectedPath) && !existsAnywhere) {
staleIds.push(accountId);
}
}
@@ -293,7 +318,8 @@ export function registerAccount(
provider: CLIProxyProvider,
tokenFile: string,
email?: string,
nickname?: string
nickname?: string,
projectId?: string
): AccountInfo {
const registry = loadAccountsRegistry();
@@ -350,7 +376,7 @@ export function registerAccount(
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
providerAccounts.accounts[accountId] = {
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: accountNickname,
tokenFile,
@@ -358,6 +384,13 @@ export function registerAccount(
lastUsedAt: new Date().toISOString(),
};
// Include projectId for Antigravity accounts
if (provider === 'agy' && projectId) {
accountMeta.projectId = projectId;
}
providerAccounts.accounts[accountId] = accountMeta;
// Set as default if first account
if (isFirstAccount) {
providerAccounts.default = accountId;
@@ -374,6 +407,7 @@ export function registerAccount(
tokenFile,
createdAt: providerAccounts.accounts[accountId].createdAt,
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
projectId: providerAccounts.accounts[accountId].projectId,
};
}
@@ -395,6 +429,7 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
/**
* Pause an account (skip in quota rotation)
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it
*/
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
@@ -404,6 +439,32 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
// Skip if already paused (idempotent)
if (accountMeta.paused) {
return true;
}
const authDir = getAuthDir();
const pausedDir = getPausedDir();
const tokenPath = path.join(authDir, accountMeta.tokenFile);
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
// Move token file to paused directory (if it exists in auth dir)
if (fs.existsSync(tokenPath)) {
try {
// Create paused directory if it doesn't exist
if (!fs.existsSync(pausedDir)) {
fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 });
}
fs.renameSync(tokenPath, pausedPath);
} catch {
// File operation failed, but continue with registry update
// syncRegistryWithTokenFiles() will handle recovery on next load
}
}
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
saveAccountsRegistry(registry);
@@ -412,6 +473,7 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
/**
* Resume a paused account
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
*/
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
@@ -421,6 +483,28 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
// Skip if already active (idempotent)
if (!accountMeta.paused) {
return true;
}
const authDir = getAuthDir();
const pausedDir = getPausedDir();
const tokenPath = path.join(authDir, accountMeta.tokenFile);
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
// Move token file back from paused directory (if it exists in paused dir)
if (fs.existsSync(pausedPath)) {
try {
fs.renameSync(pausedPath, tokenPath);
} catch {
// File operation failed, but continue with registry update
// syncRegistryWithTokenFiles() will handle recovery on next load
}
}
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
saveAccountsRegistry(registry);
@@ -474,11 +558,12 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
return false;
}
// Get token file to delete
// Get token file to delete (check both auth and paused directories)
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
const tokenPath = path.join(getAuthDir(), tokenFile);
const pausedPath = path.join(getPausedDir(), tokenFile);
// Delete token file
// Delete token file from auth directory
if (fs.existsSync(tokenPath)) {
try {
fs.unlinkSync(tokenPath);
@@ -487,6 +572,15 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
}
}
// Also delete from paused directory if it exists there
if (fs.existsSync(pausedPath)) {
try {
fs.unlinkSync(pausedPath);
} catch {
// Ignore deletion errors
}
}
// Remove from registry
delete providerAccounts.accounts[accountId];
@@ -547,6 +641,7 @@ export function touchAccount(provider: CLIProxyProvider, accountId: string): voi
/**
* Get token file path for an account
* Returns path in paused/ dir if account is paused, otherwise auth/
*/
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
@@ -555,7 +650,9 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
return null;
}
return path.join(getAuthDir(), account.tokenFile);
// Return path from paused directory if account is paused
const baseDir = account.paused ? getPausedDir() : getAuthDir();
return path.join(baseDir, account.tokenFile);
}
/**
@@ -629,6 +726,20 @@ export function discoverExistingAccounts(): void {
// Skip if token file already registered (under any accountId)
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
if (existingTokenFiles.includes(file)) {
// Token file exists - check if we need to update projectId for agy accounts
const projectIdValue =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
if (provider === 'agy' && projectIdValue) {
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, meta]) => meta.tokenFile === file
);
// Update if missing or changed
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
existingEntry[1].projectId = projectIdValue;
}
}
continue;
}
@@ -671,13 +782,24 @@ export function discoverExistingAccounts(): void {
// Register account with auto-generated nickname
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
const lastModified = stats.mtime || stats.birthtime || new Date();
providerAccounts.accounts[accountId] = {
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: generateNickname(email),
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
};
// Read project_id for Antigravity accounts (read-only field from auth token)
const discoveredProjectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
if (provider === 'agy' && discoveredProjectId) {
accountMeta.projectId = discoveredProjectId;
}
providerAccounts.accounts[accountId] = accountMeta;
} catch {
// Skip invalid files
continue;
@@ -693,7 +815,7 @@ export function discoverExistingAccounts(): void {
if (!freshRegistry.providers[prov]) {
freshRegistry.providers[prov] = discovered;
} else {
// Merge accounts, preferring fresh registry's existing entries
// Merge accounts, preferring fresh registry's existing entries but updating projectId
const freshProviderAccounts = freshRegistry.providers[prov];
if (!freshProviderAccounts) continue;
for (const [id, meta] of Object.entries(discovered.accounts)) {
@@ -703,6 +825,9 @@ export function discoverExistingAccounts(): void {
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
freshProviderAccounts.default = id;
}
} else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) {
// Update existing account with projectId if discovered from auth file
freshProviderAccounts.accounts[id].projectId = meta.projectId;
}
}
}
+55 -2
View File
@@ -200,7 +200,8 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
export function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string,
nickname?: string
nickname?: string,
verbose = false
): import('../account-manager').AccountInfo | null {
const { registerAccount, generateNickname } = require('../account-manager');
try {
@@ -229,13 +230,65 @@ export function registerAccountFromToken(
const content = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(content);
const email = data.email || undefined;
const projectId = data.project_id || undefined;
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
const account = registerAccount(
provider,
newestFile,
email,
nickname || generateNickname(email),
projectId
);
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose);
return account;
} catch {
return null;
}
}
/**
* Upload token to remote server asynchronously (fire and forget).
* Only runs if remote mode is enabled. Logs success/failure via uploadTokenToRemote.
* Does not block the OAuth flow - local token is always valid regardless of upload result.
*
* @param tokenPath - Path to the token file
* @param verbose - Enable verbose logging for upload progress
*/
function uploadTokenToRemoteAsync(tokenPath: string, verbose: boolean): void {
// Dynamic import to avoid circular dependencies
import('../remote-token-uploader')
.then(({ uploadTokenToRemote, isRemoteUploadEnabled }) => {
if (isRemoteUploadEnabled()) {
// uploadTokenToRemote handles its own success/failure logging
// On failure, show additional warning so users know local token is still valid
uploadTokenToRemote(tokenPath, verbose)
.then((success) => {
if (!success) {
console.error(
'\n[!] Remote upload failed - token saved locally only. Run "ccs tokens upload" to retry.'
);
}
})
.catch((err: unknown) => {
// Unexpected error (not handled by uploadTokenToRemote)
const message = err instanceof Error ? err.message : String(err);
console.error(`[token-manager] Unexpected upload error: ${message}`);
console.error(
'[!] Token saved locally. Run "ccs tokens upload" to sync to remote server.'
);
});
}
})
.catch((err: unknown) => {
// Module load failed - log for debugging
const message = err instanceof Error ? err.message : String(err);
console.error(`[token-manager] Failed to load remote-token-uploader: ${message}`);
});
}
/**
* Display auth status for all providers
*/
+66 -10
View File
@@ -60,6 +60,7 @@ import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '.
import { withStartupLock } from './startup-lock';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { preflightCheck } from './quota-manager';
import { HttpsTunnelProxy } from './https-tunnel-proxy';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -693,17 +694,56 @@ export async function execClaudeWithCLIProxy(
}
: undefined;
// For HTTPS remote, we need a local HTTP tunnel since Claude Code doesn't support
// HTTPS in ANTHROPIC_BASE_URL directly (undici limitation)
let httpsTunnel: HttpsTunnelProxy | null = null;
let tunnelPort: number | null = null;
if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) {
try {
httpsTunnel = new HttpsTunnelProxy({
remoteHost: proxyConfig.host,
remotePort: proxyConfig.port,
authToken: proxyConfig.authToken,
verbose,
allowSelfSigned: proxyConfig.allowSelfSigned ?? false,
});
tunnelPort = await httpsTunnel.start();
log(
`HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}`
);
} catch (error) {
const err = error as Error;
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
throw new Error(`HTTPS tunnel startup failed: ${err.message}`);
}
}
// Build env vars - use tunnel port for HTTPS remote, direct URL otherwise
const envVars = useRemoteProxy
? getRemoteEnvVars(
provider,
{
host: proxyConfig.host ?? 'localhost',
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
},
cfg.customSettingsPath
)
? httpsTunnel && tunnelPort
? // HTTPS remote via local tunnel - use HTTP to tunnel
getRemoteEnvVars(
provider,
{
host: '127.0.0.1',
port: tunnelPort,
protocol: 'http', // Tunnel speaks HTTP locally
authToken: proxyConfig.authToken,
},
cfg.customSettingsPath
)
: // HTTP remote - direct connection
getRemoteEnvVars(
provider,
{
host: proxyConfig.host ?? 'localhost',
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
},
cfg.customSettingsPath
)
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
// Codex-only: inject OpenAI reasoning effort based on tier model mapping.
@@ -722,6 +762,9 @@ export async function execClaudeWithCLIProxy(
const traceEnabled =
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
// For remote proxy mode, strip /api/provider/codex prefix from paths
// because remote CLIProxyAPI uses root paths (/v1/messages), not provider-prefixed
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
codexReasoningProxy = new CodexReasoningProxy({
upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL,
verbose,
@@ -735,6 +778,7 @@ export async function execClaudeWithCLIProxy(
sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
},
stripPathPrefix,
});
codexReasoningPort = await codexReasoningProxy.start();
log(
@@ -837,6 +881,10 @@ export async function execClaudeWithCLIProxy(
codexReasoningProxy.stop();
}
if (httpsTunnel) {
httpsTunnel.stop();
}
// Unregister this session (proxy keeps running for persistence) - only for local mode
if (sessionId) {
unregisterSession(sessionId, sessionPort);
@@ -857,6 +905,10 @@ export async function execClaudeWithCLIProxy(
codexReasoningProxy.stop();
}
if (httpsTunnel) {
httpsTunnel.stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
@@ -872,6 +924,10 @@ export async function execClaudeWithCLIProxy(
codexReasoningProxy.stop();
}
if (httpsTunnel) {
httpsTunnel.stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
+41 -4
View File
@@ -1,4 +1,5 @@
import * as http from 'http';
import * as https from 'https';
import { URL } from 'url';
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
@@ -17,6 +18,12 @@ export interface CodexReasoningProxyConfig {
modelMap: CodexReasoningModelMap;
defaultEffort?: CodexReasoningEffort;
traceFilePath?: string;
/**
* Path prefix to strip from incoming requests before forwarding to upstream.
* Used for remote proxy mode where upstream expects /v1/messages, not /api/provider/codex/v1/messages.
* Example: '/api/provider/codex' will transform '/api/provider/codex/v1/messages' to '/v1/messages'
*/
stripPathPrefix?: string;
}
function isNonEmptyString(value: unknown): value is string {
@@ -108,7 +115,7 @@ export class CodexReasoningProxy {
'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath'
>
> &
Pick<CodexReasoningProxyConfig, 'modelMap'>;
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
private readonly modelEffort: Map<string, CodexReasoningEffort>;
private readonly recent: Array<{
at: string;
@@ -127,6 +134,7 @@ export class CodexReasoningProxy {
modelMap: config.modelMap,
defaultEffort: config.defaultEffort ?? 'medium',
traceFilePath: config.traceFilePath ?? '',
stripPathPrefix: config.stripPathPrefix,
};
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
}
@@ -221,7 +229,26 @@ export class CodexReasoningProxy {
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const method = req.method || 'GET';
const requestPath = req.url || '/';
let requestPath = req.url || '/';
// Strip path prefix if configured (for remote proxy mode)
// e.g., '/api/provider/codex/v1/messages' → '/v1/messages'
// Boundary check: only match complete path segments (not partial like /codex matching /codextra)
if (
this.config.stripPathPrefix &&
requestPath.startsWith(this.config.stripPathPrefix) &&
(requestPath.length === this.config.stripPathPrefix.length ||
requestPath[this.config.stripPathPrefix.length] === '/')
) {
let stripped = requestPath.slice(this.config.stripPathPrefix.length);
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
stripped = stripped.replace(/^\/+/, '/') || '/';
if (!stripped.startsWith('/')) {
stripped = '/' + stripped;
}
requestPath = stripped;
}
const upstreamBase = new URL(this.config.upstreamBaseUrl);
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
@@ -332,13 +359,22 @@ export class CodexReasoningProxy {
return headers;
}
/**
* Get the appropriate request function based on protocol.
* Uses https.request for HTTPS URLs, http.request for HTTP.
*/
private getRequestFn(url: URL): typeof http.request | typeof https.request {
return url.protocol === 'https:' ? https.request : http.request;
}
private forwardRaw(
originalReq: http.IncomingMessage,
clientRes: http.ServerResponse,
upstreamUrl: URL
): Promise<void> {
return new Promise((resolve, reject) => {
const upstreamReq = http.request(
const requestFn = this.getRequestFn(upstreamUrl);
const upstreamReq = requestFn(
{
protocol: upstreamUrl.protocol,
hostname: upstreamUrl.hostname,
@@ -370,7 +406,8 @@ export class CodexReasoningProxy {
): Promise<void> {
return new Promise((resolve, reject) => {
const bodyString = JSON.stringify(body);
const upstreamReq = http.request(
const requestFn = this.getRequestFn(upstreamUrl);
const upstreamReq = requestFn(
{
protocol: upstreamUrl.protocol,
hostname: upstreamUrl.hostname,
+3 -1
View File
@@ -739,7 +739,9 @@ export function getRemoteEnvVars(
// Omit port suffix for standard web ports (80/443) for cleaner URLs
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
// Remote CLIProxyAPI uses root path (e.g., /v1/messages), not /api/provider/{provider}/v1/messages
// The /api/provider/ prefix is only for local CLIProxy instances
const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
// Get global env vars (DISABLE_TELEMETRY, etc.)
const globalEnv = getGlobalEnvVars();
+258
View File
@@ -0,0 +1,258 @@
/**
* HTTPS Tunnel Proxy
*
* Local HTTP server that tunnels requests to a remote HTTPS CLIProxyAPI.
* Required because Claude Code (via undici/node-fetch) doesn't support
* HTTPS in ANTHROPIC_BASE_URL directly.
*
* Flow:
* Claude CLI --HTTP--> Local Tunnel (port X) --HTTPS--> Remote CLIProxyAPI
*
* Note: Unlike CodexReasoningProxy, this tunnel does NOT buffer or limit response sizes.
* Responses are streamed directly (pipe) which is appropriate for a transparent tunnel.
* Socket-level timeouts handle hung connections; size limits are enforced by the remote server.
*/
import * as http from 'http';
import * as https from 'https';
import type { Socket } from 'net';
export interface HttpsTunnelConfig {
/** Remote server hostname */
remoteHost: string;
/** Remote server port (default: 443) */
remotePort?: number;
/** Auth token for remote server */
authToken?: string;
/** Request timeout in ms (default: 120000) */
timeoutMs?: number;
/** Enable verbose logging */
verbose?: boolean;
/** Skip TLS certificate validation (for self-signed certs) */
allowSelfSigned?: boolean;
}
export class HttpsTunnelProxy {
private server: http.Server | null = null;
private port: number | null = null;
private startingPromise: Promise<number> | null = null;
private activeConnections = new Set<Socket>();
private readonly config: Required<
Pick<
HttpsTunnelConfig,
'remoteHost' | 'remotePort' | 'timeoutMs' | 'verbose' | 'allowSelfSigned'
>
> &
Pick<HttpsTunnelConfig, 'authToken'>;
constructor(config: HttpsTunnelConfig) {
// Validate hostname format (basic check for common issues)
if (!config.remoteHost || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$/.test(config.remoteHost)) {
if (
config.remoteHost &&
config.remoteHost.length === 1 &&
/^[a-zA-Z0-9]$/.test(config.remoteHost)
) {
// Single character hostname is valid
} else {
throw new Error(
`Invalid remoteHost format: "${config.remoteHost}". ` +
'Expected hostname without protocol (e.g., "api.example.com")'
);
}
}
this.config = {
remoteHost: config.remoteHost,
remotePort: config.remotePort ?? 443,
timeoutMs: config.timeoutMs ?? 120000,
verbose: config.verbose ?? false,
allowSelfSigned: config.allowSelfSigned ?? false,
authToken: config.authToken,
};
}
private log(message: string): void {
if (this.config.verbose) {
console.error(`[https-tunnel] ${message}`);
}
}
async start(): Promise<number> {
// Already started
if (this.server) return this.port ?? 0;
// Prevent race condition: if start() is already in progress, return the same promise
if (this.startingPromise) return this.startingPromise;
this.startingPromise = new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res);
});
// Track connections for proper cleanup
this.server.on('connection', (socket: Socket) => {
this.activeConnections.add(socket);
socket.on('close', () => this.activeConnections.delete(socket));
});
this.server.listen(0, '127.0.0.1', () => {
const address = this.server?.address();
this.port = typeof address === 'object' && address ? address.port : 0;
if (this.port === 0) {
this.startingPromise = null;
reject(new Error('Failed to bind to any port'));
return;
}
this.log(
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
);
resolve(this.port);
});
this.server.on('error', (err) => {
this.startingPromise = null;
reject(err);
});
});
return this.startingPromise;
}
stop(): void {
if (!this.server) return;
// Forcefully close all active connections
for (const socket of this.activeConnections) {
socket.destroy();
}
this.activeConnections.clear();
this.server.close();
this.server = null;
this.port = null;
this.startingPromise = null;
this.log('Stopped');
}
getPort(): number | null {
return this.port;
}
private buildForwardHeaders(originalHeaders: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
const headers: http.OutgoingHttpHeaders = {};
// RFC 7230 hop-by-hop headers that should not be forwarded
const hopByHop = new Set([
'host',
'connection',
'transfer-encoding',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'upgrade',
]);
for (const [key, value] of Object.entries(originalHeaders)) {
if (!value) continue;
const lower = key.toLowerCase();
if (hopByHop.has(lower)) continue;
headers[key] = value;
}
// Set correct host header for remote
headers['Host'] = this.config.remoteHost;
// Inject Authorization header if not present but authToken is configured
// This is a fallback - normally the client (CodexReasoningProxy) forwards the header
if (!headers['authorization'] && !headers['Authorization'] && this.config.authToken) {
headers['Authorization'] = `Bearer ${this.config.authToken}`;
}
return headers;
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const method = req.method || 'GET';
const requestPath = req.url || '/';
this.log(
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
);
try {
await this.forwardRequest(req, res, requestPath);
} catch (error) {
const err = error as Error;
this.log(`Error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
// Sanitize error message: show details only in verbose mode (localhost-only anyway)
const errorMessage = this.config.verbose ? err.message : 'Upstream request failed';
res.end(JSON.stringify({ error: errorMessage }));
}
}
private forwardRequest(
originalReq: http.IncomingMessage,
clientRes: http.ServerResponse,
requestPath: string
): Promise<void> {
return new Promise((resolve, reject) => {
const headers = this.buildForwardHeaders(originalReq.headers);
const options: https.RequestOptions = {
hostname: this.config.remoteHost,
port: this.config.remotePort,
path: requestPath,
method: originalReq.method,
timeout: this.config.timeoutMs,
headers,
// Allow self-signed certificates if configured
rejectUnauthorized: !this.config.allowSelfSigned,
};
const upstreamReq = https.request(options, (upstreamRes) => {
// Forward status and headers
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
// Pipe response body
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
upstreamRes.on('error', reject);
});
upstreamReq.on('timeout', () => {
const timeoutError = new Error('Upstream request timeout');
this.log(`Timeout: ${timeoutError.message}`);
upstreamReq.destroy();
reject(timeoutError);
});
upstreamReq.on('error', (err) => {
this.log(`Upstream error: ${err.message}`);
reject(err);
});
// Handle client disconnect (premature close)
originalReq.on('error', (err) => {
this.log(`Client request error: ${err.message}`);
upstreamReq.destroy();
reject(err);
});
originalReq.on('close', () => {
if (!originalReq.complete) {
this.log('Client disconnected prematurely');
upstreamReq.destroy();
}
});
// Pipe request body to upstream
originalReq.pipe(upstreamReq);
});
}
}
+13
View File
@@ -11,6 +11,7 @@ import { getAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
import {
getProviderAccounts,
isAccountPaused,
setAccountTier,
type AccountInfo,
type AccountTier,
@@ -553,6 +554,18 @@ export async function fetchAccountQuota(
};
}
// Check if account is paused (token moved to auth-paused/ directory)
if (isAccountPaused(provider, accountId)) {
const error = 'Account is paused';
if (verbose) console.error(`[i] ${error}`);
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
};
}
// Read auth data from auth file
const authData = readAuthData(provider, accountId);
if (!authData) {
+182
View File
@@ -0,0 +1,182 @@
/**
* Remote Token Uploader
*
* Uploads OAuth tokens to remote CLIProxyAPI server after local authentication.
* Enables multi-device access to the same OAuth accounts.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getProxyTarget, buildProxyUrl } from './proxy-target-resolver';
import { info, ok, fail, warn } from '../utils/ui';
/** Timeout for upload requests (ms) */
const UPLOAD_TIMEOUT_MS = 10000;
/** Response from POST /v0/management/auth-files */
interface UploadResponse {
status?: string;
success?: boolean;
id?: string;
message?: string;
error?: string;
}
/**
* Upload a token file to remote CLIProxyAPI server.
* Uses multipart/form-data as required by CLIProxyAPI.
*
* @param tokenFilePath - Path to local token JSON file
* @param verbose - Enable verbose logging
* @returns true if upload succeeded
*/
export async function uploadTokenToRemote(
tokenFilePath: string,
verbose = false
): Promise<boolean> {
const target = getProxyTarget();
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
}
return false;
}
// Read token file
let tokenContent: string;
try {
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
} catch (error) {
console.error(fail(`Failed to read token file: ${(error as Error).message}`));
return false;
}
// Validate JSON
try {
JSON.parse(tokenContent);
} catch {
console.error(fail('Invalid token file: not valid JSON'));
return false;
}
const fileName = path.basename(tokenFilePath);
const url = buildProxyUrl(target, '/v0/management/auth-files');
// Use Authorization: Bearer header for authentication
const authKey = target.managementKey ?? target.authToken;
if (verbose) {
console.error(`[upload] Uploading ${fileName} to ${target.host}`);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
// CLIProxyAPI requires multipart/form-data with "file" field
const formData = new FormData();
const blob = new Blob([tokenContent], { type: 'application/json' });
formData.append('file', blob, fileName);
const headers: Record<string, string> = {};
if (authKey) {
headers['Authorization'] = `Bearer ${authKey}`;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
console.error(fail(`Upload failed: ${response.status} ${text}`));
return false;
}
const result = (await response.json()) as UploadResponse;
if (result.status === 'ok' || result.success || result.id) {
console.log(ok(`Token uploaded to remote server: ${fileName}`));
return true;
} else {
console.error(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`));
return false;
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
console.error(fail('Upload timed out'));
} else {
console.error(fail(`Upload failed: ${(error as Error).message}`));
}
return false;
}
}
/**
* Upload all tokens from a provider directory to remote server.
*
* @param tokenDir - Directory containing token files
* @param verbose - Enable verbose logging
* @returns Number of successfully uploaded tokens
*/
export async function uploadAllTokensToRemote(tokenDir: string, verbose = false): Promise<number> {
const target = getProxyTarget();
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
}
return 0;
}
if (!fs.existsSync(tokenDir)) {
if (verbose) {
console.error(`[upload] Token directory does not exist: ${tokenDir}`);
}
return 0;
}
const files = fs.readdirSync(tokenDir).filter((f) => f.endsWith('.json'));
if (files.length === 0) {
if (verbose) {
console.error('[upload] No token files found');
}
return 0;
}
console.log(info(`Uploading ${files.length} token(s) to remote server...`));
let uploaded = 0;
for (const file of files) {
const filePath = path.join(tokenDir, file);
const success = await uploadTokenToRemote(filePath, verbose);
if (success) {
uploaded++;
}
}
if (uploaded > 0) {
console.log(ok(`Uploaded ${uploaded}/${files.length} token(s) to ${target.host}`));
} else {
console.log(warn('No tokens were uploaded'));
}
return uploaded;
}
/**
* Check if remote upload is enabled and configured.
*/
export function isRemoteUploadEnabled(): boolean {
const target = getProxyTarget();
return target.isRemote && Boolean(target.managementKey ?? target.authToken);
}
+2 -2
View File
@@ -141,7 +141,7 @@ describe('npm CLI', () => {
describe('Error handling', () => {
it('handles empty arguments gracefully', function() {
try {
runCli('', { stdio: 'pipe' });
runCli('', { stdio: 'pipe', timeout: 3000 });
} catch (e) {
// Should either succeed or fail gracefully with a helpful error
const output = e.stderr?.toString() || e.stdout?.toString() || '';
@@ -152,7 +152,7 @@ describe('npm CLI', () => {
it('handles very long argument', function() {
const longArg = 'a'.repeat(1000);
try {
runCli(`"${longArg}"`, { stdio: 'pipe' });
runCli(`"${longArg}"`, { stdio: 'pipe', timeout: 3000 });
} catch (e) {
// Should handle gracefully, not crash
const output = e.stderr?.toString() || e.stdout?.toString() || '';
@@ -88,4 +88,110 @@ describe('Codex Reasoning Proxy', () => {
});
});
});
describe('stripPathPrefix (remote mode)', () => {
// Tests the path prefix stripping logic used in remote proxy mode
// Remote CLIProxyAPI expects /v1/messages, but Claude sends /api/provider/codex/v1/messages
/**
* Updated to match the version in codex-reasoning-proxy.ts with boundary check.
* Only strips if prefix matches a complete path segment (not partial like /codex matching /codextra)
*/
function stripPathPrefix(path, prefix) {
if (
prefix &&
path.startsWith(prefix) &&
(path.length === prefix.length || path[prefix.length] === '/')
) {
let stripped = path.slice(prefix.length);
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
stripped = stripped.replace(/^\/+/, '/') || '/';
if (!stripped.startsWith('/')) {
stripped = '/' + stripped;
}
return stripped;
}
return path;
}
it('strips /api/provider/codex prefix for remote mode', () => {
const result = stripPathPrefix('/api/provider/codex/v1/messages', '/api/provider/codex');
assert.strictEqual(result, '/v1/messages');
});
it('returns root path when prefix equals full path', () => {
const result = stripPathPrefix('/api/provider/codex', '/api/provider/codex');
assert.strictEqual(result, '/');
});
it('leaves path unchanged when prefix is undefined', () => {
const result = stripPathPrefix('/v1/messages', undefined);
assert.strictEqual(result, '/v1/messages');
});
it('leaves path unchanged when prefix does not match', () => {
const result = stripPathPrefix('/v1/messages', '/api/provider/codex');
assert.strictEqual(result, '/v1/messages');
});
it('handles empty prefix', () => {
const result = stripPathPrefix('/v1/messages', '');
assert.strictEqual(result, '/v1/messages');
});
it('handles various provider prefixes', () => {
// Gemini
assert.strictEqual(
stripPathPrefix('/api/provider/gemini/v1/chat', '/api/provider/gemini'),
'/v1/chat'
);
// Agy
assert.strictEqual(
stripPathPrefix('/api/provider/agy/v1/messages', '/api/provider/agy'),
'/v1/messages'
);
});
it('preserves query strings after stripping', () => {
const result = stripPathPrefix('/api/provider/codex/v1/messages?stream=true', '/api/provider/codex');
assert.strictEqual(result, '/v1/messages?stream=true');
});
// Edge cases for path normalization
it('collapses double slashes after stripping', () => {
// e.g., '/api/provider/codex//v1/messages' → '/v1/messages'
const result = stripPathPrefix('/api/provider/codex//v1/messages', '/api/provider/codex');
assert.strictEqual(result, '/v1/messages');
});
it('handles multiple leading slashes after stripping', () => {
// e.g., '/api/provider/codex///v1' → '/v1'
const result = stripPathPrefix('/api/provider/codex///v1', '/api/provider/codex');
assert.strictEqual(result, '/v1');
});
it('adds leading slash if missing after strip', () => {
const result = stripPathPrefix('/prefix/suffix', '/prefix');
assert.strictEqual(result, '/suffix');
});
// Boundary check tests - prevent partial segment matching
it('does NOT strip partial path segment matches', () => {
// /codex should NOT match /codextra
const result = stripPathPrefix('/api/provider/codextra/v1/messages', '/api/provider/codex');
assert.strictEqual(result, '/api/provider/codextra/v1/messages');
});
it('does NOT strip when prefix matches but next char is not slash', () => {
// /api should NOT match /api-v2
const result = stripPathPrefix('/api-v2/messages', '/api');
assert.strictEqual(result, '/api-v2/messages');
});
it('strips when prefix matches exactly with slash boundary', () => {
// /api/provider/codex should match /api/provider/codex/v1
const result = stripPathPrefix('/api/provider/codex/v1', '/api/provider/codex');
assert.strictEqual(result, '/v1');
});
});
});
@@ -0,0 +1,619 @@
/**
* HTTPS Tunnel Proxy Tests
*
* Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI.
* Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL.
*/
import { describe, it, expect, afterEach } from 'bun:test';
import * as http from 'http';
import * as https from 'https';
import type { AddressInfo } from 'net';
// Import the class under test
import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy';
/**
* Self-signed certificate for testing HTTPS servers.
* Generated with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
*/
const TEST_CERT = {
key: `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1Ji8aq3YnxiRC
i5syvghdG+08f9Gc1C55UiNZc5zxY6Ij73pg72fWO614WH5MT3GeeDHdA4jF/xxZ
tgLecJ14L5CyqpbcFYnayjRS5WjWfG40VOsVAf5OiJem6YL4Yu9EExO1MxhzIQmW
fqijCSBVPiYkJ9CoT1EuUMPBudWkxs5NQHUYJu2Hq/mG89W+yMuT+Yp9YKau1snb
x0I4aqf+OBJKrlWEZ+tgcTbyWW0bvQv8Ou9cFZjsXQF8jXBHJ97/LPW850jKt76J
6PjIZeaTScZo9Py/fSIf8+4XYFHr3TVmUpoa1f1jmp+kE0H51+KDnqbcYCB6EwFm
UFRaJ6ZtAgMBAAECggEAB5j3DMqi2qmIHR+3KKT+ZiP6X+PfKhFeyZ5/oQwk9Egr
erpb4EjqNVACHIle8rBk9t0vqjIFwILMm5lIptpY9bt4+XqyImo81+eh04A6VL9E
l/6fxXJ0n11B5Fw9g/wSRkFOkvZLpjh9Kx9befWzXMqN1aJd2/vyTwZQJNs4cgAF
BUppw0PG1ujLXl48pNoGqMVLALWA1XwlexBxh6EgU9rsar9desqqhF1pZIAimB4x
pvQSPqENFjCOMY93RRvZHITE37Y61BiofdHHqIDLAqYZLHlq+rUupYe9auUHMpaD
KFV99x9gfVS9l0aqzxXw+nqar9o1+h5lWO2hCw3HgQKBgQD1n19aJ3z1TC1bRLGV
H1UTgx/TAfFsXB7S3RO+Tkfhn+g169ByOWRELIGptOSBYauo/N29AYNHjNJPZDYM
sraJfyLStrcCTzXcum1Xfx6rjyq5LU5D93F6ZXGBlVrLmk/+YEep65g0X82VzbPN
9aIKW2kBLKuH2O9MwaqWko+ZEQKBgQC8zXlgsq2fkKeyRJfIAN8+Wx2Kd1n5KPbl
nvbj9k59oYBz1yggj9v6vjfo9MrgZxp5LmR5UgGsSFpqXpkA7SDYCvKZUwQj/eIx
LhV6NG+SnbFKtinIuh9GHiEKGNxJsRL7ZljVjW6f6C4f4MeyEW2IpE5AMAaCfpUS
JxI3afJXnQKBgBcKsGNAuRQ55Tdeploa6lw+PMoKsJ89tRaK7sM3jL65xYrpaFCO
2b0bf75v3c/VXckoj5Sfg7U+nKwd9oQSb9VOO/IQefKZg7AFPSSsJDBr6dIdUe5G
VDrrMU66uB3JiB+Q4KgsFcc0BZE8DtYPaPgXwy39Bspjq29D68DcVuRBAoGAZbA9
qalS/lhJGij7nwtpMgqdNJDn8tzvbelajJmC2QN9Tecag783+istrdj61DZz+cTU
9MsIf6RQnm3o9qjBQdtTouUlm8UIaPirNLC9TziD3vuSMbydT4S2wtt0+nPXB3Su
cAbHCHVjMmQ86lmcpzXnt4amWu6Wl7pXg2Ua07kCgYEA8XMfXql48oUVIr+cN5AN
nnql5ojMi2Vp9SeTDM/LKCJ9HORCKi4DyHqm06OjDFi2Al57DVsLHpxENWVSgUmp
OpcE1P2kqmDqQguFg9GUPX38zspijdVBd1rtpPfHsAu+ZvRWT8ozFLKZ3Xjg1fIx
TL6BeOBii9TlZ66SlZT5HRM=
-----END PRIVATE KEY-----`,
cert: `-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUfoHoOgjjiqPNOxpbp7jHBFSfTeEwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExNDE1MzAyMFoXDTI3MDEx
NDE1MzAyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAtSYvGqt2J8YkQoubMr4IXRvtPH/RnNQueVIjWXOc8WOi
I+96YO9n1juteFh+TE9xnngx3QOIxf8cWbYC3nCdeC+QsqqW3BWJ2so0UuVo1nxu
NFTrFQH+ToiXpumC+GLvRBMTtTMYcyEJln6oowkgVT4mJCfQqE9RLlDDwbnVpMbO
TUB1GCbth6v5hvPVvsjLk/mKfWCmrtbJ28dCOGqn/jgSSq5VhGfrYHE28lltG70L
/DrvXBWY7F0BfI1wRyfe/yz1vOdIyre+iej4yGXmk0nGaPT8v30iH/PuF2BR6901
ZlKaGtX9Y5qfpBNB+dfig56m3GAgehMBZlBUWiembQIDAQABo1MwUTAdBgNVHQ4E
FgQUHvZklBlcvTOIuN/xSO7BP7rgSqswHwYDVR0jBBgwFoAUHvZklBlcvTOIuN/x
SO7BP7rgSqswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWRj4
bB1eObtMOal4VPmL5iX07XzL4hp6Dwu2LSrw9KMArqTTeyJEGNSsykuHPZwPIflD
JkzfrFfDv8q7YDpSLy9vJN2E+SPn/Oq2BehUmD+uURghaoSsXeyY9Kv6vGZri95l
rgg+6wLJDVrnw5tKxEHx5hUyVR3Ms4LwU/hwAcCGCxx5exhvLpfjjxGBR814kCEc
IKGISNjqDo1Pz1Xm8QBLzG4CtlzE/QEbkJKImmwskv6vvoRbWg+B529WzFFCReYK
/sULgpvG29Uc3MwZK242dKyTUFdI6tQuZ8xierXwP0kIFlP2phtkgE9kjQJqrtRZ
fago/IeVI/sKlApDxA==
-----END CERTIFICATE-----`,
};
describe('HttpsTunnelProxy', () => {
let tunnel: HttpsTunnelProxy | null = null;
let mockServer: https.Server | null = null;
let mockServerPort: number = 0;
afterEach(async () => {
// Clean up tunnel
if (tunnel) {
tunnel.stop();
tunnel = null;
}
// Clean up mock server
if (mockServer) {
await new Promise<void>((resolve) => {
mockServer!.close(() => resolve());
});
mockServer = null;
}
});
describe('constructor', () => {
it('should apply default values for optional config', () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
// The proxy should be created without error
expect(tunnel).toBeDefined();
expect(tunnel.getPort()).toBeNull(); // Not started yet
});
it('should accept custom config values', () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'custom.host.com',
remotePort: 8443,
authToken: 'test-token',
timeoutMs: 30000,
verbose: true,
allowSelfSigned: true,
});
expect(tunnel).toBeDefined();
});
// Hostname validation tests
it('should throw error for empty hostname', () => {
expect(() => {
new HttpsTunnelProxy({ remoteHost: '' });
}).toThrow('Invalid remoteHost format');
});
it('should throw error for hostname with protocol prefix', () => {
expect(() => {
new HttpsTunnelProxy({ remoteHost: 'https://example.com' });
}).toThrow('Invalid remoteHost format');
});
it('should throw error for hostname with spaces', () => {
expect(() => {
new HttpsTunnelProxy({ remoteHost: 'example .com' });
}).toThrow('Invalid remoteHost format');
});
it('should throw error for hostname with invalid characters', () => {
expect(() => {
new HttpsTunnelProxy({ remoteHost: 'example@com' });
}).toThrow('Invalid remoteHost format');
});
it('should accept valid hostnames', () => {
// Standard domain
expect(() => new HttpsTunnelProxy({ remoteHost: 'example.com' })).not.toThrow();
// Subdomain
expect(() => new HttpsTunnelProxy({ remoteHost: 'api.example.com' })).not.toThrow();
// With dashes
expect(() => new HttpsTunnelProxy({ remoteHost: 'my-api.example-site.com' })).not.toThrow();
// IP-like
expect(() => new HttpsTunnelProxy({ remoteHost: '192.168.1.1' })).not.toThrow();
// Localhost
expect(() => new HttpsTunnelProxy({ remoteHost: 'localhost' })).not.toThrow();
// Single char hostname
expect(() => new HttpsTunnelProxy({ remoteHost: 'a' })).not.toThrow();
});
});
describe('start()', () => {
it('should start server and return valid port', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
const port = await tunnel.start();
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
expect(tunnel.getPort()).toBe(port);
});
it('should return same port on subsequent start() calls', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
const port1 = await tunnel.start();
const port2 = await tunnel.start();
expect(port1).toBe(port2);
});
it('should bind to localhost only (127.0.0.1)', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
const port = await tunnel.start();
// Try to connect - should work on localhost
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.get(`http://127.0.0.1:${port}/test`, resolve);
req.on('error', reject);
req.setTimeout(1000);
}).catch((err) => err);
// We expect an error because there's no upstream, but connection should be accepted
// If binding failed, we'd get ECONNREFUSED before any response
expect(response).toBeDefined();
});
});
describe('stop()', () => {
it('should clear port after stop', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
await tunnel.start();
expect(tunnel.getPort()).not.toBeNull();
tunnel.stop();
expect(tunnel.getPort()).toBeNull();
});
it('should be idempotent (safe to call multiple times)', () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
// Stop without start - should not throw
tunnel.stop();
tunnel.stop();
tunnel.stop();
expect(tunnel.getPort()).toBeNull();
});
it('should allow restart after stop', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
const port1 = await tunnel.start();
tunnel.stop();
const port2 = await tunnel.start();
expect(port2).toBeGreaterThan(0);
// Ports may or may not be the same depending on OS port reuse
});
});
describe('getPort()', () => {
it('should return null before start', () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
expect(tunnel.getPort()).toBeNull();
});
it('should return valid port after start', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
});
await tunnel.start();
const port = tunnel.getPort();
expect(port).not.toBeNull();
expect(port).toBeGreaterThan(0);
});
});
describe('buildForwardHeaders (Authorization injection)', () => {
// We test this indirectly through the proxy behavior
// The buildForwardHeaders method is private, so we verify via integration
it('should forward existing Authorization header', async () => {
// This test requires a mock HTTPS server
// For now, we document the expected behavior
const config: HttpsTunnelConfig = {
remoteHost: 'example.com',
authToken: 'fallback-token',
};
tunnel = new HttpsTunnelProxy(config);
await tunnel.start();
// The tunnel should:
// 1. Forward 'Authorization' header if present in request
// 2. Inject 'Authorization: Bearer fallback-token' if not present
expect(tunnel.getPort()).toBeGreaterThan(0);
});
});
describe('hop-by-hop headers filtering', () => {
// RFC 7230 hop-by-hop headers should be filtered
const hopByHopHeaders = [
'host',
'connection',
'transfer-encoding',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'upgrade',
];
it('should define all RFC 7230 hop-by-hop headers for filtering', () => {
// Document expected filtered headers
expect(hopByHopHeaders).toContain('connection');
expect(hopByHopHeaders).toContain('transfer-encoding');
expect(hopByHopHeaders).toContain('keep-alive');
});
});
describe('connection tracking', () => {
it('should track active connections for cleanup', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
verbose: false,
});
const port = await tunnel.start();
// Create a connection
const net = await import('net');
const socket = new net.Socket();
// Use a promise that resolves when connection is established
await new Promise<void>((resolve, reject) => {
socket.on('error', reject);
socket.connect(port, '127.0.0.1', () => resolve());
});
// Give the server time to register the connection
await new Promise((r) => setTimeout(r, 50));
// Stop should forcefully close all connections and the server
tunnel.stop();
// Verify stop() was called successfully (server is null after stop)
// The key behavior is that stop() destroys server-side sockets
// and clears activeConnections - we verify by checking getPort() returns null
expect(tunnel.getPort()).toBe(null);
// Clean up client socket
socket.destroy();
});
});
describe('error handling', () => {
it(
'should handle upstream timeout',
async () => {
// Create an HTTPS server that accepts connections but delays response
// beyond the tunnel's timeout. This tests the socket-level timeout.
const slowServer = https.createServer(
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
(req, res) => {
// Delay response beyond tunnel timeout (500ms)
// The tunnel should timeout before this completes
setTimeout(() => {
res.writeHead(200);
res.end('too late');
}, 2000);
}
);
await new Promise<void>((resolve) => {
slowServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowServer.address() as AddressInfo).port;
try {
tunnel = new HttpsTunnelProxy({
remoteHost: '127.0.0.1',
remotePort: slowPort,
timeoutMs: 500, // Short timeout - server responds after 2000ms
allowSelfSigned: true,
});
const port = await tunnel.start();
// Make request - should timeout because server delays 2s but tunnel times out at 500ms
const response = await new Promise<http.IncomingMessage | Error>((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/v1/messages',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
resolve
);
req.on('error', (err) => resolve(err)); // Resolve with error instead of reject
req.setTimeout(3000); // Client timeout longer than tunnel timeout
req.write('{}');
req.end();
});
// Either 502 response or connection error is acceptable
// The tunnel should have timed out and returned 502 or closed the connection
expect(response).toBeDefined();
if (response instanceof http.IncomingMessage) {
expect(response.statusCode).toBe(502);
}
} finally {
slowServer.close();
}
},
{ timeout: 10000 }
);
it('should handle client disconnect (premature close)', async () => {
// Create a slow HTTPS server that holds connection
// HttpsTunnelProxy uses https.request(), so we need an HTTPS server
const slowServer = https.createServer(
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
(req, res) => {
// Wait before responding
setTimeout(() => {
res.writeHead(200);
res.end('ok');
}, 2000);
}
);
await new Promise<void>((resolve) => {
slowServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowServer.address() as AddressInfo).port;
try {
tunnel = new HttpsTunnelProxy({
remoteHost: '127.0.0.1',
remotePort: slowPort,
timeoutMs: 10000,
allowSelfSigned: true,
});
const port = await tunnel.start();
// Make request and immediately abort
const req = http.request({
hostname: '127.0.0.1',
port,
path: '/v1/messages',
method: 'POST',
});
req.write('{}');
req.end();
// Abort after a short delay to trigger premature close
await new Promise((resolve) => setTimeout(resolve, 50));
req.destroy();
// Give time for error handling
await new Promise((resolve) => setTimeout(resolve, 100));
// Tunnel should still be operational
expect(tunnel.getPort()).toBe(port);
} finally {
slowServer.close();
}
});
it('should handle client request error', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'example.com',
remotePort: 443,
timeoutMs: 5000,
});
const port = await tunnel.start();
// Create socket and send malformed request
const net = await import('net');
const socket = new net.Socket();
await new Promise<void>((resolve, reject) => {
socket.connect(port, '127.0.0.1', () => {
// Send partial HTTP request then destroy
socket.write('POST /test HTTP/1.1\r\n');
socket.write('Content-Length: 100\r\n\r\n'); // Claim 100 bytes
socket.write('partial'); // Only send partial body
socket.destroy(); // Trigger error
resolve();
});
socket.on('error', () => resolve()); // Ignore socket errors
});
// Give time for error handling
await new Promise((resolve) => setTimeout(resolve, 100));
// Tunnel should still be operational
expect(tunnel.getPort()).toBe(port);
});
it('should handle upstream connection errors gracefully', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'nonexistent.invalid.host',
remotePort: 12345,
timeoutMs: 1000,
});
const port = await tunnel.start();
// Make request to tunnel - should get 502 error
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
resolve
);
req.on('error', reject);
req.setTimeout(5000);
req.write('{}');
req.end();
});
expect(response.statusCode).toBe(502);
});
it('should return JSON error response', async () => {
tunnel = new HttpsTunnelProxy({
remoteHost: 'nonexistent.invalid.host',
remotePort: 12345,
timeoutMs: 1000,
});
const port = await tunnel.start();
const body = await new Promise<string>((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/v1/messages',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
}
);
req.on('error', reject);
req.setTimeout(5000);
req.write('{}');
req.end();
});
const parsed = JSON.parse(body);
expect(parsed).toHaveProperty('error');
expect(typeof parsed.error).toBe('string');
});
});
describe('config interface', () => {
it('should export HttpsTunnelConfig type', () => {
const config: HttpsTunnelConfig = {
remoteHost: 'test.com',
remotePort: 443,
authToken: 'token',
timeoutMs: 60000,
verbose: false,
allowSelfSigned: false,
};
expect(config.remoteHost).toBe('test.com');
expect(config.remotePort).toBe(443);
});
it('should allow minimal config with only remoteHost', () => {
const config: HttpsTunnelConfig = {
remoteHost: 'minimal.test.com',
};
expect(config.remoteHost).toBe('minimal.test.com');
expect(config.remotePort).toBeUndefined();
expect(config.authToken).toBeUndefined();
});
});
});
describe('HttpsTunnelProxy stripPathPrefix integration with CodexReasoningProxy', () => {
// Document the integration pattern between HttpsTunnelProxy and CodexReasoningProxy
// In remote mode, the path flow is:
// Claude → CodexReasoningProxy → HttpsTunnelProxy → Remote CLIProxyAPI
//
// CodexReasoningProxy strips /api/provider/codex prefix before forwarding
// HttpsTunnelProxy then tunnels HTTP→HTTPS to remote server
it('should document path transformation for remote mode', () => {
// Remote CLIProxyAPI expects: /v1/messages
// Local CLIProxy expects: /api/provider/codex/v1/messages
//
// CodexReasoningProxy.stripPathPrefix handles this transformation:
// Input: /api/provider/codex/v1/messages
// Output: /v1/messages
const inputPath = '/api/provider/codex/v1/messages';
const prefix = '/api/provider/codex';
const expectedOutput = '/v1/messages';
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
expect(result).toBe(expectedOutput);
});
it('should handle root path after prefix strip', () => {
const inputPath = '/api/provider/codex';
const prefix = '/api/provider/codex';
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
expect(result).toBe('/');
});
});
@@ -0,0 +1,422 @@
/**
* Remote Token Uploader Tests
*
* Tests for uploadTokenToRemote and related functions.
* Uses a local HTTP server to mock the remote CLIProxyAPI.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { AddressInfo } from 'net';
// We need to mock getProxyTarget before importing the module
// Since the module reads config at import time, we'll test the pure functions
describe('remote-token-uploader', () => {
let mockServer: http.Server | null = null;
let mockServerPort: number = 0;
let tempDir: string;
let tempTokenFile: string;
beforeEach(async () => {
// Create temp directory and token file
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
tempTokenFile = path.join(tempDir, 'test-token.json');
fs.writeFileSync(
tempTokenFile,
JSON.stringify({
type: 'gemini',
email: 'test@example.com',
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
})
);
});
afterEach(async () => {
// Clean up mock server
if (mockServer) {
await new Promise<void>((resolve) => {
mockServer!.close(() => resolve());
});
mockServer = null;
}
// Clean up temp files
try {
fs.unlinkSync(tempTokenFile);
fs.rmdirSync(tempDir);
} catch {
// Ignore cleanup errors
}
});
describe('uploadTokenToRemote', () => {
it('should upload token file successfully', async () => {
// Create mock server that accepts uploads
let receivedRequest: {
method: string;
path: string;
headers: http.IncomingHttpHeaders;
body: string;
} | null = null;
mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
receivedRequest = {
method: req.method || '',
path: req.url || '',
headers: req.headers,
body,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', id: 'uploaded-123' }));
});
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
// Mock getProxyTarget to return our test server
const mockProxyTarget = {
isRemote: true,
host: `127.0.0.1:${mockServerPort}`,
protocol: 'http' as const,
authToken: 'test-auth-token',
managementKey: 'test-mgmt-key',
};
// Dynamically import and test
// We need to test the fetch logic directly since module caches getProxyTarget
const url = `http://${mockProxyTarget.host}/v0/management/auth-files`;
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
const fileName = path.basename(tempTokenFile);
const formData = new FormData();
const blob = new Blob([tokenContent], { type: 'application/json' });
formData.append('file', blob, fileName);
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${mockProxyTarget.managementKey}`,
},
body: formData,
});
expect(response.ok).toBe(true);
const result = await response.json();
expect(result).toHaveProperty('status', 'ok');
expect(result).toHaveProperty('id', 'uploaded-123');
// Verify request was received correctly
expect(receivedRequest).not.toBeNull();
expect(receivedRequest!.method).toBe('POST');
expect(receivedRequest!.path).toBe('/v0/management/auth-files');
expect(receivedRequest!.headers['authorization']).toBe('Bearer test-mgmt-key');
});
it('should handle upload failure gracefully', async () => {
// Create mock server that returns error
mockServer = http.createServer((req, res) => {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const url = `http://127.0.0.1:${mockServerPort}/v0/management/auth-files`;
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
const formData = new FormData();
formData.append('file', new Blob([tokenContent], { type: 'application/json' }), 'test.json');
const response = await fetch(url, {
method: 'POST',
body: formData,
});
expect(response.ok).toBe(false);
expect(response.status).toBe(401);
});
it('should handle connection timeout', async () => {
// Create server that never responds
mockServer = http.createServer(() => {
// Never respond
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const url = `http://127.0.0.1:${mockServerPort}/v0/management/auth-files`;
const controller = new AbortController();
// Set short timeout
const timeoutId = setTimeout(() => controller.abort(), 100);
try {
await fetch(url, {
method: 'POST',
body: new FormData(),
signal: controller.signal,
});
// Should not reach here
expect(true).toBe(false);
} catch (error) {
expect((error as Error).name).toBe('AbortError');
} finally {
clearTimeout(timeoutId);
}
});
it('should handle connection refused', async () => {
// Try to connect to a port with nothing listening
const url = 'http://127.0.0.1:59999/v0/management/auth-files';
try {
await fetch(url, {
method: 'POST',
body: new FormData(),
});
// Should not reach here in most cases
} catch (error) {
// Connection refused is expected
expect(error).toBeDefined();
}
});
});
describe('isRemoteUploadEnabled', () => {
it('should return false when not in remote mode', () => {
// Test the logic directly
const target = { isRemote: false, authToken: 'token' };
const result = target.isRemote && Boolean(target.authToken);
expect(result).toBe(false);
});
it('should return false when remote but no auth', () => {
const target = { isRemote: true, authToken: undefined, managementKey: undefined };
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
expect(result).toBe(false);
});
it('should return true when remote with authToken', () => {
const target = { isRemote: true, authToken: 'token', managementKey: undefined };
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
expect(result).toBe(true);
});
it('should return true when remote with managementKey', () => {
const target = { isRemote: true, authToken: undefined, managementKey: 'mgmt-key' };
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
expect(result).toBe(true);
});
it('should prefer managementKey over authToken', () => {
const target = { isRemote: true, authToken: 'auth', managementKey: 'mgmt' };
const key = target.managementKey ?? target.authToken;
expect(key).toBe('mgmt');
});
});
describe('token file validation', () => {
it('should reject invalid JSON', async () => {
// Create invalid token file
const invalidTokenFile = path.join(tempDir, 'invalid.json');
fs.writeFileSync(invalidTokenFile, 'not valid json {{{');
try {
const content = fs.readFileSync(invalidTokenFile, 'utf-8');
JSON.parse(content);
expect(true).toBe(false); // Should not reach
} catch (error) {
expect((error as Error).message).toContain('JSON');
} finally {
fs.unlinkSync(invalidTokenFile);
}
});
it('should handle missing file', () => {
try {
fs.readFileSync('/nonexistent/path/token.json', 'utf-8');
expect(true).toBe(false); // Should not reach
} catch (error) {
expect((error as Error).message).toContain('ENOENT');
}
});
});
describe('multipart/form-data construction', () => {
it('should construct valid FormData with file field', () => {
const tokenContent = JSON.stringify({ type: 'test', token: 'abc' });
const fileName = 'oauth-token.json';
const formData = new FormData();
const blob = new Blob([tokenContent], { type: 'application/json' });
formData.append('file', blob, fileName);
// FormData should have the file
expect(formData.has('file')).toBe(true);
const file = formData.get('file') as File;
expect(file).toBeDefined();
expect(file.name).toBe(fileName);
expect(file.type).toContain('application/json');
});
});
describe('Authorization header', () => {
it('should use Bearer token format', async () => {
let capturedAuth: string | null = null;
mockServer = http.createServer((req, res) => {
capturedAuth = req.headers['authorization'] as string;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const authToken = 'my-secret-token-123';
await fetch(`http://127.0.0.1:${mockServerPort}/test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
},
body: new FormData(),
});
expect(capturedAuth).toBe(`Bearer ${authToken}`);
});
it('should not send Authorization when no token', async () => {
let hasAuth = false;
mockServer = http.createServer((req, res) => {
hasAuth = 'authorization' in req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
await fetch(`http://127.0.0.1:${mockServerPort}/test`, {
method: 'POST',
body: new FormData(),
});
expect(hasAuth).toBe(false);
});
});
describe('response parsing', () => {
it('should accept status: ok response', async () => {
mockServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
const isSuccess = result.status === 'ok' || result.success || result.id;
expect(isSuccess).toBe(true);
});
it('should accept success: true response', async () => {
mockServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
const isSuccess = result.status === 'ok' || result.success || result.id;
expect(isSuccess).toBe(true);
});
it('should accept id in response', async () => {
mockServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'file-abc123' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
// result.id is truthy when present
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
expect(isSuccess).toBe(true);
});
it('should detect error response', async () => {
mockServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid token format' }));
});
await new Promise<void>((resolve) => {
mockServer!.listen(0, '127.0.0.1', () => resolve());
});
mockServerPort = (mockServer.address() as AddressInfo).port;
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
const result = (await response.json()) as {
status?: string;
success?: boolean;
id?: string;
error?: string;
};
// Error response: none of the success indicators are present
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
expect(isSuccess).toBe(false);
expect(result.error).toBe('Invalid token format');
});
});
});
@@ -10,7 +10,7 @@ import {
getMinClaudeQuota,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Clock } from 'lucide-react';
import { GripVertical, Loader2, Clock, Pause } from 'lucide-react';
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -202,9 +202,16 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
quota.error === 'Account is paused' ? (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<Pause className="w-3 h-3" />
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
</div>
) : (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
)
) : null}
</div>
)}
@@ -25,6 +25,8 @@ import {
Pause,
Play,
AlertCircle,
AlertTriangle,
FolderCode,
} from 'lucide-react';
import {
cn,
@@ -167,6 +169,54 @@ export function AccountItem({
</Badge>
)}
</div>
{/* Project ID for Antigravity accounts - read-only */}
{account.provider === 'agy' && (
<div className="flex items-center gap-1.5 mt-1">
{account.projectId ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={account.projectId}
>
{account.projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>
This may cause errors. Remove the account and re-add it to fetch the
project ID.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
@@ -3,10 +3,11 @@
*/
import type React from 'react';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { ProviderStats } from '../types';
import { getSuccessRate, cleanEmail } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
@@ -102,16 +103,35 @@ export function ProviderCard({
</div>
</div>
{/* Account color dots */}
<div className="flex gap-1 mt-3">
{stats.accounts.slice(0, 5).map((acc) => (
<div
key={acc.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
))}
{/* Account color dots with warning for agy accounts missing projectId */}
<div className="flex gap-1 mt-3 items-center">
{stats.accounts.slice(0, 5).map((acc) => {
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
return (
<div key={acc.id} className="relative">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
{isMissingProjectId && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
aria-label="Missing Project ID"
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Missing Project ID - re-add account to fix
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
})}
{stats.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{stats.accounts.length - 5}
@@ -99,6 +99,7 @@ export function useAuthMonitorData(): AuthMonitorData {
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
projectId: account.projectId,
};
accountsList.push(row);
providerData.accounts.push(row);
@@ -12,6 +12,8 @@ export interface AccountRow {
failureCount: number;
lastUsedAt?: string;
color: string;
/** GCP Project ID (Antigravity only) - read-only */
projectId?: string;
}
export interface ProviderStats {
+2
View File
@@ -83,6 +83,8 @@ export interface OAuthAccount {
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: 'free' | 'paid' | 'unknown';
/** GCP Project ID (Antigravity only) - read-only */
projectId?: string;
}
export interface AuthStatus {