mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
- 将 browser_click 调整为 mousedown/mouseup 后走 native click - 在取消激活和中途 detached 场景下避免强制触发 native activation - 补齐 browser_click 的回归测试与取消激活场景覆盖 - 修复 cliproxy 本地代理、Droid 环境隔离、图像分析 hook 与 proxy 集成测试的验证阻塞 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
818 lines
28 KiB
TypeScript
818 lines
28 KiB
TypeScript
/**
|
|
* Tool Sanitization Proxy
|
|
*
|
|
* HTTP proxy that intercepts Claude CLI → CLIProxy requests to:
|
|
* 1. Sanitize MCP tool names exceeding Gemini's 64-char limit
|
|
* 2. Sanitize MCP tool input_schema to remove non-standard JSON Schema properties
|
|
* 3. Forward sanitized requests to upstream
|
|
* 4. Restore original names in responses
|
|
*
|
|
* Follows CodexReasoningProxy pattern for consistency.
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
import * as https from 'https';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { URL } from 'url';
|
|
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
|
|
import { sanitizeToolSchemas } from './schema-sanitizer';
|
|
import {
|
|
extractProviderFromPathname,
|
|
getDeniedModelIdReasonForProvider,
|
|
normalizeModelIdForRouting,
|
|
stripCodexEffortSuffix,
|
|
} from './model-id-normalizer';
|
|
import { getModelMaxLevel } from './model-catalog';
|
|
import { getCcsDir } from '../utils/config-manager';
|
|
import { createLogger } from '../services/logging';
|
|
|
|
export interface ToolSanitizationProxyConfig {
|
|
/** Upstream CLIProxy URL */
|
|
upstreamBaseUrl: string;
|
|
/** Enable verbose logging */
|
|
verbose?: boolean;
|
|
/** Log warnings when sanitization occurs */
|
|
warnOnSanitize?: boolean;
|
|
/** Request timeout in milliseconds */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if a value is a plain object (Record).
|
|
* Used for safely accessing properties on unknown JSON values.
|
|
*/
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([
|
|
'strict',
|
|
'input_examples',
|
|
'type',
|
|
'cache_control',
|
|
'defer_loading',
|
|
]);
|
|
|
|
const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control']);
|
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
|
const LEGACY_CODEX_MODEL_ID_REGEX = /^gpt-5(?:\.\d+)?-codex(?:-(?:mini|max))?$/i;
|
|
|
|
function canonicalizeCodexModelId(model: string | undefined): string | null {
|
|
const normalizedModel = model?.trim().toLowerCase();
|
|
if (!normalizedModel) {
|
|
return null;
|
|
}
|
|
|
|
const withoutExtendedContext = normalizedModel.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
|
return stripCodexEffortSuffix(withoutExtendedContext);
|
|
}
|
|
|
|
function isKnownCodexModelId(model: string | undefined): boolean {
|
|
const normalizedModel = canonicalizeCodexModelId(model);
|
|
if (!normalizedModel) {
|
|
return false;
|
|
}
|
|
|
|
// Root-routed requests can carry Codex model IDs that CCS uses outside the
|
|
// small interactive catalog (for example image analysis and Cursor defaults).
|
|
return (
|
|
LEGACY_CODEX_MODEL_ID_REGEX.test(normalizedModel) ||
|
|
getModelMaxLevel('codex', normalizedModel) !== undefined
|
|
);
|
|
}
|
|
|
|
function getUnsupportedToolFields(
|
|
providerFromPath: string | null,
|
|
model: string | undefined
|
|
): ReadonlySet<string> | null {
|
|
const normalizedProvider = providerFromPath?.trim().toLowerCase() ?? null;
|
|
const normalizedModel = model?.trim().toLowerCase();
|
|
|
|
if (
|
|
normalizedProvider === 'gemini' ||
|
|
normalizedProvider === 'gemini-cli' ||
|
|
(normalizedProvider === null && normalizedModel?.startsWith('gemini-'))
|
|
) {
|
|
return GEMINI_UNSUPPORTED_TOOL_FIELDS;
|
|
}
|
|
|
|
if (
|
|
normalizedProvider === 'codex' ||
|
|
(normalizedProvider === null && isKnownCodexModelId(model))
|
|
) {
|
|
return CODEX_UNSUPPORTED_TOOL_FIELDS;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function stripUnsupportedToolFields(
|
|
tools: Tool[],
|
|
unsupportedFields: ReadonlySet<string>
|
|
): {
|
|
tools: Tool[];
|
|
removedByTool: Array<{ name: string; removed: string[] }>;
|
|
totalRemoved: number;
|
|
} {
|
|
const removedByTool: Array<{ name: string; removed: string[] }> = [];
|
|
let totalRemoved = 0;
|
|
|
|
const sanitizedTools = tools.map((tool) => {
|
|
const sanitizedTool = { ...tool };
|
|
const removed: string[] = [];
|
|
|
|
for (const field of unsupportedFields) {
|
|
if (field in sanitizedTool) {
|
|
delete sanitizedTool[field];
|
|
removed.push(field);
|
|
}
|
|
}
|
|
|
|
if (removed.length > 0) {
|
|
removedByTool.push({
|
|
name: tool.name,
|
|
removed,
|
|
});
|
|
totalRemoved += removed.length;
|
|
}
|
|
|
|
return sanitizedTool;
|
|
});
|
|
|
|
return {
|
|
tools: sanitizedTools,
|
|
removedByTool,
|
|
totalRemoved,
|
|
};
|
|
}
|
|
|
|
export class ToolSanitizationProxy {
|
|
private server: http.Server | null = null;
|
|
private port: number | null = null;
|
|
private readonly config: Required<ToolSanitizationProxyConfig>;
|
|
private readonly logFilePath: string;
|
|
private readonly debugMode: boolean;
|
|
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
|
|
|
|
constructor(config: ToolSanitizationProxyConfig) {
|
|
this.config = {
|
|
upstreamBaseUrl: config.upstreamBaseUrl,
|
|
verbose: config.verbose ?? false,
|
|
warnOnSanitize: config.warnOnSanitize ?? true,
|
|
timeoutMs: config.timeoutMs ?? 120000,
|
|
};
|
|
this.debugMode = process.env.CCS_DEBUG === '1';
|
|
this.logFilePath = this.initLogFile();
|
|
}
|
|
|
|
/**
|
|
* Initialize log file path and ensure directory exists.
|
|
*/
|
|
private initLogFile(): string {
|
|
const logsDir = path.join(getCcsDir(), 'logs');
|
|
|
|
try {
|
|
if (!fs.existsSync(logsDir)) {
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
}
|
|
} catch (err) {
|
|
// Fallback to temp directory if logs dir creation fails
|
|
if (this.debugMode) {
|
|
console.error(
|
|
`[tool-sanitization-proxy] Failed to create logs dir: ${(err as Error).message}`
|
|
);
|
|
}
|
|
return path.join(os.tmpdir(), 'tool-sanitization-proxy.log');
|
|
}
|
|
|
|
return path.join(logsDir, 'tool-sanitization-proxy.log');
|
|
}
|
|
|
|
/**
|
|
* Write log entry to file (always) and console (if CCS_DEBUG=1).
|
|
*/
|
|
private writeLog(level: 'info' | 'warn' | 'error', message: string): void {
|
|
const timestamp = new Date().toISOString();
|
|
const prefix = level === 'info' ? '[i]' : level === 'warn' ? '[!]' : '[X]';
|
|
const logLine = `${timestamp} ${prefix} ${message}\n`;
|
|
|
|
// Always write to file
|
|
try {
|
|
fs.appendFileSync(this.logFilePath, logLine);
|
|
} catch {
|
|
// Silently ignore file write errors
|
|
}
|
|
|
|
// Console output only in debug mode
|
|
if (this.debugMode) {
|
|
console.error(`${prefix} ${message}`);
|
|
}
|
|
|
|
this.logger[level](level, message, {
|
|
debugMode: this.debugMode,
|
|
logFilePath: this.logFilePath,
|
|
});
|
|
}
|
|
|
|
private log(message: string): void {
|
|
if (this.config.verbose) {
|
|
this.writeLog('info', `[tool-sanitization-proxy] ${message}`);
|
|
}
|
|
}
|
|
|
|
private warn(message: string): void {
|
|
if (this.config.warnOnSanitize) {
|
|
this.writeLog('warn', `Tool name sanitized: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the proxy server on an ephemeral port.
|
|
* @returns The assigned port number
|
|
*/
|
|
async start(): Promise<number> {
|
|
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;
|
|
this.writeLog('info', `Tool sanitization proxy active (port ${this.port})`);
|
|
resolve(this.port);
|
|
});
|
|
|
|
this.server.on('error', (err) => reject(err));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop the proxy server.
|
|
*/
|
|
stop(): void {
|
|
if (!this.server) return;
|
|
this.server.close();
|
|
this.server = null;
|
|
this.port = null;
|
|
}
|
|
|
|
/**
|
|
* Get the port the proxy is listening on.
|
|
*/
|
|
getPort(): number | null {
|
|
return this.port;
|
|
}
|
|
|
|
private readBody(req: http.IncomingMessage): Promise<string> {
|
|
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) {
|
|
req.destroy();
|
|
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<void> {
|
|
const method = req.method || 'GET';
|
|
const requestPath = req.url || '/';
|
|
const upstreamBase = new URL(this.config.upstreamBaseUrl);
|
|
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
|
|
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
|
|
|
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`);
|
|
|
|
// Only buffer+rewrite JSON POST requests
|
|
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;
|
|
}
|
|
|
|
// Create mapper for this request
|
|
const mapper = new ToolNameMapper();
|
|
|
|
// Normalize dotted Claude model IDs for provider-compatible routing.
|
|
let modifiedBody = parsed;
|
|
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
|
const deniedReason = getDeniedModelIdReasonForProvider(
|
|
modifiedBody.model,
|
|
providerFromPath
|
|
);
|
|
if (deniedReason) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: deniedReason }));
|
|
return;
|
|
}
|
|
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
|
|
if (normalizedModel !== modifiedBody.model) {
|
|
this.writeLog(
|
|
'warn',
|
|
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
|
|
);
|
|
modifiedBody = { ...modifiedBody, model: normalizedModel };
|
|
}
|
|
}
|
|
|
|
// Sanitize tools if present
|
|
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
|
|
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
|
|
const schemaResult = sanitizeToolSchemas(
|
|
modifiedBody.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
|
|
);
|
|
|
|
if (schemaResult.totalRemoved > 0) {
|
|
for (const entry of schemaResult.removedByTool) {
|
|
this.writeLog(
|
|
'warn',
|
|
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`
|
|
);
|
|
}
|
|
this.log(
|
|
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`
|
|
);
|
|
}
|
|
|
|
let rewrittenTools = schemaResult.tools as Tool[];
|
|
const unsupportedToolFields =
|
|
isRecord(modifiedBody) && typeof modifiedBody.model === 'string'
|
|
? getUnsupportedToolFields(providerFromPath, modifiedBody.model)
|
|
: getUnsupportedToolFields(providerFromPath, undefined);
|
|
|
|
if (unsupportedToolFields) {
|
|
const fieldResult = stripUnsupportedToolFields(rewrittenTools, unsupportedToolFields);
|
|
|
|
if (fieldResult.totalRemoved > 0) {
|
|
for (const entry of fieldResult.removedByTool) {
|
|
this.writeLog(
|
|
'warn',
|
|
`[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`
|
|
);
|
|
}
|
|
this.log(
|
|
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`
|
|
);
|
|
}
|
|
|
|
rewrittenTools = fieldResult.tools;
|
|
}
|
|
|
|
// Step 2: Sanitize tool names (truncate to 64 chars for Gemini)
|
|
const sanitizedTools = mapper.registerTools(rewrittenTools);
|
|
modifiedBody = { ...modifiedBody, tools: sanitizedTools };
|
|
|
|
// Log sanitization warnings
|
|
if (mapper.hasChanges()) {
|
|
const changes = mapper.getChanges();
|
|
for (const change of changes) {
|
|
this.warn(`"${change.original}" → "${change.sanitized}"`);
|
|
}
|
|
this.log(`Sanitized ${changes.length} tool name(s)`);
|
|
}
|
|
|
|
// Warn about hash collisions (multiple originals → same sanitized)
|
|
if (mapper.hasCollisions()) {
|
|
const collisions = mapper.getCollisions();
|
|
for (const collision of collisions) {
|
|
this.writeLog(
|
|
'warn',
|
|
`[tool-sanitization-proxy] Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if streaming is requested
|
|
const isStreaming = isRecord(modifiedBody) && modifiedBody.stream === true;
|
|
|
|
if (isStreaming) {
|
|
await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper);
|
|
} else {
|
|
await this.forwardJsonBuffered(req, res, fullUpstreamUrl, modifiedBody, mapper);
|
|
}
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
this.log(`Error: ${err.message}`);
|
|
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 = {};
|
|
|
|
// 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 (hopByHop.has(lower)) continue;
|
|
headers[key] = value;
|
|
}
|
|
|
|
if (bodyString !== undefined) {
|
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
headers['Content-Length'] = Buffer.byteLength(bodyString);
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
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<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const requestFn = this.getRequestFn(upstreamUrl);
|
|
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),
|
|
},
|
|
(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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Forward JSON request and buffer response for tool name restoration.
|
|
*/
|
|
private forwardJsonBuffered(
|
|
originalReq: http.IncomingMessage,
|
|
clientRes: http.ServerResponse,
|
|
upstreamUrl: URL,
|
|
body: unknown,
|
|
mapper: ToolNameMapper
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const bodyString = JSON.stringify(body);
|
|
const requestFn = this.getRequestFn(upstreamUrl);
|
|
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),
|
|
},
|
|
(upstreamRes) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
upstreamRes.on('end', () => {
|
|
try {
|
|
const responseBody = Buffer.concat(chunks).toString('utf8');
|
|
const contentType = upstreamRes.headers['content-type'] || '';
|
|
|
|
// Only process JSON responses with tool_use blocks
|
|
if (contentType.includes('application/json') && mapper.hasChanges()) {
|
|
try {
|
|
const parsed = JSON.parse(responseBody);
|
|
if (isRecord(parsed) && Array.isArray(parsed.content)) {
|
|
parsed.content = mapper.restoreToolUse(parsed.content as ContentBlock[]);
|
|
const modifiedResponse = JSON.stringify(parsed);
|
|
|
|
// Update content-length header
|
|
const headers = { ...upstreamRes.headers };
|
|
headers['content-length'] = String(Buffer.byteLength(modifiedResponse));
|
|
|
|
clientRes.writeHead(upstreamRes.statusCode || 200, headers);
|
|
clientRes.end(modifiedResponse);
|
|
resolve();
|
|
return;
|
|
}
|
|
} catch {
|
|
// JSON parse failed, pass through unchanged
|
|
}
|
|
}
|
|
|
|
// Pass through unchanged
|
|
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
|
clientRes.end(responseBody);
|
|
resolve();
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
upstreamRes.on('error', reject);
|
|
}
|
|
);
|
|
|
|
upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
|
upstreamReq.on('error', reject);
|
|
upstreamReq.write(bodyString);
|
|
upstreamReq.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Forward JSON request and stream response with tool name restoration.
|
|
* Handles SSE (Server-Sent Events) format.
|
|
*/
|
|
private forwardJsonStreaming(
|
|
originalReq: http.IncomingMessage,
|
|
clientRes: http.ServerResponse,
|
|
upstreamUrl: URL,
|
|
body: unknown,
|
|
mapper: ToolNameMapper
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const bodyString = JSON.stringify(body);
|
|
const requestFn = this.getRequestFn(upstreamUrl);
|
|
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),
|
|
},
|
|
(upstreamRes) => {
|
|
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
|
|
|
// Track upstream SSE lifecycle events (guards against empty proxy responses)
|
|
const lifecycle = {
|
|
hasContent: false,
|
|
hasData: false,
|
|
hasMessageStart: false,
|
|
hasMessageDelta: false,
|
|
hasMessageStop: false,
|
|
/** Scan text for SSE lifecycle events and update tracking flags */
|
|
update(text: string) {
|
|
if (text.includes('"content_block_start"')) this.hasContent = true;
|
|
if (text.includes('"message_start"')) this.hasMessageStart = true;
|
|
if (text.includes('"message_delta"')) this.hasMessageDelta = true;
|
|
if (text.includes('"message_stop"')) this.hasMessageStop = true;
|
|
},
|
|
};
|
|
const isSuccessResponse =
|
|
(upstreamRes.statusCode || 200) >= 200 && (upstreamRes.statusCode || 200) < 300;
|
|
|
|
// If no changes were made, intercept to detect empty responses.
|
|
// In the real failure case (issue #350), upstream sends message_start but
|
|
// NOT message_delta/message_stop, so the synthetic response completes the
|
|
// stream. If upstream DID send message_stop, Claude Code treats the second
|
|
// synthetic block as additional content in the same conversation turn.
|
|
if (!mapper.hasChanges()) {
|
|
upstreamRes.on('data', (chunk: Buffer) => {
|
|
lifecycle.hasData = true;
|
|
lifecycle.update(chunk.toString('utf8'));
|
|
// Respect backpressure: pause upstream if client can't keep up
|
|
const canContinue = clientRes.write(chunk);
|
|
if (!canContinue) {
|
|
upstreamRes.pause();
|
|
clientRes.once('drain', () => upstreamRes.resume());
|
|
}
|
|
});
|
|
upstreamRes.on('end', () => {
|
|
try {
|
|
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
|
this.writeLog(
|
|
'warn',
|
|
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
|
);
|
|
clientRes.write(
|
|
this.buildSyntheticErrorResponse(
|
|
lifecycle.hasMessageStart,
|
|
lifecycle.hasMessageDelta,
|
|
lifecycle.hasMessageStop
|
|
)
|
|
);
|
|
}
|
|
clientRes.end();
|
|
} catch {
|
|
// Client may have disconnected — safe to ignore
|
|
}
|
|
resolve();
|
|
});
|
|
upstreamRes.on('error', reject);
|
|
return;
|
|
}
|
|
|
|
// Process SSE events for tool name restoration
|
|
let buffer = '';
|
|
|
|
upstreamRes.on('data', (chunk: Buffer) => {
|
|
lifecycle.hasData = true;
|
|
buffer += chunk.toString('utf8');
|
|
|
|
// Process complete SSE events
|
|
const events = buffer.split('\n\n');
|
|
buffer = events.pop() || ''; // Keep incomplete event in buffer
|
|
|
|
for (const event of events) {
|
|
if (!event.trim()) continue;
|
|
|
|
lifecycle.update(event);
|
|
|
|
const processedEvent = this.processSSEEvent(event, mapper);
|
|
clientRes.write(processedEvent + '\n\n');
|
|
}
|
|
});
|
|
|
|
upstreamRes.on('end', () => {
|
|
try {
|
|
// Process any remaining buffer
|
|
if (buffer.trim()) {
|
|
lifecycle.update(buffer);
|
|
const processedEvent = this.processSSEEvent(buffer, mapper);
|
|
clientRes.write(processedEvent + '\n\n');
|
|
}
|
|
|
|
// Safety net: if upstream sent data but no content blocks, inject synthetic response
|
|
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
|
this.writeLog(
|
|
'warn',
|
|
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
|
|
);
|
|
clientRes.write(
|
|
this.buildSyntheticErrorResponse(
|
|
lifecycle.hasMessageStart,
|
|
lifecycle.hasMessageDelta,
|
|
lifecycle.hasMessageStop
|
|
)
|
|
);
|
|
}
|
|
|
|
clientRes.end();
|
|
} catch {
|
|
// Client may have disconnected — safe to ignore
|
|
}
|
|
resolve();
|
|
});
|
|
|
|
upstreamRes.on('error', reject);
|
|
}
|
|
);
|
|
|
|
upstreamReq.on('timeout', () => upstreamReq.destroy(new Error('Upstream request timeout')));
|
|
upstreamReq.on('error', reject);
|
|
upstreamReq.write(bodyString);
|
|
upstreamReq.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a single SSE event, restoring tool names if present.
|
|
*/
|
|
private processSSEEvent(event: string, mapper: ToolNameMapper): string {
|
|
// Parse SSE format: data: {...}
|
|
const lines = event.split('\n');
|
|
const processedLines: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const jsonStr = line.slice(6); // Remove 'data: ' prefix
|
|
|
|
// Skip [DONE] marker
|
|
if (jsonStr.trim() === '[DONE]') {
|
|
processedLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
|
|
// Handle content_block_start with tool_use
|
|
if (
|
|
isRecord(data) &&
|
|
data.type === 'content_block_start' &&
|
|
isRecord(data.content_block) &&
|
|
data.content_block.type === 'tool_use' &&
|
|
typeof data.content_block.name === 'string'
|
|
) {
|
|
const originalName = mapper.restoreName(data.content_block.name);
|
|
data.content_block.name = originalName;
|
|
processedLines.push('data: ' + JSON.stringify(data));
|
|
continue;
|
|
}
|
|
|
|
// Handle message with content array (final message)
|
|
if (isRecord(data) && Array.isArray(data.content)) {
|
|
data.content = mapper.restoreToolUse(data.content as ContentBlock[]);
|
|
processedLines.push('data: ' + JSON.stringify(data));
|
|
continue;
|
|
}
|
|
|
|
// Pass through unchanged
|
|
processedLines.push(line);
|
|
} catch {
|
|
// Not valid JSON, pass through unchanged
|
|
processedLines.push(line);
|
|
}
|
|
} else {
|
|
// Non-data lines (event:, id:, etc.) pass through
|
|
processedLines.push(line);
|
|
}
|
|
}
|
|
|
|
return processedLines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build a synthetic minimal SSE response when upstream returns empty content.
|
|
* Prevents Claude Code from crashing with "No assistant message found".
|
|
* Omits lifecycle events that upstream already sent to avoid protocol violations.
|
|
*/
|
|
private buildSyntheticErrorResponse(
|
|
upstreamSentMessageStart = false,
|
|
upstreamSentMessageDelta = false,
|
|
upstreamSentMessageStop = false
|
|
): string {
|
|
const events: string[] = [];
|
|
|
|
// Only include message_start if upstream didn't already send one
|
|
if (!upstreamSentMessageStart) {
|
|
const msgId = `msg_synthetic_${Date.now()}`;
|
|
events.push(
|
|
`event: message_start\ndata: {"type":"message_start","message":{"id":"${msgId}","type":"message","role":"assistant","content":[],"model":"unknown","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`
|
|
);
|
|
}
|
|
|
|
events.push(
|
|
`event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
|
|
`event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"[Proxy Error] The upstream API returned an empty response. This typically occurs when the proxy drops unsigned thinking blocks during sub-agent execution. Please retry the request."}}`,
|
|
`event: content_block_stop\ndata: {"type":"content_block_stop","index":0}`
|
|
);
|
|
|
|
if (!upstreamSentMessageDelta) {
|
|
events.push(
|
|
`event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
|
);
|
|
}
|
|
|
|
if (!upstreamSentMessageStop) {
|
|
events.push(`event: message_stop\ndata: {"type":"message_stop"}`);
|
|
}
|
|
|
|
return events.join('\n\n') + '\n\n';
|
|
}
|
|
}
|