From 7a9e67e6dc7de70709b6baaa3a9a1158b27fbd33 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 23 Apr 2026 22:54:47 -0400 Subject: [PATCH] fix: route codex remote https through local proxy chain --- src/cliproxy/codex-reasoning-proxy.ts | 80 ++++++-- src/cliproxy/executor/https-tunnel-policy.ts | 33 ++++ src/cliproxy/executor/index.ts | 17 +- src/cliproxy/tool-sanitization-proxy.ts | 98 +++++++--- .../unit/cliproxy/https-tunnel-policy.test.ts | 57 ++++++ .../remote-proxy-chain-regression.test.ts | 180 ++++++++++++++++++ 6 files changed, 416 insertions(+), 49 deletions(-) create mode 100644 src/cliproxy/executor/https-tunnel-policy.ts create mode 100644 tests/unit/cliproxy/https-tunnel-policy.test.ts create mode 100644 tests/unit/cliproxy/remote-proxy-chain-regression.test.ts diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index b24508e4..e3f98907 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -24,6 +24,8 @@ export interface CodexReasoningProxyConfig { modelMap: CodexReasoningModelMap; defaultEffort?: CodexReasoningEffort; traceFilePath?: string; + /** Skip TLS certificate validation for self-signed remote HTTPS proxies */ + allowSelfSigned?: boolean; /** * 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. @@ -179,6 +181,7 @@ export class CodexReasoningProxy { | 'defaultEffort' | 'traceFilePath' | 'disableEffort' + | 'allowSelfSigned' > > & Pick; @@ -203,6 +206,7 @@ export class CodexReasoningProxy { traceFilePath: config.traceFilePath ?? '', stripPathPrefix: config.stripPathPrefix, disableEffort: config.disableEffort ?? false, + allowSelfSigned: config.allowSelfSigned ?? false, }; this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } @@ -508,6 +512,38 @@ export class CodexReasoningProxy { return url.protocol === 'https:' ? https.request : http.request; } + private startResponseTimeout(upstreamReq: http.ClientRequest): () => void { + const deadline = setTimeout(() => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }, this.config.timeoutMs); + upstreamReq.setTimeout(this.config.timeoutMs, () => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }); + return () => clearTimeout(deadline); + } + + private buildRequestOptions( + upstreamUrl: URL, + method: string | undefined, + headers: http.OutgoingHttpHeaders + ): https.RequestOptions { + const options: https.RequestOptions = { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method, + timeout: this.config.timeoutMs, + headers, + }; + + if (upstreamUrl.protocol === 'https:' && this.config.allowSelfSigned) { + options.rejectUnauthorized = false; + } + + return options; + } + private forwardRaw( originalReq: http.IncomingMessage, clientRes: http.ServerResponse, @@ -515,17 +551,15 @@ export class CodexReasoningProxy { ): Promise { return new Promise((resolve, reject) => { const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - timeout: this.config.timeoutMs, - headers: this.buildForwardHeaders(originalReq.headers), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); upstreamRes.pipe(clientRes); upstreamRes.on('end', () => resolve()); @@ -533,8 +567,12 @@ export class CodexReasoningProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); originalReq.pipe(upstreamReq); }); } @@ -549,17 +587,15 @@ export class CodexReasoningProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - timeout: this.config.timeoutMs, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); const statusCode = upstreamRes.statusCode || 200; if (statusCode >= 200 && statusCode < 300) { clientRes.writeHead(statusCode, upstreamRes.headers); @@ -631,8 +667,12 @@ export class CodexReasoningProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); diff --git a/src/cliproxy/executor/https-tunnel-policy.ts b/src/cliproxy/executor/https-tunnel-policy.ts new file mode 100644 index 00000000..09cb8ec2 --- /dev/null +++ b/src/cliproxy/executor/https-tunnel-policy.ts @@ -0,0 +1,33 @@ +import type { CLIProxyProvider } from '../types'; + +interface HttpsTunnelPolicyInput { + provider: CLIProxyProvider; + useRemoteProxy: boolean; + protocol?: 'http' | 'https'; + host?: string; + isComposite?: boolean; +} + +/** + * Claude only needs the HTTPS tunnel when it would otherwise talk directly to a + * remote HTTPS CLIProxy. Single-provider Codex launches already route Claude + * through local HTTP proxy layers, so those layers can connect upstream via + * HTTPS without adding another local tunnel. + */ +export function shouldStartHttpsTunnel({ + provider, + useRemoteProxy, + protocol, + host, + isComposite, +}: HttpsTunnelPolicyInput): boolean { + if (!useRemoteProxy || protocol !== 'https' || !host) { + return false; + } + + if (provider === 'codex' && !isComposite) { + return false; + } + + return true; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 9c609e16..90db6209 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -126,6 +126,7 @@ import { resolveRuntimeThinkingOverride, shouldDisableCodexReasoning, } from './thinking-override-resolver'; +import { shouldStartHttpsTunnel } from './https-tunnel-policy'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -1017,7 +1018,15 @@ export async function execClaudeWithCLIProxy( let httpsTunnel: HttpsTunnelProxy | null = null; let tunnelPort: number | null = null; - if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) { + const useHttpsTunnel = shouldStartHttpsTunnel({ + provider, + useRemoteProxy, + protocol: proxyConfig.protocol, + host: proxyConfig.host, + isComposite: cfg.isComposite, + }); + + if (useHttpsTunnel && proxyConfig.host) { try { httpsTunnel = new HttpsTunnelProxy({ remoteHost: proxyConfig.host, @@ -1028,13 +1037,15 @@ export async function execClaudeWithCLIProxy( }); tunnelPort = await httpsTunnel.start(); log( - `HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}` + `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}`); } + } else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') { + log('HTTPS tunnel skipped for Codex; local proxy chain will connect to remote HTTPS directly'); } const imageAnalysisProxyTarget = @@ -1135,6 +1146,7 @@ export async function execClaudeWithCLIProxy( upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, verbose, warnOnSanitize: true, + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, }); toolSanitizationPort = await toolSanitizationProxy.start(); log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); @@ -1173,6 +1185,7 @@ export async function execClaudeWithCLIProxy( defaultEffort: 'medium', disableEffort: codexThinkingOff, traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 45114812..c839f7ab 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -37,6 +37,8 @@ export interface ToolSanitizationProxyConfig { warnOnSanitize?: boolean; /** Request timeout in milliseconds */ timeoutMs?: number; + /** Skip TLS certificate validation for self-signed remote HTTPS proxies */ + allowSelfSigned?: boolean; } /** @@ -162,6 +164,7 @@ export class ToolSanitizationProxy { verbose: config.verbose ?? false, warnOnSanitize: config.warnOnSanitize ?? true, timeoutMs: config.timeoutMs ?? 120000, + allowSelfSigned: config.allowSelfSigned ?? false, }; this.debugMode = process.env.CCS_DEBUG === '1'; this.logFilePath = this.initLogFile(); @@ -469,6 +472,38 @@ export class ToolSanitizationProxy { return url.protocol === 'https:' ? https.request : http.request; } + private startResponseTimeout(upstreamReq: http.ClientRequest): () => void { + const deadline = setTimeout(() => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }, this.config.timeoutMs); + upstreamReq.setTimeout(this.config.timeoutMs, () => { + upstreamReq.destroy(new Error('Upstream request timeout')); + }); + return () => clearTimeout(deadline); + } + + private buildRequestOptions( + upstreamUrl: URL, + method: string | undefined, + headers: http.OutgoingHttpHeaders + ): https.RequestOptions { + const options: https.RequestOptions = { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port, + path: upstreamUrl.pathname + upstreamUrl.search, + method, + timeout: this.config.timeoutMs, + headers, + }; + + if (upstreamUrl.protocol === 'https:' && this.config.allowSelfSigned) { + options.rejectUnauthorized = false; + } + + return options; + } + private forwardRaw( originalReq: http.IncomingMessage, clientRes: http.ServerResponse, @@ -476,16 +511,15 @@ export class ToolSanitizationProxy { ): Promise { return new Promise((resolve, reject) => { const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); upstreamRes.pipe(clientRes); upstreamRes.on('end', () => resolve()); @@ -493,8 +527,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); originalReq.pipe(upstreamReq); }); } @@ -512,16 +550,15 @@ export class ToolSanitizationProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); const chunks: Buffer[] = []; upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -564,8 +601,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); @@ -585,16 +626,15 @@ export class ToolSanitizationProxy { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); + let clearResponseTimeout: () => void = () => undefined; const upstreamReq = requestFn( - { - protocol: upstreamUrl.protocol, - hostname: upstreamUrl.hostname, - port: upstreamUrl.port, - path: upstreamUrl.pathname + upstreamUrl.search, - method: originalReq.method, - headers: this.buildForwardHeaders(originalReq.headers, bodyString), - }, + this.buildRequestOptions( + upstreamUrl, + originalReq.method, + this.buildForwardHeaders(originalReq.headers, bodyString) + ), (upstreamRes) => { + clearResponseTimeout(); clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); // Track upstream SSE lifecycle events (guards against empty proxy responses) @@ -712,8 +752,12 @@ export class ToolSanitizationProxy { } ); + clearResponseTimeout = this.startResponseTimeout(upstreamReq); upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); - upstreamReq.on('error', reject); + upstreamReq.on('error', (err) => { + clearResponseTimeout(); + reject(err); + }); upstreamReq.write(bodyString); upstreamReq.end(); }); diff --git a/tests/unit/cliproxy/https-tunnel-policy.test.ts b/tests/unit/cliproxy/https-tunnel-policy.test.ts new file mode 100644 index 00000000..a0beaa13 --- /dev/null +++ b/tests/unit/cliproxy/https-tunnel-policy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import { shouldStartHttpsTunnel } from '../../../src/cliproxy/executor/https-tunnel-policy'; + +describe('HTTPS tunnel startup policy', () => { + it('skips the tunnel for single-provider Codex remote HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(false); + }); + + it('keeps the tunnel for non-Codex remote HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'gemini', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(true); + }); + + it('keeps the tunnel for composite HTTPS launches', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'https', + host: 'remote.example.test', + isComposite: true, + }) + ).toBe(true); + }); + + it('does not start a tunnel without a remote HTTPS target', () => { + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: true, + protocol: 'http', + host: 'remote.example.test', + }) + ).toBe(false); + expect( + shouldStartHttpsTunnel({ + provider: 'codex', + useRemoteProxy: false, + protocol: 'https', + host: 'remote.example.test', + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts b/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts new file mode 100644 index 00000000..b7e64245 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-chain-regression.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'bun:test'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import { CodexReasoningProxy } from '../../../src/cliproxy/codex-reasoning-proxy'; +import { ToolSanitizationProxy } from '../../../src/cliproxy/tool-sanitization-proxy'; + +const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGH5Akfta94U5O +X7LZIeoRN9pOKiXuqiTnIrOh/dW/RZJp1Fg0M+t+Gv/WFu/KOMrUwpsPZ8ectOCy +cwdv+ME/+4RlAiGmALLinlDqCPv0Z8gRiZpfEeOinzrnBR26fphtebdlRF4Xy0rI +QcnMsC6Wr1V484CP5iM6GAdVP+gXoYINV2VtooXS/ViptmxLMqMqhjDhz6ZUfEP4 +XmQU73eM8/OaQSRIqJx1UyByERjgz0m/ySHAStS/IIKyjZVB9i0iZtwqO8Jl63ib +rzxKehVNDB0iCW9BawpQx6tqoO/hXKIUNPQaBQ8GfLwM7lCvGVSNuPLsB5mf1V65 +6UqvNVEfAgMBAAECggEAQ6Nm7G65FV4kA7G8N1DSvkoZJ3JQPOitbJN2TCmCnag8 +0hCChF/tV8IT8Z0nBBzbckN1+I5voVpHE+Udduav4w4VJv7RSgEXETMHYL7sdYYw +0XvuzeInms9Npq0idxbdJxLUv6fVj74Bq9h58n17ikoVnhNToszo3d5yMnJ85APP +tTHPJrv4oQYcrYfxksuL3RRqy4VpH/E8zA2AlakbuxfrwcqJ/kqvHCBFd/l3qfnf +U06ZqxZDYvZW9GYMvao+e2XLxT44A50EsTH0C3XIMsGGRVO+4ZzhbUBSDOgxx9pl +Lg75MrpKpFZSGO5EqjT1SX4E3eZijerHaqBS3P5sQQKBgQDnJgYyToRUq3Fb4pHF +XelW5cVg93XA/XtFWcx9ndwGRtwFYVs+b63MhILGGyDzsDzsmUjyNK013ldMTp0F +6TpMUFsc0mpyf63khNUyw7J/le447zZgwAEv/IaDf0CepCfJ149a7Y7jbZPG2R43 +ldFOxV1t8ERIiZnDd4iZjG9O7wKBgQDbbJMT8p6CGws5ZqKu0DYRbWoPvSFvMt3K +f5rXKl+PhhSOkljYXAzCi2Cjp/s/fU5cirtxf/If7ZqfR+VJwrJyzLvS/P4JDahI +HOiTulauN7QWmgHCifmHGX33Rwupo2ctUDXvMmrN53tLTKkQJVKODMmnlaQ8SDfu +CVTjyQgg0QKBgQDeND52s+YgYuNJeme5fMrof1+cFKc9TC/pfibHhy5RVmMCRRHq +1n8UATqZ6NBnkr8ujziBpcPw6fmv4E6wtQEXBZRhA6HSygzHhi5Ra7E3V1E3qDX1 +Ef7SO4av+G+NUa7yKOeleIMI/Hi4ClYzBzG78J0dJ4Ds3mJTdqB5Q3hpPwKBgGSR +8R+Vdl0/s/dNOYdSW1XFhnVMRITJFar4rams/Du2QrnODfasyhRo+ZRJK/k/n6j+ +1UcCU6Gar+INe+RA3rWLbRMZbf4MSnMy/M6o+43CbkJluCTIRJxNbNTntUq9YE7+ +kpndy+IwFOzAaETENoBxEARFrA8NMsVhdY0p2RXhAoGBAMKLDojNYSoF+Wp2nsSJ +Ahdwyrsk4eJSiFqr2g4wI1Ehhk/mOtYrDlmZZWQ3i0FC2xmAXDUKrBNgcNqoWvJg +tIXz8cRN0dmAMNeAjJ4dCBFnGUAV9D6KdUHPqhLhlMHpX3uc7h1/ya6/9gfzZNC5 +EQfxhCAaAvIvuZUSu4hJvM5U +-----END PRIVATE KEY-----`; + +const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUHdVZVRUzHman7tQL4dHoBEiRYkcwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyNDAyMzEwNFoXDTM2MDQy +MTAyMzEwNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAxh+QJH7WveFOTl+y2SHqETfaTiol7qok5yKzof3Vv0WS +adRYNDPrfhr/1hbvyjjK1MKbD2fHnLTgsnMHb/jBP/uEZQIhpgCy4p5Q6gj79GfI +EYmaXxHjop865wUdun6YbXm3ZUReF8tKyEHJzLAulq9VePOAj+YjOhgHVT/oF6GC +DVdlbaKF0v1YqbZsSzKjKoYw4c+mVHxD+F5kFO93jPPzmkEkSKicdVMgchEY4M9J +v8khwErUvyCCso2VQfYtImbcKjvCZet4m688SnoVTQwdIglvQWsKUMeraqDv4Vyi +FDT0GgUPBny8DO5QrxlUjbjy7AeZn9VeuelKrzVRHwIDAQABo28wbTAdBgNVHQ4E +FgQUBqHCiP54ZRWvDOanxmdG9ZORLCYwHwYDVR0jBBgwFoAUBqHCiP54ZRWvDOan +xmdG9ZORLCYwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIdjGd8iL83z+6tv0cglBBXwJb8LZQT0 +u++ArvAgjxEiCjojfKYyRkfKPPAxaidKZ2MLZMGnrxEg2vCBeTvmpR57HE8VL67N +Jijusc0dD3dRCqQHY7IgyxJELN4scSAzAes/LJiP3GgSvqt+RN/ltkJkXMCdeOoV +61S8jUD36NeqOPLkbCUwr2gqRfnzt27BH+9LCUufg2VlV1fBkIImqn37h80/HZtc +q7VFWuoYwwxog/mqnr9LtIzrfEzrB4V5lf4ZF9ZWtjSC0z++y2RFX2WFnFRLT1Xh +JpvCX2cYs9gOGe/Ite2Qt35tJNZYDm4UJlCb2EJHQJsDnB4rqvNFRb0= +-----END CERTIFICATE-----`; + +function listen(server: http.Server | https.Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: http.Server | https.Server): Promise { + return new Promise((resolve) => { + const fallback = setTimeout(resolve, 100); + server.close(() => { + clearTimeout(fallback); + resolve(); + }); + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + }); +} + +describe('remote proxy chain regression', () => { + it('lets the local Codex reasoning proxy connect directly to self-signed HTTPS upstreams', async () => { + let forwardedPath = ''; + let forwardedReasoningEffort: unknown; + const upstream = https.createServer( + { key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT }, + (req, res) => { + forwardedPath = req.url || ''; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + forwardedReasoningEffort = parsed.reasoning?.effort; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })); + }); + } + ); + const upstreamPort = await listen(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `https://localhost:${upstreamPort}`, + allowSelfSigned: true, + stripPathPrefix: '/api/provider/codex', + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.4', + }, + }); + + try { + const proxyPort = await proxy.start(); + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.4', messages: [] }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + expect(forwardedPath).toBe('/v1/messages'); + expect(forwardedReasoningEffort).toBe('xhigh'); + } finally { + proxy.stop(); + await close(upstream); + } + }); + + it('routes Codex through tool sanitization to a self-signed remote HTTPS proxy', async () => { + let forwardedPath = ''; + let forwardedReasoningEffort: unknown; + const upstream = https.createServer( + { key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT }, + (req, res) => { + forwardedPath = req.url || ''; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + forwardedReasoningEffort = parsed.reasoning?.effort; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })); + }); + } + ); + const upstreamPort = await listen(upstream); + const toolProxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `https://localhost:${upstreamPort}`, + allowSelfSigned: true, + warnOnSanitize: false, + }); + let codexProxy: CodexReasoningProxy | null = null; + + try { + const toolPort = await toolProxy.start(); + codexProxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${toolPort}`, + stripPathPrefix: '/api/provider/codex', + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.4', + }, + }); + const codexPort = await codexProxy.start(); + const response = await fetch(`http://127.0.0.1:${codexPort}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + messages: [], + tools: [{ name: 'read', input_schema: {}, cache_control: { type: 'ephemeral' } }], + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + expect(forwardedPath).toBe('/v1/messages'); + expect(forwardedReasoningEffort).toBe('xhigh'); + } finally { + codexProxy?.stop(); + toolProxy.stop(); + await close(upstream); + } + }); +});