From 4d982ab3fa27a3303771df8a6f5ae398df2f2c82 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:59:57 +0900 Subject: [PATCH 1/7] feat(claude): gate translated Messages with a conservative compatibility policy Reimplements PR #3730 from source head 18d64748ade8001e05327726b2ae4b22e8393418 under the accepted track2 roadmap. Apply one policy before translation, retain only closed diagnostic codes, reject invalid configured modes, and persist shadow evidence through hydration. Validation: NOT RUN by explicit user instruction (no local tests, typecheck, build, install, prepush or live proxy). Parent owns test-layout registration, exact-head remote CI and independent implementation security review. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../docs/reference/configuration/server.md | 20 ++ src/claude/compatibility.ts | 192 +++++++++++++++ src/server/claude-messages.ts | 27 ++ src/server/request-log.ts | 16 +- src/types/config.ts | 2 + src/usage/log.ts | 22 ++ .../claude-compatibility.test.ts | 113 +++++++++ .../claude-messages-endpoint.test.ts | 231 +++++++++++++++++- tests/usage/request-log.test.ts | 49 ++++ tests/usage/usage-log.test.ts | 41 ++++ 10 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 src/claude/compatibility.ts create mode 100644 tests/claude-integration/claude-compatibility.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b793f159c1e..d80d6991af2 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,12 +317,32 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional compatibility admission for translated `/v1/messages` requests. `shadow` records unsupported features and continues; `enforce` returns an Anthropic-shaped 400 before inference. Native Anthropic passthrough remains unchanged. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | | `claudeCode.classifierFallbacks?` | `string[]` | unset | Ordered classifier targets used when `classifierModel` is not set. Same qualified `provider/model` form; the first usable entry wins. An explicit `modelMap` entry for the classifier model still outranks both. | | `claudeCode.subagentEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | inherit | Effort written to generated `~/.claude/agents/ocx-*.md`; separate from Codex guidance and proxy caps. Restart through `ocx claude` to regenerate. | +The compatibility policy applies to Claude Code, Desktop and other clients using translated +Messages, including `?beta=true` and non-streaming requests. Every translated target uses the +same conservative policy, including Anthropic and native Responses adapters. It rejects +document content, thinking/redacted-thinking replay, hosted search and execution tools, +tool-search references, active deferred loading, strict tools, non-default caller modes, +structured-output formats, explicit service-tier intent, MCP connector features, context +management, containers, inference placement and unsupported protocol fields or blocks. + +Unset preserves legacy translation. Cache hints, tool input examples and ordinary +thinking/effort settings are deliberately admitted with possible degradation: this setting +does not guarantee cache breakpoints or TTL, retained examples, exact thinking budgets or +lossless translation. Beta headers alone are not validated for feature support. Shadow +evidence contains only fixed protocol codes and derived reasons, retained in request logs +and `usage.jsonl` and restored on restart. An invalid non-unset mode returns a fixed 503 +configuration error on translated Messages. Configure the value in `config.json` and +restart the proxy to load it; there is no dedicated GUI setter. Count-tokens and direct +Responses/Chat APIs are outside this policy; successful token counting does not imply +Messages admission. This setting does not add a global authorization boundary. + Auto auth selects subscription when stored Claude auth is found, proxy when none is found, and subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts new file mode 100644 index 00000000000..75ba3c13550 --- /dev/null +++ b/src/claude/compatibility.ts @@ -0,0 +1,192 @@ +/** Opt-in admission for the translated Messages path; no adapter or credential state. */ +export type ClaudeCompatibilityMode = "shadow" | "enforce"; + +// False means deliberately tolerated degradation, not lossless representation. +const FEATURES = { + cache_control: false, + input_examples: false, + thinking_settings: false, + unknown_beta: false, + thinking_replay: true, + documents: true, + web_search_tool: true, + tool_search: true, + tool_reference: true, + deferred_tools: true, + strict_tools: true, + caller_mode: true, + structured_output: true, + service_tier: true, + mcp_tool: true, + code_execution: true, + computer_use: true, + server_tool: true, + context_management: true, + container: true, + inference_geo: true, + user_profile: true, + unknown_body_field: true, + unknown_content_block: true, +} as const; + +export type ClaudeFeatureCode = keyof typeof FEATURES; +const FEATURE_CODES = Object.keys(FEATURES) as ClaudeFeatureCode[]; +const MAX_FEATURE_CODES = 32; +const MAX_REASON_LENGTH = 512; +type Rec = Record; +const isRec = (value: unknown): value is Rec => + value !== null && typeof value === "object" && !Array.isArray(value); + +export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { + return value === "shadow" || value === "enforce"; +} + +/** Project only closed codes, including when reading an untrusted persisted row. */ +export function normalizeClaudeFeatureCodes(value: unknown): ClaudeFeatureCode[] { + if (!Array.isArray(value)) return []; + const codes = new Set(); + for (const code of value) { + if (typeof code === "string" && Object.hasOwn(FEATURES, code)) codes.add(code as ClaudeFeatureCode); + } + return FEATURE_CODES.filter(code => codes.has(code)).sort().slice(0, MAX_FEATURE_CODES); +} + +/** Never accept a caller-supplied reason, header value, model name or tool name. */ +export function claudeCompatibilityReason(codes: readonly ClaudeFeatureCode[], shadow: boolean): string | undefined { + const unsupported = codes.filter(code => FEATURES[code]); + if (unsupported.length === 0) return undefined; + return `${shadow ? "shadow: would reject" : "unsupported translated Claude features"}: ${unsupported.join(", ")}` + .slice(0, MAX_REASON_LENGTH); +} + +const BODY_FIELDS = new Set([ + "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", + "output_config", "metadata", "service_tier", "stop_sequences", "stream", + "temperature", "top_p", "top_k", "cache_control", "context_management", + "container", "inference_geo", "user_profile_id", "mcp_servers", "defer_tools", "deferred_tools", +]); + +function activeDeferred(value: unknown): boolean { + return value === true || (Array.isArray(value) ? value.length > 0 : isRec(value) && Object.keys(value).length > 0); +} + +function nonDirectCaller(value: unknown): boolean { + return value !== undefined && !(Array.isArray(value) && value.length === 1 && value[0] === "direct"); +} + +/** Complete finite detection. Only protocol content positions are visited, never schemas/arguments. */ +function detectFeatures(body: unknown, anthropicBeta?: string): Set { + const codes = new Set(); + // Header-only beta semantics are outside this policy. No header bytes become codes. + if (anthropicBeta?.trim()) codes.add("unknown_beta"); + if (!isRec(body)) return codes; // The existing Messages parser owns malformed top-level input. + if (Object.keys(body).some(key => !BODY_FIELDS.has(key))) codes.add("unknown_body_field"); + for (const [field, code] of [ + ["cache_control", "cache_control"], ["context_management", "context_management"], + ["container", "container"], ["inference_geo", "inference_geo"], + ["user_profile_id", "user_profile"], ["mcp_servers", "mcp_tool"], + ] as const) { + if (Object.hasOwn(body, field)) codes.add(code); + } + if (body.service_tier !== undefined && body.service_tier !== null) codes.add("service_tier"); + if (isRec(body.thinking)) codes.add("thinking_settings"); + if (isRec(body.output_config) + && (body.output_config.format != null || body.output_config.output_format != null)) codes.add("structured_output"); + if (activeDeferred(body.defer_tools) || activeDeferred(body.deferred_tools)) codes.add("deferred_tools"); + + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + if (!isRec(tool)) continue; + if (Object.hasOwn(tool, "cache_control")) codes.add("cache_control"); + if (Object.hasOwn(tool, "input_examples")) codes.add("input_examples"); + if (tool.strict === true) codes.add("strict_tools"); + if (tool.defer === true || tool.defer_loading === true) codes.add("deferred_tools"); + if (nonDirectCaller(tool.allowed_callers)) codes.add("caller_mode"); + const type = tool.type; + // Ordinary client function names do not convey hosted execution semantics. + if (type === undefined || type === "function" || type === "custom") continue; + if (type === "mcp_toolset") codes.add("mcp_tool"); + else if (typeof type === "string" && /^web_search_\d{8}$/.test(type)) codes.add("web_search_tool"); + else if (typeof type === "string" && /^tool_search(?:_tool_(?:regex|bm25))?(?:_\d{8})?$/.test(type)) codes.add("tool_search"); + else if (typeof type === "string" && /^code_execution_\d{8}$/.test(type)) codes.add("code_execution"); + else if (typeof type === "string" && /^computer(?:_toolset)?_\d{8}$/.test(type)) codes.add("computer_use"); + else codes.add("server_tool"); + } + } + + const scanBlock = (block: unknown, position: "message" | "system" | "result") => { + if (!isRec(block)) return; + if (Object.hasOwn(block, "cache_control")) codes.add("cache_control"); + if (position === "system" && block.type !== "text") codes.add("unknown_content_block"); + if (position === "result" && (typeof block.type !== "string" || !["text", "image", "document", "tool_reference"].includes(block.type))) { + codes.add("unknown_content_block"); + } + switch (block.type) { + case "text": + case "image": break; + case "document": codes.add("documents"); break; + case "thinking": + case "redacted_thinking": codes.add("thinking_replay"); break; + case "tool_reference": codes.add("tool_reference"); break; + case "tool_search_tool_result": codes.add("tool_search"); break; + case "web_search_tool_result": codes.add("web_search_tool"); break; + case "code_execution_tool_result": + case "bash_code_execution_tool_result": + case "text_editor_code_execution_tool_result": codes.add("code_execution"); break; + case "mcp_tool_use": + case "mcp_tool_result": codes.add("mcp_tool"); break; + case "tool_use": + if (block.caller !== undefined && (!isRec(block.caller) || block.caller.type !== "direct")) codes.add("caller_mode"); + break; + case "server_tool_use": + switch (block.name) { + case "tool_search": + case "tool_search_tool_regex": + case "tool_search_tool_bm25": codes.add("tool_search"); break; + case "web_search": codes.add("web_search_tool"); break; + case "code_execution": codes.add("code_execution"); break; + case "computer": codes.add("computer_use"); break; + default: codes.add("server_tool"); + } + break; + case "tool_result": break; // Children are visited below at the one supported nesting level. + default: codes.add("unknown_content_block"); + } + }; + if (Array.isArray(body.system)) for (const block of body.system) scanBlock(block, "system"); + if (Array.isArray(body.messages)) { + for (const message of body.messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + scanBlock(block, "message"); + if (isRec(block) && block.type === "tool_result" && Array.isArray(block.content)) { + for (const child of block.content) scanBlock(child, "result"); + } + } + } + } + return codes; +} + +export interface ClaudeCompatibilityResult { + featureCodes: ClaudeFeatureCode[]; + compatible: boolean; + decision: "allow" | "shadow" | "reject"; + reason?: string; +} + +/** All translated targets share this policy; native passthrough never calls it. */ +export function analyzeClaudeCompatibility( + body: unknown, + opts: { mode: ClaudeCompatibilityMode; anthropicBeta?: string }, +): ClaudeCompatibilityResult { + const detected = detectFeatures(body, opts.anthropicBeta); + const compatible = !FEATURE_CODES.some(code => detected.has(code) && FEATURES[code]); + const featureCodes = normalizeClaudeFeatureCodes([...detected]); + return { + featureCodes, + compatible, + decision: compatible ? "allow" : opts.mode === "shadow" ? "shadow" : "reject", + ...(!compatible ? { reason: claudeCompatibilityReason(featureCodes, opts.mode === "shadow") } : {}), + }; +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index cda2e3e45d6..20bc14e195a 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -16,6 +16,7 @@ import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -719,6 +720,32 @@ async function handleClaudeMessagesWithBudget( if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } + // Capture source semantics before effort rewriting or translation drops fields. + // This policy is uniform across translated targets, including later fallback attempts. + const compatibilityMode: unknown = config.claudeCode?.compatibility; + if (compatibilityMode !== undefined) { + if (!isClaudeCompatibilityMode(compatibilityMode)) { + logCtx.errorCode = "claude_compatibility_configuration"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 503, { closeReason: "non_stream" }); + return anthropicErrorResponse(503, "Invalid claudeCode.compatibility setting", "api_error"); + } + const compatibility = analyzeClaudeCompatibility(anthropicBody, { + mode: compatibilityMode, + anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, + }); + if (compatibility.decision === "reject") { + logCtx.errorCode = "claude_compatibility_unsupported"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, compatibility.reason!, "invalid_request_error"); + } + if (compatibility.decision === "shadow") { + logCtx.claudeCompatibility = { + decision: "shadow", + featureCodes: compatibility.featureCodes, + reason: compatibility.reason, + }; + } + } if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), diff --git a/src/server/request-log.ts b/src/server/request-log.ts index a4c942bd883..bd0f7e5b18d 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -24,6 +24,7 @@ import { isKnownUsageSurface, isCodexUsageAccountLogLabel, isValidReasoningWireValue, + normalizeClaudeCompatibilityUsageLog, readRecentUsageEntries, usageForFinalLog, usageStatusForFinalLog, @@ -31,6 +32,7 @@ import { type AttemptRecoveryKind, type PersistedUsageAttempt, type PersistedUsageEntry, + type PersistedClaudeCompatibilityLog, type UsageStatus, } from "../usage/log"; import { @@ -138,6 +140,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Opt-in shadow evidence, normalized again at the logging boundary. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } export interface RequestLogEntry { @@ -203,6 +207,8 @@ export interface RequestLogEntry { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes; no request or header values. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const requestLog: RequestLogEntry[] = []; @@ -271,6 +277,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const terminalStatus = asTerminalStatus(entry.terminalStatus); const closeReason = asCloseReason(entry.closeReason); const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); return { requestId: entry.requestId, timestamp: entry.timestamp, @@ -313,6 +320,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } @@ -368,10 +376,13 @@ export function addRequestLog(entry: RequestLogEntry) { // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a // sanitization bug because the safe surface is the one you check. const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); - const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); + const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined ? entry : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; + if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility; + else if (retained !== entry) delete retained.claudeCompatibility; entry = retained; retainRequestLogEntry(entry); try { @@ -431,6 +442,7 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), + ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), }); } catch { /* request logging must never fail a user request */ @@ -985,6 +997,7 @@ export function addFinalRequestLog( // means a future caller cannot reintroduce the hole by forgetting to sanitize first, and // the in-memory /api/logs row matches what usage.jsonl already stores. const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility); addLog({ requestId, timestamp: start, @@ -1034,6 +1047,7 @@ export function addFinalRequestLog( ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }); if (isUsageDebugEnabled()) { appendUsageDebug({ diff --git a/src/types/config.ts b/src/types/config.ts index e8dec5dda68..ee97cdf9ac6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,8 @@ import type { CodexAccount } from "./accounts"; * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. */ export interface OcxClaudeCodeConfig { + /** Opt-in translated Messages admission; unset keeps legacy behavior. Native passthrough is exempt. */ + compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ enabled?: boolean; /** diff --git a/src/usage/log.ts b/src/usage/log.ts index 257cb86d3ef..fd8408cc199 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -9,6 +9,24 @@ import { usageDisplayTotalTokens } from "./totals"; import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; +import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; + +export interface PersistedClaudeCompatibilityLog { + decision: "shadow"; + featureCodes: ClaudeFeatureCode[]; + reason?: string; +} + +/** Disk and in-memory callers share a closed-code projection; free-form reasons are discarded. */ +export function normalizeClaudeCompatibilityUsageLog(value: unknown): PersistedClaudeCompatibilityLog | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const row = value as Record; + if (row.decision !== "shadow") return undefined; + const featureCodes = normalizeClaudeFeatureCodes(row.featureCodes); + const reason = claudeCompatibilityReason(featureCodes, true); + if (!reason) return undefined; + return { decision: "shadow", featureCodes, reason }; +} export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; /** @@ -160,6 +178,8 @@ export interface PersistedUsageEntry { * contains prompts, credentials, or hidden reasoning. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes only; absent on older rows. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const KNOWN_USAGE_SURFACES = new Set>([ @@ -490,6 +510,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const callerServiceTier = sanitizeLogMetadataString(entry.callerServiceTier); const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier); const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; @@ -563,6 +584,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts new file mode 100644 index 00000000000..874fcba405c --- /dev/null +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../../src/claude/compatibility"; + +// Shapes from Anthropic's thinking, tool-search, strict-tool-use and Messages docs: +// https://platform.claude.com/docs/en/build-with-claude/thinking +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use +// https://platform.claude.com/docs/en/api/http/messages/create +const userBlock = (block: Record) => ({ messages: [{ role: "user", content: [block] }] }); +const assistantBlock = (block: Record) => ({ messages: [{ role: "assistant", content: [block] }] }); +const functionTool = { name: "lookup", input_schema: { type: "object", properties: {} } }; + +describe("Claude translated compatibility", () => { + const rejected: Array<[string, Record, string[]]> = [ + ["document", userBlock({ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }), ["documents"]], + ["nested document", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }), ["documents"]], + ["thinking replay", assistantBlock({ type: "thinking", thinking: "private-fixture", signature: "private-signature" }), ["thinking_replay"]], + ["redacted replay", assistantBlock({ type: "redacted_thinking", data: "opaque-fixture" }), ["thinking_replay"]], + ["hosted search", { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, ["tool_search"]], + ["hosted search history", assistantBlock({ type: "server_tool_use", name: "tool_search", id: "srv1", input: {} }), ["tool_search"]], + ["search result", userBlock({ type: "tool_search_tool_result", tool_use_id: "srv1", content: { type: "tool_search_tool_search_result", tool_references: [] } }), ["tool_search"]], + ["client search reference", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }), ["tool_reference"]], + ["deferred tool", { tools: [{ ...functionTool, defer_loading: true }] }, ["deferred_tools"]], + ["strict tool", { tools: [{ ...functionTool, strict: true }] }, ["strict_tools"]], + ["programmatic caller", { tools: [{ ...functionTool, allowed_callers: ["code_execution_20260120"] }] }, ["caller_mode"]], + ["caller replay", assistantBlock({ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "code_execution_20260120", tool_id: "srv1" } }), ["caller_mode"]], + ["structured output", { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, ["structured_output"]], + ["service tier", { service_tier: "standard_only" }, ["service_tier"]], + ["MCP connector", { mcp_servers: [{ type: "url", url: "https://example.invalid/mcp", authorization_token: "private-fixture" }] }, ["mcp_tool"]], + ["MCP toolset", { tools: [{ type: "mcp_toolset", mcp_server_name: "example" }] }, ["mcp_tool"]], + ["MCP replay", assistantBlock({ type: "mcp_tool_use", id: "t1", name: "lookup" }), ["mcp_tool"]], + ["web search controls", { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"], max_uses: 1 }] }, ["web_search_tool"]], + ["web search result", userBlock({ type: "web_search_tool_result", content: [] }), ["web_search_tool"]], + ["code execution", { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, ["code_execution"]], + ["computer toolset", { tools: [{ type: "computer_toolset_20260801", name: "computer" }] }, ["computer_use"]], + ["context editing", { context_management: { edits: [{ type: "clear_thinking_20251015", keep: "all" }] } }, ["context_management"]], + ["container", { container: "private-container" }, ["container"]], + ["placement", { inference_geo: "us" }, ["inference_geo"]], + ["profile", { user_profile_id: "private-profile" }, ["user_profile"]], + ["unknown body field", { future_option: "private-fixture" }, ["unknown_body_field"]], + ["unknown content", userBlock({ type: "future_block", payload: "private-fixture" }), ["unknown_content_block"]], + ["nested unknown content", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "future_block" }] }), ["unknown_content_block"]], + ["name cannot hide unsupported type", { tools: [{ type: "future_server_tool", name: "tool_search", input_schema: {} }] }, ["server_tool"]], + ]; + for (const [name, body, featureCodes] of rejected) { + test(`rejects ${name} in enforce and observes it in shadow`, () => { + const enforce = analyzeClaudeCompatibility(body, { mode: "enforce" }); + expect(enforce).toMatchObject({ decision: "reject", compatible: false, featureCodes }); + const shadow = analyzeClaudeCompatibility(body, { mode: "shadow" }); + expect(shadow).toMatchObject({ decision: "shadow", compatible: false, featureCodes }); + for (const result of [enforce, shadow]) { + expect(result.reason?.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(JSON.stringify(result)).not.toContain("example.invalid"); + } + }); + } + + test("ordinary client names, schemas and arguments are not protocol declarations", () => { + for (const name of ["mcp_lookup", "tool_search", "tool_search_tool_local", "safe_code_execution", "computer"]) { + const result = analyzeClaudeCompatibility({ + tools: [{ ...functionTool, name, type: "function", input_schema: { + type: "object", properties: { cache_control: { type: "string" }, strict: { const: true } }, + } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name, id: "t1", input: { + type: "document", defer_loading: true, mcp_servers: [], + } }] }], + }, { mode: "enforce" }); + expect(result).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); + } + }); + + test("inactive flags and direct callers remain ordinary tools", () => { + expect(analyzeClaudeCompatibility({ + defer_tools: false, deferred_tools: [], + tools: [{ ...functionTool, strict: false, defer_loading: false, allowed_callers: ["direct"] }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "direct" } }] }], + }, { mode: "enforce" })).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); + }); + + test("cache hints, examples and thinking settings are explicitly tolerated degradation", () => { + for (const thinking of [{ type: "disabled" }, { type: "enabled", budget_tokens: 2048 }, { type: "adaptive" }]) { + expect(analyzeClaudeCompatibility({ + thinking, + system: [{ type: "text", text: "private-fixture", cache_control: { type: "ephemeral", ttl: "1h" } }], + tools: [{ ...functionTool, input_examples: [{ value: "private-example" }] }], + }, { mode: "enforce" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["cache_control", "input_examples", "thinking_settings"], + }); + } + }); + + test("header volume cannot hide semantic rejection or persist header text", () => { + const result = analyzeClaudeCompatibility(userBlock({ type: "document" }), { + mode: "enforce", anthropicBeta: Array.from({ length: 100 }, (_, i) => `private-header-${i}`).join(","), + }); + expect(result).toEqual({ + decision: "reject", compatible: false, featureCodes: ["documents", "unknown_beta"], + reason: "unsupported translated Claude features: documents", + }); + expect(analyzeClaudeCompatibility({}, { mode: "enforce", anthropicBeta: "private-header" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["unknown_beta"], + }); + }); + + test("mode recognition is exact", () => { + expect(isClaudeCompatibilityMode("shadow")).toBe(true); + expect(isClaudeCompatibilityMode("enforce")).toBe(true); + for (const value of [undefined, null, false, 1, {}, [], "ENFORCE", "enforce ", "invalid"]) { + expect(isClaudeCompatibilityMode(value)).toBe(false); + } + }); +}); diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 558555a43e1..a84e09a8782 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -4,14 +4,17 @@ import { logsFromApiBody } from "../helpers/logs-api"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { readRecentUsageEntries } from "../../src/usage/log"; import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; import type { DesktopProfile } from "../../src/claude/desktop-profile"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; import { clearableDeadline } from "../../src/lib/abort"; import { clearRequestLogsForTests, + addRequestLog, getRequestLogEntries, + hydrateRequestLogsFromDisk, type RequestLogContext, } from "../../src/server/request-log"; import { startServer } from "../../src/server"; @@ -1385,6 +1388,232 @@ test("claudeCode.enabled=false -> 403 permission_error on both routes", async () } }); +test("compatibility is uniform across translated adapters and rejects before inference", async () => { + let sends = 0; + const upstream = Bun.serve({ port: 0, fetch() { sends++; return new Response("unexpected inference", { status: 500 }); } }); + const baseUrl = new URL("/v1", upstream.url).href; + const features: Array> = [ + { messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }, + { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private-fixture", signature: "opaque-fixture" }] }] }, + { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "opaque-fixture" }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }] }] }, + { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, defer_loading: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, strict: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, allowed_callers: ["code_execution_20260120"] }] }, + { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"] }] }, + { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, + { service_tier: "standard_only" }, + { mcp_servers: [{ type: "url", name: "mcp", url: "https://example.invalid", authorization_token: "private-fixture" }] }, + { tools: [{ type: "mcp_toolset", mcp_server_name: "mcp" }] }, + { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, + { tools: [{ type: "computer_20250124", name: "computer" }] }, + { context_management: { edits: [] } }, { container: "private-fixture" }, + { inference_geo: "us" }, { user_profile_id: "private-fixture" }, + { future_option: true }, + { messages: [{ role: "user", content: [{ type: "future_block" }] }] }, + ]; + try { + for (const adapter of ["anthropic", "openai-responses", "openai-chat"] as const) { + const config = mockConfig(baseUrl, { compatibility: "enforce" }); + config.providers.mock.adapter = adapter; + saveConfig(config); + const server = startServer(0); + try { + clearRequestLogsForTests(); + for (const [index, feature] of features.entries()) { + const response = await fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", headers: { + "content-type": "application/json", "x-api-key": "placeholder", + "anthropic-beta": Array.from({ length: 50 }, (_, i) => `private-header-${i}`).join(","), + }, + body: JSON.stringify({ model: "mock/test-model", max_tokens: 64, stream: index % 2 === 0, + messages: [{ role: "user", content: "hi" }], ...feature }), + }); + expect(response.status).toBe(400); + const error = await response.json() as { type: string; error: { type: string; message: string } }; + expect(error.type).toBe("error"); + expect(error.error.type).toBe("invalid_request_error"); + expect(error.error.message).not.toContain("private-"); + expect(getRequestLogEntries()).toHaveLength(index + 1); + expect(getRequestLogEntries().at(-1)?.errorCode).toBe("claude_compatibility_unsupported"); + } + expect(sends).toBe(0); + // Count-token success is intentionally independent of Messages admission. + const counted = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", messages: [{ role: "user", content: [{ type: "document" }] }] }), + }); + expect(counted.status).toBe(200); + } finally { await server.stop(true); } + } + } finally { await upstream.stop(true); } +}); + +test("compatibility unset, shadow persistence and ordinary enforce controls", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + const document = { model: "mock/test-model", max_tokens: 64, stream: true, + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }; + try { + for (const compatibility of [undefined, "shadow", "enforce"] as const) { + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const body = compatibility === "enforce" ? { + ...document, stream: false, thinking: { type: "disabled" }, + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + tools: [{ name: "mcp_lookup", input_schema: { type: "object" }, strict: false, defer_loading: false, input_examples: [{}] }], + messages: [{ role: "user", content: "hi" }], + } : document; + const response = await postMessages(server.url.toString(), body); + expect(response.status).toBe(200); + await response.text(); + const expected = compatibility === "shadow" ? { + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + } : undefined; + expect(getRequestLogEntries()).toHaveLength(1); + const requestId = getRequestLogEntries()[0].requestId; + expect(getRequestLogEntries()[0].claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + const dto = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(dto.find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + clearRequestLogsForTests(); + hydrateRequestLogsFromDisk(); + expect(getRequestLogEntries().find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + if (compatibility === "shadow") { + addRequestLog({ requestId: "compatibility-boundary", timestamp: Date.now(), model: "test-model", provider: "mock", + status: 200, durationMs: 1, usageStatus: "unreported", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }); + const rows = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(rows.find(row => row.requestId === "compatibility-boundary")?.claudeCompatibility).toEqual(expected); + } + } finally { await server.stop(true); } + } + expect(captured).toHaveLength(3); + } finally { await upstream.stop(true); } +}); + +test("present invalid compatibility modes return one fixed 503 before translation", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + try { + for (const compatibility of [null, "", "enfroce", false, 1, [], { secret: "private-fixture" }]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ + ...mockConfig(new URL("/v1", upstream.url).href), claudeCode: { compatibility }, + })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 16, messages: [{ role: "user", content: "hi" }], + }); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ type: "error", error: { type: "api_error", message: "Invalid claudeCode.compatibility setting" } }); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].errorCode).toBe("claude_compatibility_configuration"); + if (compatibility === null) { + const malformed = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: { "content-type": "application/json" }, body: "{", + }); + expect(malformed.status).toBe(400); + expect(getRequestLogEntries()).toHaveLength(2); + } + } finally { await server.stop(true); } + } + expect(urls).toEqual([]); + } finally { await upstream.stop(true); } +}); + +test("native passthrough precedes even invalid compatibility mode", async () => { + const received: unknown[] = []; + const upstream = Bun.serve({ port: 0, async fetch(req) { + received.push(await req.json()); + return Response.json({ id: "msg_test", type: "message", role: "assistant", model: "claude-haiku-4-5", + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }); + } }); + const body = { model: "claude-haiku-4-5", max_tokens: 16, messages: [ + { role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }, + ] }; + try { + for (const compatibility of ["enforce", "invalid"]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ ...mockConfig("http://127.0.0.1:1/v1"), + claudeCode: { compatibility, anthropicBaseUrl: upstream.url.origin } })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { method: "POST", + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, body: JSON.stringify(body) }); + expect(response.status).toBe(200); + await response.text(); + } finally { await server.stop(true); } + } + expect(received).toEqual([body, body]); + } finally { await upstream.stop(true); } +}); + +test("compatibility survives management toggles and rejects Desktop source features", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "enforce" })); + const server = startServer(0); + try { + for (const enabled of [false, true]) { + const toggle = await fetch(new URL("/api/native-integrations/claude", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }), + }); + expect(toggle.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + } + const settings = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ injectAgents: false }), + }); + expect(settings.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + buildDesktop3pRegistry([], [{ provider: "mock", id: "test-model" }], { + version: 1, assignments: { "mock/test-model": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/test-model", fable: null, sonnet: null, haiku: null }, + }); + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "claude-opus-4-8-20260201", max_tokens: 16, + system: [{ type: "text", text: "" }], + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }], + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("thinking_replay"); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].surface).toBe("claude-desktop"); + expect(urls).toEqual([]); + } finally { + buildDesktop3pRegistry([], []); + await server.stop(true); + await upstream.stop(true); + } +}); + +test("shadow captures source thinking settings before an effort directive removes them", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "shadow" })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 64, stream: true, thinking: { type: "disabled" }, + system: [{ type: "text", text: "\n" }], + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "fixture" } }] }], + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents", "thinking_settings"], reason: "shadow: would reject: documents", + }); + } finally { await server.stop(true); await upstream.stop(true); } +}); + async function postMessages(serverUrl: string, body: Record): Promise { return fetch(new URL("/v1/messages", serverUrl), { method: "POST", diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index 1a3b4e32640..4f15906373b 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -58,6 +58,55 @@ function log(overrides: Partial): RequestLogEntry { } describe("request log metadata", () => { + test("Claude evidence is normalized before direct ring ingress and cannot be mutated afterwards", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-claude-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + const raw = JSON.parse('{"decision":"shadow","featureCodes":["documents","unknown_beta","private-header"],"reason":"private-reason"}'); + addRequestLog(log({ claudeCompatibility: raw })); + raw.featureCodes.length = 0; + raw.reason = "changed"; + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + const finalized: RequestLogEntry[] = []; + addFinalRequestLog("claude-final", 1, { model: "test", provider: "mock", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents"],"reason":"private-final"}') }, + 200, { closeReason: "non_stream" }, row => finalized.push(row)); + expect(finalized).toHaveLength(1); + expect(finalized[0].claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + }); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + + test("custom hydration normalizes Claude evidence and ignores malformed optional rows", () => { + clearRequestLogsForTests(); + const raw: PersistedUsageEntry[] = [ + { ...log({ requestId: "legacy" }) }, + { ...log({ requestId: "shadow" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }, + { ...log({ requestId: "malformed" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":null,"reason":"private-reason"}') }, + ]; + try { + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(3); + expect(getRequestLogEntries().map(row => row.claudeCompatibility)).toEqual([ + undefined, { decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents" }, undefined, + ]); + raw[1].claudeCompatibility!.featureCodes.length = 0; + expect(getRequestLogEntries()[1]?.claudeCompatibility?.featureCodes).toEqual(["documents"]); + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(0); + expect(JSON.stringify(getRequestLogEntries())).not.toContain("private-"); + } finally { clearRequestLogsForTests(); } + }); + test("upstream credential attribution requires the resolved canonical xAI transport", () => { const attempt = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); const oauth = { adapter: "openai-chat", authMode: "oauth" as const, baseUrl: "https://cli-chat-proxy.grok.com/v1" }; diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index e3c490535ed..f39c778d2b2 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -7,6 +7,8 @@ import { appendUsageEntry, currentUsageLogRevision, normalizeUsageEntryForTest, + normalizeClaudeCompatibilityUsageLog, + normalizePersistedUsageRow, readRecentUsageEntries, readUsageEntries, readUsageEntriesForManagement, @@ -39,6 +41,45 @@ afterEach(() => { }); describe("usage log", () => { + test("Claude shadow metadata round trips as closed codes and a regenerated reason", () => { + const evidence = normalizeClaudeCompatibilityUsageLog({ + decision: "shadow", featureCodes: ["unknown_beta", "documents", "documents", "private-header", "__proto__"], + reason: "private-reason", extra: "private-payload", + }); + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(evidence).toEqual(expected); + appendUsageEntry({ requestId: "claude-shadow", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 3, outputTokens: 2 }, + claudeCompatibility: evidence }); + // Later mutation of the caller's evidence cannot rewrite the serialized record. + evidence!.featureCodes.length = 0; + resetUsageReadCacheForTests(); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.usage).toMatchObject({ inputTokens: 3, outputTokens: 2 }); + expect(readFileSync(usageLogPath(), "utf8")).not.toContain("private-"); + }); + + test("legacy and malformed persisted Claude metadata does not poison readers", () => { + const base = { requestId: "claude-legacy", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "unreported" }; + const invalid: unknown[] = [undefined, null, [], "private-value", 1, + { decision: "reject", featureCodes: ["documents"] }, + { decision: "shadow", featureCodes: "documents" }, + { decision: "shadow", featureCodes: [null, {}, "constructor", "private-header"] }, + { decision: "shadow", featureCodes: ["cache_control"], reason: "private-reason" }, + ]; + for (const claudeCompatibility of invalid) { + const row = normalizePersistedUsageRow({ ...base, claudeCompatibility }); + expect(row).toBeDefined(); + expect(row?.claudeCompatibility).toBeUndefined(); + } + writeFileSync(usageLogPath(), invalid.map(claudeCompatibility => JSON.stringify({ ...base, claudeCompatibility })).join("\n") + "\n"); + resetUsageReadCacheForTests(); + expect(readUsageEntries()).toHaveLength(invalid.length); + expect(readUsageEntries().every(row => row.claudeCompatibility === undefined)).toBe(true); + }); + test("round trips only recognized per-attempt xAI credential sources", () => { const attempt = { ordinal: 1, provider: "xai", model: "grok-test", adapter: "openai-chat", status: 200, From 3ff52b36e2e921890dfcf52b357db3ebfa062ce2 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:02:38 +0900 Subject: [PATCH 2/7] test(claude): register compatibility policy coverage --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3d84e77bd27..6b8c5d83fff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -88,6 +88,7 @@ "^(?:chatgpt|generic|state)-" ] }, + "claude-compatibility.test.ts": "claude-integration", "claude-integration": { "match": [] }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dc1bb60a246..6cc854d0836 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -116,6 +116,7 @@ "cl01-claude-outbound-review-regressions.test.ts": "routing", "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", + "claude-compatibility.test.ts": "claude-integration", "claude-529-mapping.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", From 4255bfac615c59602088a9af5045762067d262c2 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:05:12 +0900 Subject: [PATCH 3/7] fix(test): place compatibility test in the explicit layout inventory [skip ci] --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6b8c5d83fff..794ded9ee5f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -88,7 +88,6 @@ "^(?:chatgpt|generic|state)-" ] }, - "claude-compatibility.test.ts": "claude-integration", "claude-integration": { "match": [] }, @@ -291,6 +290,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6cc854d0836..c68c85ddbb1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -116,7 +116,6 @@ "cl01-claude-outbound-review-regressions.test.ts": "routing", "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", - "claude-compatibility.test.ts": "claude-integration", "claude-529-mapping.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", @@ -126,6 +125,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", From 0f8936b1f692d72ff1d2c1dd6218183dd0e9b882 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 04:12:57 +0900 Subject: [PATCH 4/7] test(windows): retain an owned module cache in composed fixtures [skip ci] --- .../070_windows_fixture_cache.md | 25 ++++++++++++++++ .../codex-composed-acceptance.test.ts | 29 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md diff --git a/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md new file mode 100644 index 00000000000..aca7abd8446 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md @@ -0,0 +1,25 @@ +# Windows composed-fixture cache ownership + +## Trigger and plan amendment + +Final integrated run 34049728209 failed only Windows shard 2/6's composed-toggle B case with a request timeout. Product changes did not run in that management-only case. Three-head diagnostic run 34051777446 kept all deadlines/assertions, comparing base 24c761a, prior d60a0716 and current 84c94f3; all samples passed, but OFF consistently consumed 20-26 seconds of a 30-second request budget. + +## Controlled evidence + +Same-VM run https://github.com/lidge-jun/opencodex/actions/runs/34053472964 changed only fixture child PowerShell module-cache policy, on fixed source 84c94f3. The parent cache was read into a private job-owned regular file; no original cache path was passed to experiment subprocesses. All test assertions and deadlines remained unchanged. + +| Condition | Startup | OFF request | Result | +| --- | --- | --- | --- | +| Original A1 | 35.26 s | abort at 30.00 s | timeout; held-sync 45 s abort also recorded | +| Original A2 | 30.11 s | 25.50 s | pass | +| Owned empty, two samples | 26.71-27.41 s | 26.07-26.96 s | pass | +| Owned prepared copy, two samples | 6.12-6.29 s | 3.36-3.37 s | pass | +| Restored original | 27.50 s | 26.15 s | pass | + +The copied-cache whole composed file also passed (7 pass, 1 pre-existing skip, 0 fail). A1 has mixed abort observations, so the latency comparison uses the complete A2 and restored-control samples. A mere empty destination did not remove the delay. In copied samples the stale sync completed after the provider release; original controls exhausted discovery before release. No guard-ablation claim is made. + +## Scoped delta and acceptance + +MODIFY tests/codex-integration/codex-composed-acceptance.test.ts only: seed a Windows-only constructor-owned module cache using the existing Desktop fixture policy, outside the Codex manifest root, and pass only that owned destination to children. Preserve HOME/USERPROFILE variants, real SID/service evidence, coordinator paths, provider hold/release, cleanup, all assertions and all deadlines. No product, workflow, or identity-policy changes. + +The isolated diagnostic workflows are not delivery changes. Independent source/security review and a fresh full cross-platform run of the combined latest-dev integration head remain required. Local suites/typecheck/build are NOT RUN per instruction. This support layer is separate from the four product PRs. diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index 20d4943912a..f1ef88860cb 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -8,6 +8,8 @@ */ import { afterEach, describe, expect, test } from "bun:test"; import { + copyFileSync, + rmSync, existsSync, lstatSync, mkdirSync, @@ -19,7 +21,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { createHash } from "node:crypto"; import { Database } from "bun:sqlite"; @@ -130,12 +132,36 @@ class Fixture { readonly lockAllowlist: string[]; readonly serviceManagerEnv: Record; readonly serviceManagerPreloadPath: string | undefined; + readonly powerShellCacheEnv: Record = {}; readonly children: Array> = []; constructor() { for (const path of [this.codex, this.ocx, this.homeA, this.homeB, this.userprofileA, this.userprofileB, this.runtime, this.provider]) { mkdirSync(path, { recursive: true, mode: 0o700 }); } + if (process.platform === "win32") { + // Fresh child profiles otherwise repeatedly rebuild PowerShell's command cache. + // Seed one owned copy per fixture; children must never update the parent cache. + const cache = join(this.root, "module-analysis-cache"); + this.powerShellCacheEnv.PSModuleAnalysisCachePath = cache; + const source = Object.entries(process.env).find(([key]) => + key.toLowerCase() === "psmoduleanalysiscachepath")?.[1]; + if (source && isAbsolute(source)) { + try { + const before = lstatSync(source); + if (before.isFile() && !before.isSymbolicLink()) { + copyFileSync(source, cache); + if (lstatSync(cache).size !== before.size) rmSync(cache, { force: true }); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ESTALE") { + throw new Error("Composed fixture could not read or copy the PowerShell module cache"); + } + rmSync(cache, { force: true }); + } + } + } this.lockPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(this.codex)); this.lockAllowlist = [this.lockPath, `${this.lockPath}-journal`, `${this.lockPath}-wal`, `${this.lockPath}-shm`]; for (const path of this.lockAllowlist) { @@ -155,6 +181,7 @@ class Fixture { // Do not inherit ambient homes or proxy configuration. `process.execPath` // is absolute, so a PATH is intentionally unnecessary for CLI children. return { + ...this.powerShellCacheEnv, HOME: home, USERPROFILE: userprofile, // Windows os.homedir() follows USERPROFILE, while POSIX follows HOME. From 4ce1b8fe00085338da1e4722c01ab45f2f2baf0d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 05:03:49 +0900 Subject: [PATCH 5/7] test: clean composed fixture on cache setup failure [skip ci] --- .../070_windows_fixture_cache.md | 6 +++ .../codex-composed-acceptance.test.ts | 44 +++++++++++-------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md index aca7abd8446..90060198348 100644 --- a/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md +++ b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md @@ -23,3 +23,9 @@ The copied-cache whole composed file also passed (7 pass, 1 pre-existing skip, 0 MODIFY tests/codex-integration/codex-composed-acceptance.test.ts only: seed a Windows-only constructor-owned module cache using the existing Desktop fixture policy, outside the Codex manifest root, and pass only that owned destination to children. Preserve HOME/USERPROFILE variants, real SID/service evidence, coordinator paths, provider hold/release, cleanup, all assertions and all deadlines. No product, workflow, or identity-policy changes. The isolated diagnostic workflows are not delivery changes. Independent source/security review and a fresh full cross-platform run of the combined latest-dev integration head remain required. Local suites/typecheck/build are NOT RUN per instruction. This support layer is separate from the four product PRs. + +## Follow-up source review + +External review of #3808 identified a construction-failure cleanup gap: the fixture is registered only after its constructor returns. The cache-setup block now removes its own temporary root with bounded retries before rethrowing. Parent cache sources and shared coordinator paths remain outside that cleanup. Independent source review passed; final remote verification will include the delta. + +Run 34054412656 passed Windows composed shard 2, including B at 8.511 seconds. It failed a different competing-OFF fixture in shard 6 before the flip started; this does not negate the cache-control result and is tracked separately. diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index f1ef88860cb..b333fa3c659 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -139,28 +139,34 @@ class Fixture { for (const path of [this.codex, this.ocx, this.homeA, this.homeB, this.userprofileA, this.userprofileB, this.runtime, this.provider]) { mkdirSync(path, { recursive: true, mode: 0o700 }); } - if (process.platform === "win32") { - // Fresh child profiles otherwise repeatedly rebuild PowerShell's command cache. - // Seed one owned copy per fixture; children must never update the parent cache. - const cache = join(this.root, "module-analysis-cache"); - this.powerShellCacheEnv.PSModuleAnalysisCachePath = cache; - const source = Object.entries(process.env).find(([key]) => - key.toLowerCase() === "psmoduleanalysiscachepath")?.[1]; - if (source && isAbsolute(source)) { - try { - const before = lstatSync(source); - if (before.isFile() && !before.isSymbolicLink()) { - copyFileSync(source, cache); - if (lstatSync(cache).size !== before.size) rmSync(cache, { force: true }); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "ESTALE") { - throw new Error("Composed fixture could not read or copy the PowerShell module cache"); + try { + if (process.platform === "win32") { + // Fresh child profiles otherwise repeatedly rebuild PowerShell's command cache. + // Seed one owned copy per fixture; children must never update the parent cache. + const cache = join(this.root, "module-analysis-cache"); + this.powerShellCacheEnv.PSModuleAnalysisCachePath = cache; + const source = Object.entries(process.env).find(([key]) => + key.toLowerCase() === "psmoduleanalysiscachepath")?.[1]; + if (source && isAbsolute(source)) { + try { + const before = lstatSync(source); + if (before.isFile() && !before.isSymbolicLink()) { + copyFileSync(source, cache); + if (lstatSync(cache).size !== before.size) rmSync(cache, { force: true }); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ESTALE") { + throw new Error("Composed fixture could not read or copy the PowerShell module cache"); + } + rmSync(cache, { force: true }); } - rmSync(cache, { force: true }); } } + } catch (error) { + // Construction precedes registration in roots, so afterEach cannot own this cleanup. + rmSync(this.root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + throw error; } this.lockPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(this.codex)); this.lockAllowlist = [this.lockPath, `${this.lockPath}-journal`, `${this.lockPath}-wal`, `${this.lockPath}-shm`]; From c7a96b14f27c127e2e59f75a9daabd3fce306a5c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 05:10:38 +0900 Subject: [PATCH 6/7] test: allocate Windows competing OFF preparation separately [skip ci] --- .../080_windows_sync_preparation.md | 18 ++++++++++++++++++ tests/codex-integration/codex-sync-api.test.ts | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md diff --git a/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md b/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md new file mode 100644 index 00000000000..cd8c79bcc33 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md @@ -0,0 +1,18 @@ +# Windows competing-OFF preparation allocation + +## Failure and bounded plan + +Final run 34054412656 passed every individual job except Windows shard 6. The competing-OFF test refused to begin its real second-process flip: 32,262 ms remained of the 85,000 ms child budget after 52,738 ms of preparation. The unchanged flip plus reap requires 45,000 ms. This was a pre-flip budget rejection, not an outer timeout or a product assertion failure. Unlike the composed fixture, this test already inherits the ambient child environment; no cache-causation claim is made. + +MODIFY only the test's named preparation allocation: on Windows reserve two existing boot budgets for import, identity and admission preparation, then retain the original single flip and reap budgets. Windows CI child/test limits become 125/130 seconds; other platforms retain 85/90. No product deadline, assertion, coordinator, identity or service evidence changes. + +## Remote control + +Diagnostic run https://github.com/lidge-jun/opencodex/actions/runs/34056824267 checked out fixed source 0f8936b1f692d72ff1d2c1dd6218183dd0e9b882 and used a 52-second TOTAL preparation floor before the original remaining-budget guard. + +- Unchanged control passed and completed its real flip. +- Old allocation rejected the floor before flip, without timeout. +- New allocation completed the real flip and passed the original skip and unchanged-config assertions. +- With the new allocation, a one-site diagnostic mutation stored ON for the OFF request. The real flip process completed successfully, but the original result assertion failed: applied instead of skipped/desired_disabled. No timeout or budget refusal contaminated that failure. + +The isolated job restored both files byte-for-byte. Its synthetic floor, traces, workflow and production mutation are excluded from delivery. This proves the preparation allocation and test sensitivity to broken OFF persistence, not every downstream guard. Independent diagnostic security review passed. Full integration CI remains the delivery gate; no local suites, typecheck, install or build were run. diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index 011f3d0f685..09e67722315 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -19,7 +19,13 @@ const TEST_HOME = join(TEST_DIR, "home"); const repoRoot = resolveRepoRoot(); const COMPETING_OFF_REAP_MS = 5_000; const COMPETING_OFF_BOOT_MS = SPAWN_BUDGET_MS - COMPETING_OFF_REAP_MS; -const COMPETING_OFF_CHILD_MS = 2 * COMPETING_OFF_BOOT_MS + COMPETING_OFF_REAP_MS; +// Windows preparation performs real identity/admission preflight before discovery. +// Reserve that work separately: CI observed 52.7s before the flip could even start. +// The second process still keeps its original boot and reap limits. +const COMPETING_OFF_PREPARATION_MS = process.platform === "win32" + ? 2 * COMPETING_OFF_BOOT_MS + : COMPETING_OFF_BOOT_MS; +const COMPETING_OFF_CHILD_MS = COMPETING_OFF_PREPARATION_MS + COMPETING_OFF_BOOT_MS + COMPETING_OFF_REAP_MS; const COMPETING_OFF_TEST_MS = COMPETING_OFF_CHILD_MS + COMPETING_OFF_REAP_MS; let prevCodexHome: string | undefined; let prevOpenCodexHome: string | undefined; From 4a759716a940595b999805be40d1514c100d63cf Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 05:35:56 +0900 Subject: [PATCH 7/7] test: own Windows shim advisory process deadline [skip ci] --- .../090_windows_shim_budget.md | 14 ++++++++++++++ .../codex-integration/codex-shim-readiness.test.ts | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md diff --git a/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md b/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md new file mode 100644 index 00000000000..7d85053bbdb --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md @@ -0,0 +1,14 @@ +# Windows unreadable-config shim fixture deadline + +Final run 34057173038 passed 23 individual jobs and failed only Windows shard 2 (plus aggregate). The original shim unreadable-config test had a 10-second outer timeout and no owned child deadline. Bun killed the dangling child at 10.24 seconds, yielding status null. Fixture and inspected runtime source were unchanged since 8615f1a. The specific slow runtime stage is unproven; this is not an environmental or cache-causation claim. + +MODIFY only that subprocess fixture: use the existing 45-second spawn budget on Windows and a named 5-second outer cleanup allowance. Preserve POSIX's 10-second limit, all temporary paths, fake launcher, real install/diagnosis/advisory path and original exit/output assertions. Error/signal diagnostics are fixed and omit captured output. Product behavior is unchanged. + +Remote diagnostic https://github.com/lidge-jun/opencodex/actions/runs/34058337624 pinned c7a96b14f and demonstrated: + +- Unchanged control: real CLI exit 0 and original assertions pass. +- With a 12-second preload delay, old limit: child is signalled and test times out. +- Same delayed CLI with owned Windows deadline: exit 0 and original assertions pass. +- Same new limit with the readiness collector's advisory catch changed to rethrow: real CLI exits 1 without timeout/signal; the original exit-0 assertion fails. + +The diagnostic restored source bytes and is excluded from delivery. Its initial run 34058187661 failed before tests because Python selected a Windows legacy codec; explicit UTF-8 corrected that diagnostic-only error. No passing rerun was used to erase a product failure. Source/security review of the diagnostic and independent source review of the candidate passed. No local suites, installs, typecheck or builds were run. Final cross-platform CI remains required for the published combined head. diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index 9780321d48b..b6b4e04e9bd 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -11,9 +11,16 @@ import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { codexShimReadinessWarnings } from "../../src/cli/codex-shim-readiness"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); +// This case proves a real install followed by advisory collection, not startup latency. +const SHIM_INSTALL_CHILD_MS = process.platform === "win32" ? SPAWN_BUDGET_MS : undefined; +const SHIM_INSTALL_CLEANUP_MS = 5_000; +const SHIM_INSTALL_CASE_MS = SHIM_INSTALL_CHILD_MS === undefined + ? 10_000 + : SHIM_INSTALL_CHILD_MS + SHIM_INSTALL_CLEANUP_MS; const ready = { routingKind: "native" as const, @@ -169,13 +176,18 @@ describe("Codex shim install readiness", () => { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`, }, encoding: "utf8", + timeout: SHIM_INSTALL_CHILD_MS, + killSignal: "SIGKILL", }); + if (result.error || result.signal !== null) { + throw new Error(`Shim install fixture did not complete: error=${result.error?.name ?? "none"} signal=${result.signal ?? "none"}`); + } expect(result.status).toBe(0); expect(result.stdout).toStartWith("⚠️ Codex autostart shim installed"); expect(result.stderr).toContain("Codex routing could not be verified"); } finally { removeTreeWithRetry(root); } - }, 10_000); + }, SHIM_INSTALL_CASE_MS); });