diff --git a/package.json b/package.json index edf9f0e9..3f79cc3d 100644 --- a/package.json +++ b/package.json @@ -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/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 436539cc..7f357b88 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 { @@ -230,12 +231,50 @@ export function registerAccountFromToken( const data = JSON.parse(content); const email = data.email || undefined; - return registerAccount(provider, newestFile, email, nickname || generateNickname(email)); + const account = registerAccount( + provider, + newestFile, + email, + nickname || generateNickname(email) + ); + + // 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 logging for success/failure + uploadTokenToRemote(tokenPath, verbose).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}`); + }); + } + }) + .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 03c8c04f..6f05ca57 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 = { @@ -691,17 +692,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. @@ -720,6 +760,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, @@ -733,6 +776,7 @@ export async function execClaudeWithCLIProxy( sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, }, + stripPathPrefix, }); codexReasoningPort = await codexReasoningProxy.start(); log( @@ -835,6 +879,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); @@ -855,6 +903,10 @@ export async function execClaudeWithCLIProxy( codexReasoningProxy.stop(); } + if (httpsTunnel) { + httpsTunnel.stop(); + } + // Unregister session, proxy keeps running (local mode only) if (sessionId) { unregisterSession(sessionId, sessionPort); @@ -870,6 +922,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..f5cd76db --- /dev/null +++ b/src/cliproxy/https-tunnel-proxy.ts @@ -0,0 +1,246 @@ +/** + * 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 + */ + +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 starting = false; + 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 { + // Prevent race condition with concurrent start() calls + if (this.server || this.starting) return this.port ?? 0; + this.starting = true; + + return 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; + this.starting = false; + if (this.port === 0) { + 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.starting = false; + reject(err); + }); + }); + } + + 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.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', () => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }); + + 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/remote-token-uploader.ts b/src/cliproxy/remote-token-uploader.ts new file mode 100644 index 00000000..f5e829db --- /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 X-Management-Key header (CLIProxyAPI requirement) + 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..08690b94 --- /dev/null +++ b/tests/unit/cliproxy/https-tunnel-proxy.test.ts @@ -0,0 +1,551 @@ +/** + * 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, beforeEach, afterEach } from 'bun:test'; +import * as http from 'http'; +import * as https from 'https'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { AddressInfo } from 'net'; + +// Import the class under test +import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy'; + +describe('HttpsTunnelProxy', () => { + let tunnel: HttpsTunnelProxy | null = null; + let mockServer: https.Server | null = null; + let mockServerPort: number = 0; + + // Self-signed certificate for testing (generated inline) + const selfSignedCert = { + key: `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7MJnE6J3cELkN +k4Jn0HmkF1K9VvWHzQp3F5EqOPvO5d7X5qQvQFv1M8UY8LZI9u5X5T0FJ3XKQK9F +1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 +-----END PRIVATE KEY-----`, + cert: `-----BEGIN CERTIFICATE----- +MIIC+TCCAeGgAwIBAgIJAKHBfpegQr2EMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV +BAMMCGxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMx +-----END CERTIFICATE-----`, + }; + + 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 socket = new (await import('net')).Socket(); + const connectPromise = new Promise((resolve, reject) => { + socket.connect(port, '127.0.0.1', () => resolve()); + socket.on('error', reject); + }); + + await connectPromise; + + // Stop should forcefully close connections + tunnel.stop(); + + // Socket should be destroyed + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(socket.destroyed).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle upstream timeout', async () => { + // Create a server that never responds to trigger timeout + const hangingServer = http.createServer(() => { + // Never respond - let it hang + }); + + await new Promise((resolve) => { + hangingServer.listen(0, '127.0.0.1', () => resolve()); + }); + + const hangingPort = (hangingServer.address() as AddressInfo).port; + + try { + tunnel = new HttpsTunnelProxy({ + remoteHost: '127.0.0.1', + remotePort: hangingPort, + timeoutMs: 100, // Very short timeout + allowSelfSigned: true, + }); + + const port = await tunnel.start(); + + // Make request - should timeout + 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(); + }).catch((err) => err); + + // Either 502 response or error is acceptable + expect(response).toBeDefined(); + } finally { + hangingServer.close(); + } + }); + + it('should handle client disconnect (premature close)', async () => { + // Create a slow server that holds connection + const slowServer = http.createServer((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..c09d31af --- /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, mock, spyOn } 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'); + }); + }); +});