From 204eea00ce006fd667ce8c9e71dad397423dae2d Mon Sep 17 00:00:00 2001 From: oscarlehuu Date: Thu, 8 Jan 2026 16:02:02 +1030 Subject: [PATCH 1/2] feat(codex): inject OpenAI reasoning.effort per tier --- src/cliproxy/cliproxy-executor.ts | 74 +++- src/cliproxy/codex-reasoning-proxy.ts | 373 ++++++++++++++++++ .../cliproxy/codex-reasoning-proxy.test.js | 71 ++++ 3 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 src/cliproxy/codex-reasoning-proxy.ts create mode 100644 tests/unit/cliproxy/codex-reasoning-proxy.test.js diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a04b2d81..03241737 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -35,6 +35,7 @@ import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; +import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { findAccountByQuery, getProviderAccounts, @@ -702,21 +703,74 @@ export async function execClaudeWithCLIProxy( cfg.customSettingsPath ) : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); + + // Codex-only: inject OpenAI reasoning effort based on tier model mapping. + // Maps by request.model: + // - OPUS/default model → xhigh + // - SONNET model → high + // - HAIKU model → medium + // - Unknown → medium + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; + if (provider === 'codex' && envVars.ANTHROPIC_BASE_URL) { + try { + const traceEnabled = + process.env.CCS_CODEX_REASONING_TRACE === '1' || + process.env.CCS_CODEX_REASONING_TRACE === 'true'; + codexReasoningProxy = new CodexReasoningProxy({ + upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, + verbose, + defaultEffort: 'medium', + traceFilePath: traceEnabled + ? `${process.env.HOME || ''}/.ccs/codex-reasoning-proxy.log` + : '', + modelMap: { + defaultModel: envVars.ANTHROPIC_MODEL, + opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL, + sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, + haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, + }, + }); + codexReasoningPort = await codexReasoningProxy.start(); + log( + `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` + ); + } catch (error) { + const err = error as Error; + codexReasoningProxy = null; + codexReasoningPort = null; + if (verbose) { + console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + } + } + } + + const effectiveEnvVars = + codexReasoningProxy && codexReasoningPort + ? { + ...envVars, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`, + } + : envVars; const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, - ...envVars, + ...effectiveEnvVars, ...webSearchEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; - log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`); - log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); + log(`Claude env: ANTHROPIC_BASE_URL=${effectiveEnvVars.ANTHROPIC_BASE_URL}`); + log(`Claude env: ANTHROPIC_MODEL=${effectiveEnvVars.ANTHROPIC_MODEL}`); if (Object.keys(webSearchEnv).length > 0) { log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); } // Log global env vars for visibility - if (envVars.DISABLE_TELEMETRY || envVars.DISABLE_ERROR_REPORTING || envVars.DISABLE_BUG_COMMAND) { + if ( + effectiveEnvVars.DISABLE_TELEMETRY || + effectiveEnvVars.DISABLE_ERROR_REPORTING || + effectiveEnvVars.DISABLE_BUG_COMMAND + ) { log(`Claude env: Global env applied (telemetry/reporting disabled)`); } @@ -773,6 +827,10 @@ export async function execClaudeWithCLIProxy( claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); + if (codexReasoningProxy) { + codexReasoningProxy.stop(); + } + // Unregister this session (proxy keeps running for persistence) - only for local mode if (sessionId) { unregisterSession(sessionId, sessionPort); @@ -789,6 +847,10 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); + if (codexReasoningProxy) { + codexReasoningProxy.stop(); + } + // Unregister session, proxy keeps running (local mode only) if (sessionId) { unregisterSession(sessionId, sessionPort); @@ -800,6 +862,10 @@ export async function execClaudeWithCLIProxy( const cleanup = () => { log('Parent signal received, cleaning up'); + if (codexReasoningProxy) { + codexReasoningProxy.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 new file mode 100644 index 00000000..657359ad --- /dev/null +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -0,0 +1,373 @@ +import * as http from 'http'; +import { URL } from 'url'; + +export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; + +export interface CodexReasoningModelMap { + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + defaultModel?: string; +} + +export interface CodexReasoningProxyConfig { + upstreamBaseUrl: string; + verbose?: boolean; + timeoutMs?: number; + modelMap: CodexReasoningModelMap; + defaultEffort?: CodexReasoningEffort; + traceFilePath?: string; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseModelEffortSuffix( + model: string +): { upstreamModel: string; effort: CodexReasoningEffort } | null { + const match = model.match(/^(.*)-(xhigh|high|medium)$/); + if (!match) return null; + const upstreamModel = match[1]?.trim(); + const effort = match[2] as CodexReasoningEffort; + if (!upstreamModel) return null; + return { upstreamModel, effort }; +} + +const EFFORT_RANK: Record = { + medium: 1, + high: 2, + xhigh: 3, +}; + +function minEffort(a: CodexReasoningEffort, b: CodexReasoningEffort): CodexReasoningEffort { + return EFFORT_RANK[a] <= EFFORT_RANK[b] ? a : b; +} + +export function buildCodexModelEffortMap( + models: CodexReasoningModelMap, + defaultEffort: CodexReasoningEffort = 'medium' +): Map { + const map = new Map(); + + const upsertMin = (model: string | undefined, effort: CodexReasoningEffort) => { + if (!isNonEmptyString(model)) return; + const existing = map.get(model); + map.set(model, existing ? minEffort(existing, effort) : effort); + }; + + upsertMin(models.defaultModel, 'xhigh'); + upsertMin(models.opusModel, 'xhigh'); + upsertMin(models.sonnetModel, 'high'); + upsertMin(models.haikuModel, 'medium'); + + if (map.size === 0 && isNonEmptyString(models.defaultModel)) { + map.set(models.defaultModel, defaultEffort); + } + + return map; +} + +export function getEffortForModel( + model: string | null, + modelEffort: Map, + defaultEffort: CodexReasoningEffort +): CodexReasoningEffort { + if (!model) return defaultEffort; + return modelEffort.get(model) ?? defaultEffort; +} + +export function injectReasoningEffortIntoBody( + body: unknown, + effort: CodexReasoningEffort +): unknown { + if (!isRecord(body)) return body; + + // OpenAI Responses API knob: reasoning: { effort: "..." } + // Always override effort (user expectation). + const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {}; + return { + ...body, + reasoning: { + ...existingReasoning, + effort, + }, + }; +} + +export class CodexReasoningProxy { + private server: http.Server | null = null; + private port: number | null = null; + private readonly config: Required< + Pick< + CodexReasoningProxyConfig, + 'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath' + > + > & + Pick; + private readonly modelEffort: Map; + private readonly recent: Array<{ + at: string; + model: string | null; + upstreamModel: string | null; + effort: CodexReasoningEffort; + path: string; + }> = []; + private readonly counts: Record = { medium: 0, high: 0, xhigh: 0 }; + + constructor(config: CodexReasoningProxyConfig) { + this.config = { + upstreamBaseUrl: config.upstreamBaseUrl, + verbose: config.verbose ?? false, + timeoutMs: config.timeoutMs ?? 120000, + modelMap: config.modelMap, + defaultEffort: config.defaultEffort ?? 'medium', + traceFilePath: config.traceFilePath ?? '', + }; + this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); + } + + private log(message: string): void { + if (this.config.verbose) { + console.error(`[codex-reasoning-proxy] ${message}`); + } + } + + private trace(line: string): void { + if (!this.config.traceFilePath) return; + try { + // Intentionally best-effort: tracing must never break requests. + + const fs = require('fs') as typeof import('fs'); + + const path = require('path') as typeof import('path'); + const dir = path.dirname(this.config.traceFilePath); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(this.config.traceFilePath, line + '\n'); + } catch { + // ignore + } + } + + private record( + model: string | null, + upstreamModel: string | null, + effort: CodexReasoningEffort, + path: string + ): void { + this.counts[effort] += 1; + this.recent.push({ at: new Date().toISOString(), model, upstreamModel, effort, path }); + if (this.recent.length > 50) this.recent.shift(); + } + + async start(): Promise { + if (this.server) return this.port ?? 0; + + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + this.server.listen(0, '127.0.0.1', () => { + const address = this.server?.address(); + this.port = typeof address === 'object' && address ? address.port : 0; + resolve(this.port); + }); + + this.server.on('error', (err) => reject(err)); + }); + } + + stop(): void { + if (!this.server) return; + this.server.close(); + this.server = null; + this.port = null; + } + + private readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const maxSize = 10 * 1024 * 1024; // 10MB + let total = 0; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxSize) { + reject(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const method = req.method || 'GET'; + const requestPath = req.url || '/'; + const upstreamBase = new URL(this.config.upstreamBaseUrl); + const fullUpstreamUrl = new URL(requestPath, upstreamBase); + + this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`); + + // Debug/status endpoint (no upstream call). Does not expose prompt content. + if (method.toUpperCase() === 'GET' && requestPath === '/__ccs/reasoning') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + counts: this.counts, + recent: this.recent, + modelMap: this.config.modelMap, + defaultEffort: this.config.defaultEffort, + }) + ); + return; + } + + // Only buffer+rewrite JSON bodies; otherwise just proxy as-is. + const contentType = String(req.headers['content-type'] || ''); + const isJson = contentType.includes('application/json'); + const shouldRewrite = isJson && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()); + + try { + if (!shouldRewrite) { + await this.forwardRaw(req, res, fullUpstreamUrl); + return; + } + + const rawBody = await this.readBody(req); + let parsed: unknown; + try { + parsed = rawBody.length ? JSON.parse(rawBody) : {}; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON in request body' })); + return; + } + + const originalModel = + isRecord(parsed) && typeof parsed.model === 'string' ? parsed.model : null; + + // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: + // - upstream model: `gpt-5.2-codex` + // - reasoning.effort: `xhigh` + // + // This allows tier→effort mapping without inventing upstream model IDs. + const suffixParsed = originalModel ? parseModelEffortSuffix(originalModel) : null; + const upstreamModel = suffixParsed?.upstreamModel ?? originalModel; + const effort = + suffixParsed?.effort ?? + getEffortForModel(originalModel, this.modelEffort, this.config.defaultEffort); + + const withUpstreamModel = + upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; + const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort); + + this.record(originalModel, upstreamModel, effort, requestPath); + this.trace( + `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ + upstreamModel ?? 'null' + } effort=${effort} path=${requestPath}` + ); + + await this.forwardJson(req, res, fullUpstreamUrl, rewritten); + } catch (error) { + const err = error as Error; + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: err.message })); + } + } + + private buildForwardHeaders( + originalHeaders: http.IncomingHttpHeaders, + bodyString?: string + ): http.OutgoingHttpHeaders { + const headers: http.OutgoingHttpHeaders = {}; + + for (const [key, value] of Object.entries(originalHeaders)) { + if (!value) continue; + const lower = key.toLowerCase(); + if (lower === 'host' || lower === 'content-length') continue; + headers[key] = value; + } + + if (bodyString !== undefined) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + headers['Content-Length'] = Buffer.byteLength(bodyString); + } + + return headers; + } + + private forwardRaw( + originalReq: http.IncomingMessage, + clientRes: http.ServerResponse, + upstreamUrl: URL + ): Promise { + return new Promise((resolve, reject) => { + const upstreamReq = http.request( + { + 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), + }, + (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve()); + upstreamRes.on('error', reject); + } + ); + + upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); + upstreamReq.on('error', reject); + originalReq.pipe(upstreamReq); + }); + } + + private forwardJson( + originalReq: http.IncomingMessage, + clientRes: http.ServerResponse, + upstreamUrl: URL, + body: unknown + ): Promise { + return new Promise((resolve, reject) => { + const bodyString = JSON.stringify(body); + const upstreamReq = http.request( + { + 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), + }, + (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve()); + upstreamRes.on('error', reject); + } + ); + + upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout'))); + upstreamReq.on('error', reject); + upstreamReq.write(bodyString); + upstreamReq.end(); + }); + } +} diff --git a/tests/unit/cliproxy/codex-reasoning-proxy.test.js b/tests/unit/cliproxy/codex-reasoning-proxy.test.js new file mode 100644 index 00000000..9322f709 --- /dev/null +++ b/tests/unit/cliproxy/codex-reasoning-proxy.test.js @@ -0,0 +1,71 @@ +const assert = require('assert'); + +const { + buildCodexModelEffortMap, + getEffortForModel, + injectReasoningEffortIntoBody, +} = require('../../../dist/cliproxy/codex-reasoning-proxy'); + +describe('Codex Reasoning Proxy', () => { + describe('buildCodexModelEffortMap', () => { + it('maps tier models to expected efforts', () => { + const map = buildCodexModelEffortMap({ + defaultModel: 'gpt-5.2-codex', + opusModel: 'gpt-5.2-codex', + sonnetModel: 'gpt-5.2-codex-high', + haikuModel: 'gpt-5.2-codex-mini', + }); + + assert.strictEqual(map.get('gpt-5.2-codex'), 'xhigh'); + assert.strictEqual(map.get('gpt-5.2-codex-high'), 'high'); + assert.strictEqual(map.get('gpt-5.2-codex-mini'), 'medium'); + }); + + it('uses the lowest effort when the same model appears in multiple tiers', () => { + const map = buildCodexModelEffortMap({ + opusModel: 'same', + sonnetModel: 'same', + haikuModel: 'same', + }); + + assert.strictEqual(map.get('same'), 'medium'); + }); + }); + + describe('getEffortForModel', () => { + it('defaults to medium for unknown model', () => { + const map = buildCodexModelEffortMap({ opusModel: 'm1' }); + assert.strictEqual(getEffortForModel('unknown', map, 'medium'), 'medium'); + }); + }); + + describe('injectReasoningEffortIntoBody', () => { + it('overrides reasoning.effort on object bodies', () => { + const out = injectReasoningEffortIntoBody( + { model: 'm1', reasoning: { effort: 'xhigh' } }, + 'high' + ); + assert.deepStrictEqual(out, { model: 'm1', reasoning: { effort: 'high' } }); + }); + + it('leaves non-object bodies unchanged', () => { + assert.strictEqual(injectReasoningEffortIntoBody('x', 'medium'), 'x'); + }); + }); + + describe('model suffix aliasing', () => { + it('lets callers use model-id suffixes without changing upstream model ids', () => { + // We can't call the full proxy in unit tests, but we can validate the intent: + // - injected effort is based on the suffix + // - callers should set request.model to the base model upstream (proxy does this) + const out = injectReasoningEffortIntoBody( + { model: 'gpt-5.2-codex', reasoning: { effort: 'medium' } }, + 'xhigh' + ); + assert.deepStrictEqual(out, { + model: 'gpt-5.2-codex', + reasoning: { effort: 'xhigh' }, + }); + }); + }); +}); From 87cfcc5b3cbecfdccaa56c1a02b24fb8b84eb654 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 8 Jan 2026 09:15:42 -0500 Subject: [PATCH 2/2] fix(codex-proxy): security hardening and edge case fixes - Fix path traversal in trace() via safe dir validation - Add RFC 7230 hop-by-hop header filtering - Fix isRecord() to exclude arrays - Add req.destroy() on oversized body rejection - Add missing ANTHROPIC_BASE_URL warning for Codex - Use cwd() fallback when HOME undefined - Add 4 edge case unit tests --- src/cliproxy/cliproxy-executor.ts | 62 ++++++++++--------- src/cliproxy/codex-reasoning-proxy.ts | 36 +++++++++-- .../cliproxy/codex-reasoning-proxy.test.js | 20 ++++++ 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 03241737..03c8c04f 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -712,35 +712,39 @@ export async function execClaudeWithCLIProxy( // - Unknown → medium let codexReasoningProxy: CodexReasoningProxy | null = null; let codexReasoningPort: number | null = null; - if (provider === 'codex' && envVars.ANTHROPIC_BASE_URL) { - try { - const traceEnabled = - process.env.CCS_CODEX_REASONING_TRACE === '1' || - process.env.CCS_CODEX_REASONING_TRACE === 'true'; - codexReasoningProxy = new CodexReasoningProxy({ - upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, - verbose, - defaultEffort: 'medium', - traceFilePath: traceEnabled - ? `${process.env.HOME || ''}/.ccs/codex-reasoning-proxy.log` - : '', - modelMap: { - defaultModel: envVars.ANTHROPIC_MODEL, - opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL, - sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, - haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, - }, - }); - codexReasoningPort = await codexReasoningProxy.start(); - log( - `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` - ); - } catch (error) { - const err = error as Error; - codexReasoningProxy = null; - codexReasoningPort = null; - if (verbose) { - console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + if (provider === 'codex') { + if (!envVars.ANTHROPIC_BASE_URL) { + log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); + } else { + try { + const traceEnabled = + process.env.CCS_CODEX_REASONING_TRACE === '1' || + process.env.CCS_CODEX_REASONING_TRACE === 'true'; + codexReasoningProxy = new CodexReasoningProxy({ + upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, + verbose, + defaultEffort: 'medium', + traceFilePath: traceEnabled + ? `${process.env.HOME || process.cwd()}/.ccs/codex-reasoning-proxy.log` + : '', + modelMap: { + defaultModel: envVars.ANTHROPIC_MODEL, + opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL, + sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, + haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, + }, + }); + codexReasoningPort = await codexReasoningProxy.start(); + log( + `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` + ); + } catch (error) { + const err = error as Error; + codexReasoningProxy = null; + codexReasoningPort = null; + if (verbose) { + console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + } } } } diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 657359ad..6412c93d 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -24,7 +24,7 @@ function isNonEmptyString(value: unknown): value is string { } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); } function parseModelEffortSuffix( @@ -141,13 +141,22 @@ export class CodexReasoningProxy { if (!this.config.traceFilePath) return; try { // Intentionally best-effort: tracing must never break requests. - const fs = require('fs') as typeof import('fs'); + const pathMod = require('path') as typeof import('path'); - const path = require('path') as typeof import('path'); - const dir = path.dirname(this.config.traceFilePath); + // Security: validate trace path against safe directories to prevent path traversal + const resolvedPath = pathMod.resolve(this.config.traceFilePath); + const home = process.env.HOME || process.env.USERPROFILE || ''; + const safeDirs = ['/tmp/', '/var/log/', home ? `${home}/.ccs/` : '']; + const isSafe = safeDirs.some((base) => base && resolvedPath.startsWith(base)); + if (!isSafe) { + this.log(`[SECURITY] Trace path rejected (outside allowed dirs): ${resolvedPath}`); + return; + } + + const dir = pathMod.dirname(resolvedPath); fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(this.config.traceFilePath, line + '\n'); + fs.appendFileSync(resolvedPath, line + '\n'); } catch { // ignore } @@ -198,6 +207,7 @@ export class CodexReasoningProxy { req.on('data', (chunk: Buffer) => { total += chunk.length; if (total > maxSize) { + req.destroy(); // Signal client to stop sending reject(new Error('Request body too large (max 10MB)')); return; } @@ -293,10 +303,24 @@ export class CodexReasoningProxy { ): http.OutgoingHttpHeaders { const headers: http.OutgoingHttpHeaders = {}; + // RFC 7230 hop-by-hop headers that should not be forwarded + const hopByHop = new Set([ + 'host', + 'content-length', + '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 (lower === 'host' || lower === 'content-length') continue; + if (hopByHop.has(lower)) continue; headers[key] = value; } diff --git a/tests/unit/cliproxy/codex-reasoning-proxy.test.js b/tests/unit/cliproxy/codex-reasoning-proxy.test.js index 9322f709..a9294ae7 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy.test.js +++ b/tests/unit/cliproxy/codex-reasoning-proxy.test.js @@ -30,6 +30,16 @@ describe('Codex Reasoning Proxy', () => { assert.strictEqual(map.get('same'), 'medium'); }); + + it('returns empty map when all models undefined', () => { + const map = buildCodexModelEffortMap({}); + assert.strictEqual(map.size, 0); + }); + + it('ignores empty string models', () => { + const map = buildCodexModelEffortMap({ defaultModel: '', opusModel: ' ' }); + assert.strictEqual(map.size, 0); + }); }); describe('getEffortForModel', () => { @@ -37,6 +47,11 @@ describe('Codex Reasoning Proxy', () => { const map = buildCodexModelEffortMap({ opusModel: 'm1' }); assert.strictEqual(getEffortForModel('unknown', map, 'medium'), 'medium'); }); + + it('handles null model', () => { + const map = buildCodexModelEffortMap({ opusModel: 'm1' }); + assert.strictEqual(getEffortForModel(null, map, 'high'), 'high'); + }); }); describe('injectReasoningEffortIntoBody', () => { @@ -51,6 +66,11 @@ describe('Codex Reasoning Proxy', () => { it('leaves non-object bodies unchanged', () => { assert.strictEqual(injectReasoningEffortIntoBody('x', 'medium'), 'x'); }); + + it('leaves array bodies unchanged (not treated as record)', () => { + const arr = [1, 2, 3]; + assert.deepStrictEqual(injectReasoningEffortIntoBody(arr, 'high'), arr); + }); }); describe('model suffix aliasing', () => {