mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
fix: route codex remote https through local proxy chain
This commit is contained in:
@@ -24,6 +24,8 @@ export interface CodexReasoningProxyConfig {
|
|||||||
modelMap: CodexReasoningModelMap;
|
modelMap: CodexReasoningModelMap;
|
||||||
defaultEffort?: CodexReasoningEffort;
|
defaultEffort?: CodexReasoningEffort;
|
||||||
traceFilePath?: string;
|
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.
|
* 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.
|
* Used for remote proxy mode where upstream expects /v1/messages, not /api/provider/codex/v1/messages.
|
||||||
@@ -179,6 +181,7 @@ export class CodexReasoningProxy {
|
|||||||
| 'defaultEffort'
|
| 'defaultEffort'
|
||||||
| 'traceFilePath'
|
| 'traceFilePath'
|
||||||
| 'disableEffort'
|
| 'disableEffort'
|
||||||
|
| 'allowSelfSigned'
|
||||||
>
|
>
|
||||||
> &
|
> &
|
||||||
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||||
@@ -203,6 +206,7 @@ export class CodexReasoningProxy {
|
|||||||
traceFilePath: config.traceFilePath ?? '',
|
traceFilePath: config.traceFilePath ?? '',
|
||||||
stripPathPrefix: config.stripPathPrefix,
|
stripPathPrefix: config.stripPathPrefix,
|
||||||
disableEffort: config.disableEffort ?? false,
|
disableEffort: config.disableEffort ?? false,
|
||||||
|
allowSelfSigned: config.allowSelfSigned ?? false,
|
||||||
};
|
};
|
||||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
||||||
}
|
}
|
||||||
@@ -508,6 +512,38 @@ export class CodexReasoningProxy {
|
|||||||
return url.protocol === 'https:' ? https.request : http.request;
|
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(
|
private forwardRaw(
|
||||||
originalReq: http.IncomingMessage,
|
originalReq: http.IncomingMessage,
|
||||||
clientRes: http.ServerResponse,
|
clientRes: http.ServerResponse,
|
||||||
@@ -515,17 +551,15 @@ export class CodexReasoningProxy {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
|
let clearResponseTimeout: () => void = () => undefined;
|
||||||
const upstreamReq = requestFn(
|
const upstreamReq = requestFn(
|
||||||
{
|
this.buildRequestOptions(
|
||||||
protocol: upstreamUrl.protocol,
|
upstreamUrl,
|
||||||
hostname: upstreamUrl.hostname,
|
originalReq.method,
|
||||||
port: upstreamUrl.port,
|
this.buildForwardHeaders(originalReq.headers)
|
||||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
),
|
||||||
method: originalReq.method,
|
|
||||||
timeout: this.config.timeoutMs,
|
|
||||||
headers: this.buildForwardHeaders(originalReq.headers),
|
|
||||||
},
|
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||||
upstreamRes.pipe(clientRes);
|
upstreamRes.pipe(clientRes);
|
||||||
upstreamRes.on('end', () => resolve());
|
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('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
||||||
upstreamReq.on('error', reject);
|
upstreamReq.on('error', (err) => {
|
||||||
|
clearResponseTimeout();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
originalReq.pipe(upstreamReq);
|
originalReq.pipe(upstreamReq);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -549,17 +587,15 @@ export class CodexReasoningProxy {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const bodyString = JSON.stringify(body);
|
const bodyString = JSON.stringify(body);
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
|
let clearResponseTimeout: () => void = () => undefined;
|
||||||
const upstreamReq = requestFn(
|
const upstreamReq = requestFn(
|
||||||
{
|
this.buildRequestOptions(
|
||||||
protocol: upstreamUrl.protocol,
|
upstreamUrl,
|
||||||
hostname: upstreamUrl.hostname,
|
originalReq.method,
|
||||||
port: upstreamUrl.port,
|
this.buildForwardHeaders(originalReq.headers, bodyString)
|
||||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
),
|
||||||
method: originalReq.method,
|
|
||||||
timeout: this.config.timeoutMs,
|
|
||||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
|
||||||
},
|
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
|
clearResponseTimeout();
|
||||||
const statusCode = upstreamRes.statusCode || 200;
|
const statusCode = upstreamRes.statusCode || 200;
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
clientRes.writeHead(statusCode, upstreamRes.headers);
|
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('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
||||||
upstreamReq.on('error', reject);
|
upstreamReq.on('error', (err) => {
|
||||||
|
clearResponseTimeout();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
upstreamReq.write(bodyString);
|
upstreamReq.write(bodyString);
|
||||||
upstreamReq.end();
|
upstreamReq.end();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ import {
|
|||||||
resolveRuntimeThinkingOverride,
|
resolveRuntimeThinkingOverride,
|
||||||
shouldDisableCodexReasoning,
|
shouldDisableCodexReasoning,
|
||||||
} from './thinking-override-resolver';
|
} from './thinking-override-resolver';
|
||||||
|
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -1017,7 +1018,15 @@ export async function execClaudeWithCLIProxy(
|
|||||||
let httpsTunnel: HttpsTunnelProxy | null = null;
|
let httpsTunnel: HttpsTunnelProxy | null = null;
|
||||||
let tunnelPort: number | 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 {
|
try {
|
||||||
httpsTunnel = new HttpsTunnelProxy({
|
httpsTunnel = new HttpsTunnelProxy({
|
||||||
remoteHost: proxyConfig.host,
|
remoteHost: proxyConfig.host,
|
||||||
@@ -1028,13 +1037,15 @@ export async function execClaudeWithCLIProxy(
|
|||||||
});
|
});
|
||||||
tunnelPort = await httpsTunnel.start();
|
tunnelPort = await httpsTunnel.start();
|
||||||
log(
|
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) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
|
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
|
||||||
throw new Error(`HTTPS tunnel startup failed: ${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 =
|
const imageAnalysisProxyTarget =
|
||||||
@@ -1135,6 +1146,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
||||||
verbose,
|
verbose,
|
||||||
warnOnSanitize: true,
|
warnOnSanitize: true,
|
||||||
|
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||||
});
|
});
|
||||||
toolSanitizationPort = await toolSanitizationProxy.start();
|
toolSanitizationPort = await toolSanitizationProxy.start();
|
||||||
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
||||||
@@ -1173,6 +1185,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
defaultEffort: 'medium',
|
defaultEffort: 'medium',
|
||||||
disableEffort: codexThinkingOff,
|
disableEffort: codexThinkingOff,
|
||||||
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
||||||
|
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||||
modelMap: {
|
modelMap: {
|
||||||
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
||||||
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface ToolSanitizationProxyConfig {
|
|||||||
warnOnSanitize?: boolean;
|
warnOnSanitize?: boolean;
|
||||||
/** Request timeout in milliseconds */
|
/** Request timeout in milliseconds */
|
||||||
timeoutMs?: number;
|
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,
|
verbose: config.verbose ?? false,
|
||||||
warnOnSanitize: config.warnOnSanitize ?? true,
|
warnOnSanitize: config.warnOnSanitize ?? true,
|
||||||
timeoutMs: config.timeoutMs ?? 120000,
|
timeoutMs: config.timeoutMs ?? 120000,
|
||||||
|
allowSelfSigned: config.allowSelfSigned ?? false,
|
||||||
};
|
};
|
||||||
this.debugMode = process.env.CCS_DEBUG === '1';
|
this.debugMode = process.env.CCS_DEBUG === '1';
|
||||||
this.logFilePath = this.initLogFile();
|
this.logFilePath = this.initLogFile();
|
||||||
@@ -469,6 +472,38 @@ export class ToolSanitizationProxy {
|
|||||||
return url.protocol === 'https:' ? https.request : http.request;
|
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(
|
private forwardRaw(
|
||||||
originalReq: http.IncomingMessage,
|
originalReq: http.IncomingMessage,
|
||||||
clientRes: http.ServerResponse,
|
clientRes: http.ServerResponse,
|
||||||
@@ -476,16 +511,15 @@ export class ToolSanitizationProxy {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
|
let clearResponseTimeout: () => void = () => undefined;
|
||||||
const upstreamReq = requestFn(
|
const upstreamReq = requestFn(
|
||||||
{
|
this.buildRequestOptions(
|
||||||
protocol: upstreamUrl.protocol,
|
upstreamUrl,
|
||||||
hostname: upstreamUrl.hostname,
|
originalReq.method,
|
||||||
port: upstreamUrl.port,
|
this.buildForwardHeaders(originalReq.headers)
|
||||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
),
|
||||||
method: originalReq.method,
|
|
||||||
headers: this.buildForwardHeaders(originalReq.headers),
|
|
||||||
},
|
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||||
upstreamRes.pipe(clientRes);
|
upstreamRes.pipe(clientRes);
|
||||||
upstreamRes.on('end', () => resolve());
|
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('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
||||||
upstreamReq.on('error', reject);
|
upstreamReq.on('error', (err) => {
|
||||||
|
clearResponseTimeout();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
originalReq.pipe(upstreamReq);
|
originalReq.pipe(upstreamReq);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -512,16 +550,15 @@ export class ToolSanitizationProxy {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const bodyString = JSON.stringify(body);
|
const bodyString = JSON.stringify(body);
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
|
let clearResponseTimeout: () => void = () => undefined;
|
||||||
const upstreamReq = requestFn(
|
const upstreamReq = requestFn(
|
||||||
{
|
this.buildRequestOptions(
|
||||||
protocol: upstreamUrl.protocol,
|
upstreamUrl,
|
||||||
hostname: upstreamUrl.hostname,
|
originalReq.method,
|
||||||
port: upstreamUrl.port,
|
this.buildForwardHeaders(originalReq.headers, bodyString)
|
||||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
),
|
||||||
method: originalReq.method,
|
|
||||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
|
||||||
},
|
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
|
clearResponseTimeout();
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
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('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
||||||
upstreamReq.on('error', reject);
|
upstreamReq.on('error', (err) => {
|
||||||
|
clearResponseTimeout();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
upstreamReq.write(bodyString);
|
upstreamReq.write(bodyString);
|
||||||
upstreamReq.end();
|
upstreamReq.end();
|
||||||
});
|
});
|
||||||
@@ -585,16 +626,15 @@ export class ToolSanitizationProxy {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const bodyString = JSON.stringify(body);
|
const bodyString = JSON.stringify(body);
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
|
let clearResponseTimeout: () => void = () => undefined;
|
||||||
const upstreamReq = requestFn(
|
const upstreamReq = requestFn(
|
||||||
{
|
this.buildRequestOptions(
|
||||||
protocol: upstreamUrl.protocol,
|
upstreamUrl,
|
||||||
hostname: upstreamUrl.hostname,
|
originalReq.method,
|
||||||
port: upstreamUrl.port,
|
this.buildForwardHeaders(originalReq.headers, bodyString)
|
||||||
path: upstreamUrl.pathname + upstreamUrl.search,
|
),
|
||||||
method: originalReq.method,
|
|
||||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
|
||||||
},
|
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||||
|
|
||||||
// Track upstream SSE lifecycle events (guards against empty proxy responses)
|
// 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('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
||||||
upstreamReq.on('error', reject);
|
upstreamReq.on('error', (err) => {
|
||||||
|
clearResponseTimeout();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
upstreamReq.write(bodyString);
|
upstreamReq.write(bodyString);
|
||||||
upstreamReq.end();
|
upstreamReq.end();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user