diff --git a/package.json b/package.json index 451198af..cd4be086 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index fc450c7b..6cb11363 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -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 = { 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 = { 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; } } } diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 436539cc..1e31273c 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -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 */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 764db7ca..7e0a9ed8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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); diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 6412c93d..6931288b 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -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; + Pick; private readonly modelEffort: Map; 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 { 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 { 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 { 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, diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index e2d9c40f..a00c9632 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -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(); diff --git a/src/cliproxy/https-tunnel-proxy.ts b/src/cliproxy/https-tunnel-proxy.ts new file mode 100644 index 00000000..81e17867 --- /dev/null +++ b/src/cliproxy/https-tunnel-proxy.ts @@ -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 | null = null; + private activeConnections = new Set(); + private readonly config: Required< + Pick< + HttpsTunnelConfig, + 'remoteHost' | 'remotePort' | 'timeoutMs' | 'verbose' | 'allowSelfSigned' + > + > & + Pick; + + 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 { + // 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 { + 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 { + 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); + }); + } +} diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index b009da92..df0accb9 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -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) { diff --git a/src/cliproxy/remote-token-uploader.ts b/src/cliproxy/remote-token-uploader.ts new file mode 100644 index 00000000..dd50324a --- /dev/null +++ b/src/cliproxy/remote-token-uploader.ts @@ -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 { + 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 = {}; + 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 { + 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); +} diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index a05675e3..a89072ac 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -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() || ''; diff --git a/tests/unit/cliproxy/codex-reasoning-proxy.test.js b/tests/unit/cliproxy/codex-reasoning-proxy.test.js index a9294ae7..f08bd33f 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy.test.js +++ b/tests/unit/cliproxy/codex-reasoning-proxy.test.js @@ -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'); + }); + }); }); diff --git a/tests/unit/cliproxy/https-tunnel-proxy.test.ts b/tests/unit/cliproxy/https-tunnel-proxy.test.ts new file mode 100644 index 00000000..808c274e --- /dev/null +++ b/tests/unit/cliproxy/https-tunnel-proxy.test.ts @@ -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((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((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((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((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((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((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((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((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((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('/'); + }); +}); diff --git a/tests/unit/cliproxy/remote-token-uploader.test.ts b/tests/unit/cliproxy/remote-token-uploader.test.ts new file mode 100644 index 00000000..4cd8c171 --- /dev/null +++ b/tests/unit/cliproxy/remote-token-uploader.test.ts @@ -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((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((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((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((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((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((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((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((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((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((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'); + }); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 1b07c358..7f7d2bd7 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -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({ ) : quota?.error ? ( -
- {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} -
+ quota.error === 'Account is paused' ? ( +
+ + Paused +
+ ) : ( +
+ {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} +
+ ) ) : null} )} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 031a01b3..2aec131d 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -25,6 +25,8 @@ import { Pause, Play, AlertCircle, + AlertTriangle, + FolderCode, } from 'lucide-react'; import { cn, @@ -167,6 +169,54 @@ export function AccountItem({ )} + {/* Project ID for Antigravity accounts - read-only */} + {account.provider === 'agy' && ( +
+ {account.projectId ? ( + + + +
+
+
+ +

GCP Project ID (read-only)

+
+
+
+ ) : ( + + + +
+ + Project ID: N/A +
+
+ +
+

Missing Project ID

+

+ This may cause errors. Remove the account and re-add it to fetch the + project ID. +

+
+
+
+
+ )} +
+ )} {account.lastUsedAt && (
diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index d7faf5f9..56faedbc 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -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({
- {/* Account color dots */} -
- {stats.accounts.slice(0, 5).map((acc) => ( -
- ))} + {/* Account color dots with warning for agy accounts missing projectId */} +
+ {stats.accounts.slice(0, 5).map((acc) => { + const isMissingProjectId = stats.provider === 'agy' && !acc.projectId; + return ( +
+
+ {isMissingProjectId && ( + + + + + + + Missing Project ID - re-add account to fix + + + + )} +
+ ); + })} {stats.accounts.length > 5 && ( +{stats.accounts.length - 5} diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index 1640f037..dd19b8bc 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -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); diff --git a/ui/src/components/monitoring/auth-monitor/types.ts b/ui/src/components/monitoring/auth-monitor/types.ts index 1eb61953..408b1e71 100644 --- a/ui/src/components/monitoring/auth-monitor/types.ts +++ b/ui/src/components/monitoring/auth-monitor/types.ts @@ -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 { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 22d67bb0..95ce900c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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 {