diff --git a/bun.lock b/bun.lock index 1617d2a51cd..bcb2528f9b8 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.4", - "zod": "4.4.3", + "zod": "4.6.6", }, "devDependencies": { "@types/bun": "1.4.0", @@ -25,6 +25,7 @@ ], "overrides": { "@hono/node-server": "2.1.0", + "body-parser": "2.4.0", "fast-uri": "^3.1.7", "hono": "4.13.8", "ip-address": "^10.4.0", @@ -137,7 +138,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], "bun": ["bun@1.4.4", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.4", "@oven/bun-darwin-x64": "1.4.4", "@oven/bun-freebsd-aarch64": "1.4.4", "@oven/bun-freebsd-x64": "1.4.4", "@oven/bun-linux-aarch64": "1.4.4", "@oven/bun-linux-aarch64-android": "1.4.4", "@oven/bun-linux-aarch64-musl": "1.4.4", "@oven/bun-linux-x64": "1.4.4", "@oven/bun-linux-x64-android": "1.4.4", "@oven/bun-linux-x64-musl": "1.4.4", "@oven/bun-windows-aarch64": "1.4.4", "@oven/bun-windows-x64": "1.4.4" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-hj0ASdG6PNjLZGFAXktWb3Uaj/dcv/1wCsIttoEXADNIQBsGjTr+5uLKYbrDkuc7NT0Tkj4yPIqJI2jDxXdvVg=="], @@ -315,11 +316,13 @@ "wreq-js": ["wreq-js@2.3.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], } diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3e0d89389d2..e76d2f47ac1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.92.0" +version = "2.93.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 525d32e9905..e721358a158 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.92.0" +version = "2.93.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0445c9ce40c..2d1844dbdaf 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.92.0", + "version": "2.93.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 435e83f1a90..018ad34f51e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -169,6 +169,12 @@ API, the adapter removes item-level `extra_content` from the outgoing HTTP reque create. Native `/responses/compact` applies the same projection. The original history keeps the Google thought signature for later Google turns. Other Responses gateways retain their extensions. +OpenAI-operated destinations receive replayed `url_citation` annotations with missing or invalid +titles and indices repaired. This applies to ordinary HTTP/WebSocket requests and native +`/responses/compact`. Custom gateways keep the original annotations even when +`supportsNativeCompactEndpoint: true` enables native compaction. Citation repair leaves the +client's stored history unchanged. + The adapter preserves the incoming client's `User-Agent` as a fallback in both auth modes because some Responses-compatible providers use the Codex client fingerprint for compatibility behavior. An explicitly configured provider `User-Agent` remains authoritative regardless of header casing; diff --git a/gui/bun.lock b/gui/bun.lock index 656b3ac165f..c8a6d4a42d4 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -15,7 +15,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", - "happy-dom": "20.11.2", + "happy-dom": "21.0.4", "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", @@ -133,7 +133,7 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], diff --git a/gui/package.json b/gui/package.json index b37de5dd949..045ed340086 100644 --- a/gui/package.json +++ b/gui/package.json @@ -27,7 +27,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", - "happy-dom": "20.11.2", + "happy-dom": "21.0.4", "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", diff --git a/package.json b/package.json index 89176da2851..54e8387e2ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.92.0", + "version": "2.93.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", @@ -83,7 +83,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.4", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@types/bun": "1.4.0", @@ -94,6 +94,7 @@ }, "overrides": { "@hono/node-server": "2.1.0", + "body-parser": "2.4.0", "fast-uri": "^3.1.7", "hono": "4.13.8", "ip-address": "^10.4.0", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 57c7ccf0e6b..e8abc6aab58 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -315,7 +315,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairLegacyDottedToolCallNames(outBody); // Shared HTTP/WS outbound projection, including concrete combo/fallback destinations. if (isOpenAiOperatedResponsesDestination(provider)) { - outBody = stripProviderInputItemMetadata(outBody); + outBody = repairUrlCitationAnnotations(stripProviderInputItemMetadata(outBody)); } if (!isCanonicalOpenAiForwardProvider(provider)) { if (provider.preserveResponsesMessageMetadata !== true) { diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 6fb0f591854..2b5ab93d25c 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -24,7 +24,7 @@ export function stripProviderInputItemMetadata(body: unknown): unknown { input ??= [...body.input]; input[index] = next; } - return repairUrlCitationAnnotations(input ? { ...body, input } : body); + return input ? { ...body, input } : body; } function isUrlCitationIndex(value: unknown): value is number { diff --git a/src/codex/desktop-compatibility/windows-certificate-trust.ts b/src/codex/desktop-compatibility/windows-certificate-trust.ts index d26e2f828c3..1ad9e3be883 100644 --- a/src/codex/desktop-compatibility/windows-certificate-trust.ts +++ b/src/codex/desktop-compatibility/windows-certificate-trust.ts @@ -9,6 +9,26 @@ export type DesktopCertificateTrust = "trusted" | "not-trusted" | "unknown"; export type TrustOperation = "inspect" | "trust" | "remove"; export type CertificateTrustRunner = (operation: TrustOperation, certificateDer: string, fingerprint: string) => Promise; +/** Public status is an interactive read; a store that cannot answer within it reports `unknown`. */ +export const TRUST_INSPECT_DEADLINE_MS = 10_000; +/** A mutation may wait on the user's certificate prompt. */ +export const TRUST_CHANGE_DEADLINE_MS = 120_000; + +/** The slice of `child_process.execFile` the runner uses, so a deadline breach can be driven without a real store. */ +export type TrustCommandExec = ( + file: string, + args: string[], + options: { timeout: number; maxBuffer: number; windowsHide: boolean; encoding: "utf8" }, + callback: (error: Error | null, stdout: string) => void, +) => { stdin?: { on(event: "error", listener: () => void): unknown; end(chunk: string): unknown } | null }; + +export type PowerShellTrustRunnerOptions = { + inspectDeadlineMs?: number; + platform?: NodeJS.Platform; + exec?: TrustCommandExec; + resolvePowerShell?: () => string; +}; + const SCRIPT = [ "$ErrorActionPreference='Stop'", "$inputData=[Console]::In.ReadToEnd()|ConvertFrom-Json", @@ -31,18 +51,32 @@ const SCRIPT = [ "}finally{$store.Close();$store.Dispose();$cert.Dispose()}", ].join("\n"); -const run: CertificateTrustRunner = (operation, certificateDer, fingerprint) => { - if (process.platform !== "win32") return Promise.resolve("unknown"); - if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown"); - return new Promise(resolve => { - const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { - timeout: operation === "inspect" ? 10_000 : 120_000, maxBuffer: 4096, windowsHide: true, encoding: "utf8", - }, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted") - ? stdout.trim() as DesktopCertificateTrust : "unknown")); - child.stdin?.on("error", () => { /* completion and readback decide the result */ }); - child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint })); - }); -}; +/** + * The real CurrentUser Root runner. Any failure, including a deadline breach, is `unknown`, which + * never authorizes a write. The options exist so a test can replay this exact call with a cold-start + * deadline and can drive a breach deterministically; production uses the defaults. + */ +export function createPowerShellCertificateTrustRunner({ + inspectDeadlineMs = TRUST_INSPECT_DEADLINE_MS, + platform = process.platform, + exec = execFile as unknown as TrustCommandExec, + resolvePowerShell = resolveTrustedWindowsPowerShellExe, +}: PowerShellTrustRunnerOptions = {}): CertificateTrustRunner { + return (operation, certificateDer, fingerprint) => { + if (platform !== "win32") return Promise.resolve("unknown"); + if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown"); + return new Promise(resolve => { + const child = exec(resolvePowerShell(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { + timeout: operation === "inspect" ? inspectDeadlineMs : TRUST_CHANGE_DEADLINE_MS, maxBuffer: 4096, windowsHide: true, encoding: "utf8", + }, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted") + ? stdout.trim() as DesktopCertificateTrust : "unknown")); + child.stdin?.on("error", () => { /* completion and readback decide the result */ }); + child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint })); + }); + }; +} + +const run = createPowerShellCertificateTrustRunner(); /** Public status cannot create a certificate, decrypt a key or alter OS trust. */ export async function inspectWindowsCertificateTrust(certPem: string, expectedFingerprint: string, runner: CertificateTrustRunner = run): Promise { diff --git a/src/codex/native-main-owner.ts b/src/codex/native-main-owner.ts index df2d7b7698b..a15b6b7fdc4 100644 --- a/src/codex/native-main-owner.ts +++ b/src/codex/native-main-owner.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; @@ -299,6 +299,32 @@ export function nativeMainOwnerSnapshot(context: NativeProfileContext): NativeMa return entry ? { ...entry.snapshot } : null; } +/** + * Homes at or below `root` whose owner entry may still hold or reopen handles there. + * + * Conservative on purpose: besides a published database or side descriptor, an + * acquisition in flight (`drive`) holds its descriptor and database in locals until + * hardening returns, a pending retry timer will reopen them, and a detached entry is + * still closing. Removal code asks this before deleting a home. Windows refuses to + * delete a file SQLite holds open, so a removal that starts early fails with EBUSY + * only after its whole retry budget, without naming the owner. + */ +export function nativeMainOwnerHomesOpenAtOrBelow(root: string): string[] { + const fold = (path: string) => (process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path)); + const base = fold(root); + const open: string[] = []; + const candidates = [ + ...[...entries.values()].filter(entry => entry.database || entry.file || entry.drive || entry.timer), + ...detachedEntries, + ]; + for (const entry of candidates) { + const suffix = relative(base, fold(entry.context.codexHome)); + if (suffix === ".." || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) continue; + open.push(entry.context.codexHome); + } + return open; +} + export function assertNativeMainOwner(context: NativeProfileContext): void { const entry = entries.get(context.homeId); if (entry?.snapshot.status === "held" && entry.database && !entry.closing) return; diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 28cb3fbc718..f9d687e1e09 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -25,7 +25,7 @@ import { parseRequest } from "../../responses/parser"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { applyReasoningRetention, appendRetentionNotice, type ReasoningRetentionResult } from "../../responses/reasoning-retention"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; -import { stripProviderInputItemMetadata } from "../../adapters/openai-responses/request-strips"; +import { repairUrlCitationAnnotations, stripProviderInputItemMetadata } from "../../adapters/openai-responses/request-strips"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair"; import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router"; @@ -949,7 +949,7 @@ export async function handleResponsesCompact( // #5095: the compact endpoint forwards `raw` directly, so it needs the same legacy dotted // call-name repair the adapter applies. A damaged item here refuses the compaction itself, // which is the request a long task depends on to keep going. - // This branch is OpenAI-only and bypasses the adapter's Google replay projection (#7111). + // Direct compact bypasses the adapter's Google replay projection (#7111). const compactBody = repairLegacyDottedToolCallNames( stripProviderInputItemMetadata(sanitizeReasoningInputContent(compactBodyRaw, { preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(compactProvider) @@ -1160,8 +1160,9 @@ export async function handleResponsesCompact( if (!isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.preserveResponsesMessageMetadata !== true) { replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody; } + // A queued key selection can refresh the destination; project only for this send. return isOpenAiOperatedResponsesDestination(sendProvider) - ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); + ? repairUrlCitationAnnotations(replayBody) : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); }; const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, diff --git a/structure/INDEX.md b/structure/INDEX.md index e4982e65274..002be9cd5cd 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -51,6 +51,7 @@ The wire surfaces a client actually talks to. | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | One-shot exact signature-binding recovery before output, and successful-retry memo boundaries. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | Optional native reasoning ciphertext forwarding on model/account switches, configuration and management controls. | +| [`transports/responses-citations.md`](transports/responses-citations.md) | Destination-gated citation repair for ordinary Responses and every native compact send. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | | [`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md) | Combo preflight output commitment, turn lease lifetime, and bounded hop warnings. | @@ -178,7 +179,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/openai-chat.ts` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-chat/` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-responses/opengateway-input.ts` | [`providers/opengateway.md`](providers/opengateway.md) | -| `src/adapters/openai-responses/passthrough.ts` | [`providers/opengateway.md`](providers/opengateway.md) | +| `src/adapters/openai-responses/passthrough.ts` | [`transports/responses-citations.md`](transports/responses-citations.md)
[`providers/opengateway.md`](providers/opengateway.md) | +| `src/adapters/openai-responses/request-strips.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) | | `src/adapters/workbuddy-schema.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/adapters/workbuddy.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/advisor/` | [`advisor.md`](advisor.md) | @@ -295,6 +297,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/anthropic-thinking-recovery.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/server/responses/compact.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) | | `src/server/responses/opengateway-completion-guard.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-completion.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-dispatch.ts` | [`providers/opengateway.md`](providers/opengateway.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 3f6079e5566..212ed558ada 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -302,6 +302,17 @@ "gui/" ] }, + { + "path": "transports/responses-citations.md", + "tier": 3, + "title": "Replayed URL Citations", + "scope": "Destination-gated citation repair for ordinary Responses and every native compact send.", + "documents": [ + "src/adapters/openai-responses/request-strips.ts", + "src/adapters/openai-responses/passthrough.ts", + "src/server/responses/compact.ts" + ] + }, { "path": "transports/responses-wire-shapes.md", "tier": 3, diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 0f52b0837c4..9c68d01cf4e 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -77,11 +77,16 @@ fixture-specific cleanup or claim OS ACL coverage for synthetic tests. case roots (`tests/codex-integration/codex-quota-auto-refresh.test.ts`), and Anthropic instance root removal (`tests/helpers/anthropic-instance-fixture.ts`). It settles producers, then each root's config flights, closes the request-history SQLite index, -drains each root's ACL work, and only then removes drained roots. A producer or history failure -blocks every removal; a root-specific drain failure preserves that root while other drained roots -can be removed. Removal failures do not stop later roots. Environment restoration runs in finally, -and the first failure is rethrown. Undrained roots remain for run-level sandbox cleanup. -`tests/ci-workflows/fixture-teardown-helper.test.ts` verifies ordering and failure gating with injected owners. +drains each root's ACL work, then asks `nativeMainOwnerHomesOpenAtOrBelow` whether a native-main +owner below the root is still open, acquiring (descriptor and SQLite handle held while its hardener +runs), waiting on a retry, or closing; only roots that pass both are removed. An open owner fails +that root at once with an error naming the owner home, without spending the removal retry budget. +A producer or history failure blocks every removal; a root-specific drain or owner failure +preserves that root while other ready roots can be removed. Removal failures do not stop later +roots. Environment restoration runs in finally, and the first failure is rethrown. Undrained roots +remain for run-level sandbox cleanup. +`tests/ci-workflows/fixture-teardown-helper.test.ts` verifies ordering and failure gating with injected +owners, and with a real owner whose hardener is paused mid-acquisition. `tests/helpers/spawn-test-child.ts` owns behavior and warm-up children on the parent's live event loop. It captures output continuously, kills at the child deadline, and bounds exit/pipe diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a81d0790042..437a2ebf493 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -445,7 +445,7 @@ picker, so the catalog and the wire agree; a rung the upstream then refuses is r error rather than replayed without the effort, because the operator asked for it. The flag is part of the compatibility behavior record for the same reason as above. -The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. Ordinary requests and native compact apply the [destination-specific citation projection](transports/responses-citations.md) independently of provider metadata stripping. ## Hosted-search continuation binding > Decision record: [ADR-6930](decisions/ADR-6930-hosted-search-continuation-binding.md) > Decision record: [ADR-6931](decisions/ADR-6931-lite-hosted-search-opt-in.md) diff --git a/structure/transports/responses-citations.md b/structure/transports/responses-citations.md new file mode 100644 index 00000000000..1e7f465a312 --- /dev/null +++ b/structure/transports/responses-citations.md @@ -0,0 +1,19 @@ +# Replayed URL Citations + +OpenAI-operated Responses destinations repair replayed `url_citation` annotations to the required +string title and non-negative integer indices through `repairUrlCitationAnnotations` in +`src/adapters/openai-responses/request-strips.ts`. Provider metadata stripping remains an +independent projection. The ordinary HTTP/WS adapter in +`src/adapters/openai-responses/passthrough.ts` applies citation repair only after destination +classification. + +The direct native `/responses/compact` path in `src/server/responses/compact.ts` applies the same +gate in its final body builder for every physical send, including a provider destination refreshed +after key admission. It keeps the shared history free of citation repair so an OpenAI-to-gateway +refresh preserves the gateway's original annotations and the reverse refresh still repairs OpenAI +history. Opting a third-party gateway into native compaction declares protocol support, not +permission to rewrite its citations. `tests/responses/compact-key-selection.test.ts` covers exact +gateway preservation and both refresh directions; +`tests/responses/responses-native-item-extensions-compact.test.ts` covers official API and ChatGPT +compact repair, while `tests/responses/responses-url-citation-title-repair.test.ts` covers ordinary +HTTP/WS projection, helper independence and input immutability. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 02f6eb0a8b6..d2a19b5bcb1 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. Every custom native v1 send carries only the gateway transport's headers, and Codex pool recovery (401 refresh replay, quota and transient alternates) and pool outcome recording skip gateway sends, so recovery cannot put a stored ChatGPT credential on a gateway wire (`tests/responses/native-compaction-gateway-credentials.test.ts`). Readable reasoning retention also bypasses native v2-capable gateways; the capability predicate controls local retention as well as dispatch. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. Key-auth compact revalidates the serving key after pacing, rebuilding custom gateway paths, transport headers and replay sanitization against that physical selection; `tests/responses/compact-key-selection.test.ts` covers initial gateway dispatch and queued reselection. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. Every custom native v1 send carries only the gateway transport's headers, and Codex pool recovery (401 refresh replay, quota and transient alternates) and pool outcome recording skip gateway sends, so recovery cannot put a stored ChatGPT credential on a gateway wire (`tests/responses/native-compaction-gateway-credentials.test.ts`). Readable reasoning retention also bypasses native v2-capable gateways; the capability predicate controls local retention as well as dispatch. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. Key-auth compact revalidates the serving key after pacing, rebuilding custom gateway paths, transport headers, replay sanitization and [citation projection](responses-citations.md) against that physical selection; `tests/responses/compact-key-selection.test.ts` covers initial gateway dispatch and queued reselection. Canonical native v1's 404 fallback also bypasses local readable retention: its native v2 turn receives the original reasoning and creates no archive (`tests/responses/reasoning-retention-routing.test.ts`). A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:`/`ocx2:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no known serving-identity mismatch and the destination is known to decode native blobs — the canonical ChatGPT forward surface, the official OpenAI API, or a provider with the explicit diff --git a/tests/ci-workflows/fixture-teardown-helper.test.ts b/tests/ci-workflows/fixture-teardown-helper.test.ts index bdd936dca17..11bc42443ba 100644 --- a/tests/ci-workflows/fixture-teardown-helper.test.ts +++ b/tests/ci-workflows/fixture-teardown-helper.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { retainNativeMainOwner } from "../../src/codex/native-main-owner"; import { flushConfigDirHardening } from "../../src/config/paths"; import { defaultFixtureTeardownDeps, @@ -6,6 +10,7 @@ import { type FixtureTeardownDeps, type FixtureTeardownPlan, } from "../helpers/fixture-teardown"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function harness(overrides: Partial = {}) { const calls: string[] = []; @@ -14,6 +19,7 @@ function harness(overrides: Partial = {}) { settleConfigFlights: async root => { calls.push(`flight:${root}`); }, closeHistory: () => { calls.push("history"); }, drainAcl: async root => { calls.push(`acl:${root}`); }, + openLocks: () => [], remove: root => { calls.push(`remove:${root}`); }, ...overrides, }; @@ -127,4 +133,71 @@ describe("fixture removal ownership", () => { h.plan.restoreEnvironment = () => { throw last; }; await expect(drainAndRemoveFixtureRoots(h.plan, h.deps)).rejects.toBe(first); }); + + test("an owner still open under a root keeps only that root and names it", async () => { + const h = harness({ openLocks: root => (root === "A" ? ["A/codex"] : []) }); + await expect(drainAndRemoveFixtureRoots(h.plan, h.deps)).rejects.toThrow("native-main owner still open for A/codex"); + expect(h.calls).not.toContain("remove:A"); + expect(h.calls).toContain("remove:B"); + expect(h.calls.filter(call => call === "restore")).toHaveLength(1); + }); + + test("the default lock probe sees a held owner below the root until its release closes", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-teardown-lock-")); + const codexHome = join(root, "codex"); + const sibling = `${root}-sibling`; + mkdirSync(codexHome); + const owner = retainNativeMainOwner( + { codexHome, homeId: `teardown-lock-${process.pid}` } as never, + { hardenPath: async () => {} }, + ); + try { + await new Promise(held => owner.subscribe(snapshot => { if (snapshot.status === "held") held(); })); + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([codexHome]); + expect(defaultFixtureTeardownDeps.openLocks(codexHome)).toEqual([codexHome]); + expect(defaultFixtureTeardownDeps.openLocks(sibling)).toEqual([]); + await owner.release(); + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([]); + } finally { + await owner.release(); + removeTreeWithRetry(root); + } + }); + + test("an owner paused mid-acquisition keeps its root until preparation and release finish", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-teardown-acquire-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + let entered!: () => void, resume!: () => void; + const hardening = new Promise(done => { entered = done; }); + const resumed = new Promise(done => { resume = done; }); + const owner = retainNativeMainOwner( + { codexHome, homeId: `teardown-acquire-${process.pid}` } as never, + { hardenPath: async () => { entered(); await resumed; } }, + ); + const removed: string[] = []; + const plan = (): FixtureTeardownPlan => ({ roots: [{ path: root, remove: path => { removed.push(path); removeTreeWithRetry(path); } }], restoreEnvironment: () => {} }); + try { + // The descriptor and SQLite handle exist only in locals while the hardener runs. + await hardening; + expect(owner.snapshot().status).toBe("acquiring"); + await expect(drainAndRemoveFixtureRoots(plan())).rejects.toThrow(`native-main owner still open for ${codexHome}`); + expect(removed).toEqual([]); + + const released = owner.release(); + // Still closing: the release waits for the paused acquisition. + await expect(drainAndRemoveFixtureRoots(plan())).rejects.toThrow("native-main owner still open"); + expect(removed).toEqual([]); + + resume(); + await released; + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([]); + await drainAndRemoveFixtureRoots(plan()); + expect(removed).toEqual([root]); + } finally { + resume(); + await owner.release(); + if (removed.length === 0) removeTreeWithRetry(root); + } + }); }); diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..6d21c8726e6 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -98,7 +98,7 @@ describe("install scripts", () => { // proves the unified version; then delete testRunnerBun and the setup action's role. expect(pkg.dependencies?.bun).toBe("1.4.4"); expect(pkg.testRunnerBun).toBe("1.4.3"); - expect(pkg.dependencies?.zod).toBe("4.4.3"); + expect(pkg.dependencies?.zod).toBe("4.6.6"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); expect(pkg.scripts?.dev).toBe("bun run src/cli/index.ts start"); diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index aa596031ef9..af4ac15d344 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; @@ -10,6 +10,7 @@ import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-p import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; import { killProxy } from "../../src/lib/process-control"; import { repoPath } from "../helpers/repo-root"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { trustedFixtureRoot } from "../helpers/trusted-fixture-root"; @@ -762,14 +763,29 @@ describe("real PATH Bun validation", () => { }); }); -function warmCopiedBun(path: string): void { - if (process.platform !== "win32") return; - const warmed = spawnSync(path, ["--version"], { - encoding: "utf8", - timeout: WINDOWS_COLD_SPAWN_MS, - windowsHide: true, +/** + * The one real Bun copy every PATH-fallback case puts on PATH, created and run once in setup. + * + * The first exec of a freshly copied bun binary is held by the OS's first-run scan: about 1s on an + * idle macOS host (later execs of the same path take ~3ms) and well over that for a cold win32 + * bun.exe. A copy per case put that scan inside each case's 5s budget and inside the launcher's own + * 5s PATH probe, so the verdict tracked host load (#7578). The scan is keyed by path, so a hardlink + * does not inherit it; sharing the warmed path is what keeps it out of the measured window. + * The launcher only reads and executes this file, so sharing it changes nothing a case asserts. + */ +let sharedPathBun: { root: string; dir: string; bun: string } | undefined; + +async function createWarmPathBun(): Promise { + const root = realpathSync.native(pathFixtureRoot("ocx-path-launcher-bun-")); + const dir = join(root, "path"); mkdirSync(dir); + const bun = join(dir, process.platform === "win32" ? "bun.exe" : "bun"); + copyFileSync(process.execPath, bun); + chmodSync(bun, 0o755); + sharedPathBun = { root, dir, bun }; + await warmColdSpawn("bun/launcher-path-fallback-copy", deadlineMs => { + const warmed = spawnSync(bun, ["--version"], { encoding: "utf8", timeout: deadlineMs, windowsHide: true }); + if (warmed.status !== 0) throw new Error(`[cold-spawn-warmup] PATH Bun copy did not run: status=${String(warmed.status)} ${warmed.stderr}`); }); - expect(warmed.status, warmed.stderr).toBe(0); } function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { @@ -783,17 +799,14 @@ function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broke copyFileSync(BIN_OCX, join(root, "bin", "ocx.mjs")); writeFileSync(join(root, "package.json"), JSON.stringify({ version: "2.81.0", dependencies: { bun: "1.4.2" } })); writeFileSync(join(root, "src", "cli", "index.ts"), 'console.log(JSON.stringify({source:process.env.OCX_BUN_RUNTIME_SOURCE,path:process.env.OCX_BUN_RUNTIME_PATH}));'); - const pathDir = join(root, "path"); mkdirSync(pathDir); + const shared = mode !== "none" && mode !== "rejected"; + if (shared && !sharedPathBun) throw new Error("the shared PATH Bun is created by the activation suite's beforeAll"); + const pathDir = shared ? sharedPathBun!.dir : join(root, "path"); + if (!shared) mkdirSync(pathDir); const pathBun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); if (mode === "rejected") { writeFileSync(pathBun, '#!/bin/sh\nprintf "2.0.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(pathBun, 0o755); - } else if (mode !== "none") { - copyFileSync(process.execPath, pathBun); - chmodSync(pathBun, 0o755); - // The product PATH probe is 5s for both execs together. Warm the copy first so - // that budget measures a scanned binary; a cold win32 bun.exe is the flake. - warmCopiedBun(pathBun); } const bundleDir = join(root, "node_modules", "bun"); if (["bundled", "repair", "broken", "inspection"].includes(mode)) { @@ -820,6 +833,8 @@ function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broke } describe.skipIf(!nodeAvailable)("Node launcher PATH fallback activation", () => { + beforeAll(async () => { await createWarmPathBun(); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + afterAll(() => { if (sharedPathBun) removeTree(sharedPathBun.root); sharedPathBun = undefined; }); test.each(["missing", "broken", "inspection"] as const)("%s bundle reaches validated PATH Bun", mode => { const result = launcherFallbackFixture(mode); expect(result.status).toBe(0); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index c99a04342ed..f3472bc4b71 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -60,9 +60,23 @@ const CLIENT_LIFECYCLE_FIXTURE_IMPORT_PROLOGUE = const CLIENT_FIXTURE_FAILURE_CATEGORIES = ["module_load", "config_setup", "desktop_setup", "scenario", "child_failed"] as const; type ClientFixtureFailureCategory = typeof CLIENT_FIXTURE_FAILURE_CATEGORIES[number]; -const TRANSACTION_PHASES = ["module_load", "module_ready", "connect_entered", "connect_resolved", "connect_rejected", "state_read", "result_published"] as const; +const TRANSACTION_PHASES = [ + "module_load", "module_ready", "connect_entered", "connect_resolved", "connect_rejected", + "operation_entered", "operation_settled", "state_read", "result_published", +] as const; type TransactionProgress = { phase: typeof TRANSACTION_PHASES[number]; elapsedMs: number }; +/** + * Child-side half of transactionProgress: each --eval child writes its milestones to stderr + * synchronously, so a deadline SIGKILL cannot drop the last one it reached. + */ +const CHILD_MILESTONE_PROLOGUE = ` + const transactionStarted = Date.now(); + const markTransaction = phase => require("node:fs").writeSync(2, JSON.stringify({ + clientTransactionPhase: phase, elapsedMs: Math.max(0, Date.now() - transactionStarted), + }) + "\\n"); +`; + /** Only this fixed diagnostic envelope may reach a parent-side failure message. */ function transactionProgress(stderr: unknown): TransactionProgress | undefined { if (typeof stderr !== "string") return undefined; @@ -82,6 +96,17 @@ function transactionProgress(stderr: unknown): TransactionProgress | undefined { return undefined; } +/** The last lifecycle-fixture phase the child reached, so a deadline kill still names where it stopped. */ +function lastFixturePhase(stderr: unknown): ClientFixtureFailureCategory | undefined { + if (typeof stderr !== "string") return undefined; + for (const line of stderr.slice(-8192).split("\n").reverse()) { + const reached = /^\{"clientFixturePhase":"([a-z_]+)"\}$/.exec(line.trim())?.[1]; + const phase = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === reached); + if (phase) return phase; + } + return undefined; +} + class ClientStateProbeError extends Error { constructor( readonly pid: number, @@ -97,6 +122,32 @@ class ClientStateProbeError extends Error { } } +/** + * Run one repository --eval child under a finite deadline and parse its last stdout line. + * + * spawnSync blocks this worker, so Bun's per-test timeout cannot end a wedged child: a call + * without `timeout` turns one stalled child into a file that never finishes (#7404). + * Every synchronous child in this file goes through here; the only child text that may reach a + * failure message is the allowlisted milestone read by transactionProgress. + */ +function runEvalChild(script: string, env: NodeJS.ProcessEnv, timeoutMs = INTERNAL_DEADLINE_MS) { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env, + encoding: "utf8", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + const progress = transactionProgress(result.stderr); + if (result.error || result.status !== 0 || result.signal !== null) { + throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", undefined, progress); + } + let parsed: Record; + try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } + catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false, undefined, progress); } + return { status: result.status, stderr: result.stderr, parsed }; +} + async function readStateProbe(script: string, home: string, timeoutMs = INTERNAL_DEADLINE_MS) { const maxCaptureBytes = 1024 * 1024; const cleanupMs = 1_000; @@ -421,10 +472,7 @@ function runTransactionScenario( } const script = options.script ?? ` const { existsSync, readFileSync, writeSync } = require("node:fs"); - const transactionStarted = Date.now(); - const markTransaction = phase => writeSync(2, JSON.stringify({ - clientTransactionPhase: phase, elapsedMs: Math.max(0, Date.now() - transactionStarted), - }) + "\\n"); + ${CHILD_MILESTONE_PROLOGUE} markTransaction("module_load"); const { createHash } = require("node:crypto"); ${CLIENT_TRANSACTION_EVAL_IMPORT_PROLOGUE} @@ -522,21 +570,10 @@ function runTransactionScenario( if (failures.length) throw new AggregateError(failures, "Could not clean client transaction homes"); }; try { - const result = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - timeout: options.timeoutMs ?? INTERNAL_DEADLINE_MS, - killSignal: "SIGKILL", - }); - const progress = transactionProgress(result.stderr); - if (result.error || result.status !== 0 || result.signal !== null) { - throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", undefined, progress); - } - let parsed: Record; - try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } - catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false, undefined, progress); } - return { status: result.status, stderr: result.stderr, parsed, configBytes: readFileSync(configPath, "utf8"), cleanup }; + const result = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }, options.timeoutMs); + return { ...result, configBytes: readFileSync(configPath, "utf8"), cleanup }; } catch (error) { try { cleanup(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], "Client transaction failed and fixture cleanup failed"); } @@ -577,7 +614,8 @@ describe("connect transaction and offline disconnect", () => { test("transaction fixture stops a child retained after valid output", async () => { const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); const markerPath = join(proofHome, "child-started.json"); - const naturalExitPath = join(proofHome, "natural-exit"); + // The child never exits on its own, so only the deadline kill can end it. A natural-exit + // timer here would race that kill on wall-clock time under load. const script = ` const fs = require("node:fs"); fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ @@ -586,7 +624,7 @@ describe("connect transaction and offline disconnect", () => { fs.writeSync(1, '{"ok":true}\\n'); fs.writeSync(2, '{"clientTransactionPhase":"result_published","elapsedMs":1}\\n'); fs.writeSync(2, '{"clientTransactionPhase":"private-child-secret","elapsedMs":2}\\n'); - setTimeout(() => { fs.writeFileSync(${JSON.stringify(naturalExitPath)}, "exited"); }, 5_000); + setInterval(() => {}, 1_000); `; let run: Awaited> | undefined; try { @@ -600,7 +638,6 @@ describe("connect transaction and offline disconnect", () => { expect(failure.timedOut).toBe(true); expect(failure.progress).toEqual({ phase: "result_published", elapsedMs: 1 }); expect(failure.message).not.toContain("private-child-secret"); - expect(existsSync(naturalExitPath)).toBe(false); const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; expect(proof.pid).toBe(failure.pid); expect(existsSync(proof.home)).toBe(false); @@ -806,19 +843,25 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const script = ` const fs = require("node:fs"); const path = require("node:path"); + ${CHILD_MILESTONE_PROLOGUE} + markTransaction("module_load"); const { disconnectClient, syncConnectedClient } = require("./src/client/connect"); const { readClientConnectionState } = require("./src/client/state"); const mode = ${JSON.stringify(mode)}; + markTransaction("module_ready"); (async () => { let result = null; let error = null; try { + markTransaction("operation_entered"); if (mode === "disconnect-conflict" || mode === "disconnect-process-journal") result = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); else result = await syncConnectedClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }, fetchImpl: async () => Response.json({ error: "fixture" }, { status: mode === "sync-401" ? 401 : 503 }), }); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + markTransaction("operation_settled"); + markTransaction("state_read"); console.log(JSON.stringify({ result, error, @@ -826,22 +869,27 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c tokenExists: fs.existsSync(path.join(process.env.OPENCODEX_HOME, "service-api-token")), journalExists: fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json")), })); + markTransaction("result_published"); })(); `; - const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); - const parsed = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; - return { - status: child.status, - parsed, - cleanup: () => { - removeTreeWithRetry(opencodexHome); - removeTreeWithRetry(codexHome); - }, + const cleanup = () => { + const failures: unknown[] = []; + for (const home of [opencodexHome, codexHome]) { + try { removeTreeWithRetry(home); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Could not clean connected client homes"); }; + try { + const { status, parsed } = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }); + return { status, parsed, cleanup }; + } catch (error) { + try { cleanup(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], "Connected client scenario failed and fixture cleanup failed"); } + throw error; + } } describe("connected sync and disconnect conflicts", () => { @@ -923,8 +971,11 @@ describe("recoverable connected key rotation", () => { const script = ` const fs = require("node:fs"); const path = require("node:path"); + ${CHILD_MILESTONE_PROLOGUE} + markTransaction("module_load"); const { rotateConnectedClientKey } = require("./src/client/connect"); const { readClientConnectionState } = require("./src/client/state"); + markTransaction("module_ready"); let commitCalls = 0; let committed = false; const oldKey = ${JSON.stringify(oldKey)}; @@ -953,7 +1004,10 @@ describe("recoverable connected key rotation", () => { }; (async () => { const credential = new TextEncoder().encode("ocx_admin_rotation_test"); + markTransaction("operation_entered"); const result = await rotateConnectedClientKey({ credential: { kind: "admin", value: credential } }, { fetchImpl, lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); + markTransaction("operation_settled"); + markTransaction("state_read"); console.log(JSON.stringify({ result, state: readClientConnectionState(), @@ -962,16 +1016,13 @@ describe("recoverable connected key rotation", () => { commitCalls, credentialZeroed: credential.every(value => value === 0), })); + markTransaction("result_published"); })(); `; - const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); try { - expect(child.status).toBe(0); - const result = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; + const { parsed: result } = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }); expect(result.commitCalls).toBe(2); expect(result.tokenIsNew).toBe(true); expect(result.backup).toBe(false); @@ -1055,7 +1106,7 @@ function runDesktopLifecycleScenario(mode: string) { catalogFingerprint: crypto.createHash("sha256").update(catalog).digest("base64url"), priorCatalog: Buffer.from(prior).toString("base64"), } }; - fixtureFailurePhase = "config_setup"; + markFixturePhase("config_setup"); configApi.saveConfig(config); if (configApi.readConfigDiagnostics().source !== "file" || stateApi.readClientConnectionState().kind !== "connected") { throw new Error("fixture_config_invalid"); @@ -1067,7 +1118,7 @@ function runDesktopLifecycleScenario(mode: string) { const profilePath = path.join(desktop, "fixture.json"); const baselinePath = path.join(home, "desktop-remote", "baseline.json"); const unused = mode.startsWith("status-") || mode === "disconnect-expected-owner"; - fixtureFailurePhase = "desktop_setup"; + markFixturePhase("desktop_setup"); if (!unused) { fs.writeFileSync(path.join(desktop, "_meta.json"), JSON.stringify({ appliedId: "fixture", entries: [{ id: "fixture", name: "opencodex" }], foreignMeta: "preserve" })); fs.writeFileSync(profilePath, JSON.stringify({ @@ -1135,7 +1186,7 @@ function runDesktopLifecycleScenario(mode: string) { } throw new Error("unexpected fixture request"); }; - fixtureFailurePhase = "scenario"; + markFixturePhase("scenario"); await (async () => { let result = null, error = null, second = null, statusInside = null, statusOutside = null, cliRotation = null; const credential = new TextEncoder().encode("ocx_admin_fixture"); @@ -1254,7 +1305,13 @@ function runDesktopLifecycleScenario(mode: string) { const fixturePath = join(root, "client-lifecycle-fixture.test.ts"); writeFileSync(fixturePath, `import { test } from "bun:test"; test("isolated client lifecycle scenario", async () => { - let fixtureFailurePhase = "module_load"; + // Written synchronously to stderr so a deadline SIGKILL still leaves the phase it stopped in. + let fixtureFailurePhase; + const markFixturePhase = phase => { + fixtureFailurePhase = phase; + require("node:fs").writeSync(2, JSON.stringify({ clientFixturePhase: phase }) + "\\n"); + }; + markFixturePhase("module_load"); try { ${resolvedScript} } catch { @@ -1276,7 +1333,8 @@ function runDesktopLifecycleScenario(mode: string) { let envelope: { fixtureFailure?: unknown; fixtureResult?: unknown } | undefined; try { if (marker) envelope = JSON.parse(marker); } catch { /* fixed category below */ } if (child.error || child.status !== 0 || child.signal || !envelope?.fixtureResult) { - const category = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === envelope?.fixtureFailure) ?? "child_failed"; + const category = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === envelope?.fixtureFailure) + ?? lastFixturePhase(child.stderr) ?? "child_failed"; throw new ClientStateProbeError(child.pid, child.status, child.signal, (child.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", category); } return envelope.fixtureResult as Record; diff --git a/tests/clients/desktop-compatibility-trust.test.ts b/tests/clients/desktop-compatibility-trust.test.ts index db580c72246..38232ead423 100644 --- a/tests/clients/desktop-compatibility-trust.test.ts +++ b/tests/clients/desktop-compatibility-trust.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { randomUUID, X509Certificate } from "node:crypto"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import type { StoredDesktopAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; -import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust, type TrustOperation } from "../../src/codex/desktop-compatibility/windows-certificate-trust"; +import { + createPowerShellCertificateTrustRunner, createWindowsCertificateTrust, inspectWindowsCertificateTrust, TRUST_INSPECT_DEADLINE_MS, + type DesktopCertificateTrust, type TrustCommandExec, type TrustOperation, +} from "../../src/codex/desktop-compatibility/windows-certificate-trust"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; function fixture(serverAuthOnly = true): StoredDesktopAuthority { const commonName = `OpenCodex Codex Desktop ${randomUUID()}`; @@ -73,7 +77,41 @@ describe("exact Windows compatibility certificate trust", () => { release("trusted"); expect(await first).toBe("trusted"); }); - test.skipIf(process.platform !== "win32")("real CurrentUser Root inspection remains read-only for an unregistered synthetic CA", async () => { + test("a store read that outlives the status deadline is unknown and requests nothing but an inspection", async () => { + // The runner's own deadline, driven without a real store: execFile reports a killed child the way + // node does when `timeout` elapses, with whatever the child printed before the kill. This is the + // half of the real-store case that depends on timing, so it is proven here on every platform. + const value = fixture(), calls: { timeout: number; input: string }[] = []; + const exec: TrustCommandExec = (_file, _args, options, callback) => { + let input = ""; + queueMicrotask(() => { + calls.push({ timeout: options.timeout, input }); + callback(Object.assign(new Error("Command failed: powershell.exe"), { killed: true, signal: "SIGTERM", code: null }), "not-trusted"); + }); + return { stdin: { on: () => undefined, end: (chunk: string) => { input = chunk; } } }; + }; + const runner = createPowerShellCertificateTrustRunner({ platform: "win32", exec, resolvePowerShell: () => "powershell.exe" }); + expect(await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint, runner)).toBe("unknown"); + expect(calls).toHaveLength(1); + expect(calls[0].timeout).toBe(TRUST_INSPECT_DEADLINE_MS); + expect(JSON.parse(calls[0].input).operation).toBe("inspect"); + }); +}); + +describe.skipIf(process.platform !== "win32")("exact Windows compatibility certificate trust on the real store", () => { + // The first PowerShell in a shard pays process start, .NET load and the X509 store open; on a + // loaded runner that crossed the 10s status deadline and read as `unknown` (#7569). Replaying the + // same inspection once here leaves the unchanged product deadline below measuring a warm read. + beforeAll(async () => { + await warmColdSpawn("powershell/desktop-certificate-trust-inspect", async deadlineMs => { + const value = fixture(); + const runner = createPowerShellCertificateTrustRunner({ inspectDeadlineMs: deadlineMs }); + const state = await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint, runner); + if (state === "unknown") throw new Error(`[cold-spawn-warmup] real CurrentUser Root inspection gave no answer within ${deadlineMs}ms`); + }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + + test("real CurrentUser Root inspection remains read-only for an unregistered synthetic CA", async () => { const value = fixture(); expect(await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint)).toBe("not-trusted"); }, 15_000); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index cdd00a433db..b26014ff727 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -69,7 +69,7 @@ import { resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; -import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { blockNativeMainStartupForUnownedServiceHome, completeNativeMainRecovery, @@ -86,17 +86,19 @@ import { recordContextSessionOwner, clearContextSessionOwnersForTests } from ".. import { handleContextHistory } from "../../src/server/context-history"; import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; import { hasForwardableCodexBearer } from "../../src/server/auth-cors"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; let testDir: string; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; beforeEach(() => { - // This suite validates refresh admission and auth-context outcomes. Real icacls - // processes are covered elsewhere and can retain temp-dir handles long enough - // to obscure those assertions under Windows isolated-test load. + // This suite validates refresh admission and auth-context outcomes; real icacls is + // covered by tests/windows. Both runners are stubbed: the native-main claim lock is + // hardened through the ASYNC runner, and with only the sync one stubbed, real icacls + // still rewrote that lock's DACL under the home this teardown removes (#7515). setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); testDir = mkdtempSync(join(tmpdir(), "ocx-auth-ctx-")); previousOpencodexHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = testDir; @@ -113,21 +115,28 @@ beforeEach(() => { clearAccountNeedsReauth("pool-b"); }); -afterEach(() => { +afterEach(async () => { resetContextRelayActivationForTests(); - setIcaclsRunnerForTests(null); - removeTreeWithRetry(testDir); - clearThreadAccountMap(); - clearCodexUpstreamHealth(); - clearAccountQuota(); - setMainAccountPlan(null); - __resetGuardianState(); - clearAccountNeedsReauth("pool-a"); - clearAccountNeedsReauth("pool-b"); - if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpencodexHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; + // Settle producers, config flights and ACL work, and refuse a home whose native-main + // owner is still open, before removal; the stubs stay installed until removal ends. + await drainAndRemoveFixtureRoots({ + roots: [{ path: testDir }], + restoreEnvironment: () => { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + setMainAccountPlan(null); + __resetGuardianState(); + clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + }, + }); }); function config(): OcxConfig { diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 5991c05f7cf..f7dad943d19 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -859,10 +859,24 @@ describe("resolveCodexRuntime", () => { const previousHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = configDir; resetCodexRuntimeResolveCacheForTests(); - const deps = { env: { PATH: "" }, discoverAlternatives: false }; + const deps = { + env: { CODEX_HOME: join(configDir, "codex-home"), HOME: configDir, PATH: "" }, + platform: "linux" as const, + discoverAlternatives: false, + }; try { - const cached = resolveCodexRuntime(deps); + const missingCodex = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + const cached = resolveCodexRuntime({ + ...deps, + configDir, + existsSync: () => false, + readdirSync: () => [], + execFileSync: ((_file, _args, options) => { + expect(options.timeout).toBe(8_000); + throw missingCodex; + }) as RuntimeExecFile, + }); expect(cached.runtime.source).toBe("fallback"); setCodexRuntimeResolveCacheForTests({ runtime: { command: "codex", version: null, source: "environment" }, diff --git a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts index 5db587eed26..07a1191cb6c 100644 --- a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts +++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts @@ -18,7 +18,7 @@ import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; const now = Date.UTC(2026, 8, 5); const FALLBACK = { model: "deepseek/deepseek-chat", reasoningEffort: "low" }; @@ -39,7 +39,7 @@ beforeEach(() => { observeMainQuotaIdentity("fallback-account-a"); }); -afterEach(() => { +afterEach(async () => { releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; @@ -47,9 +47,15 @@ afterEach(() => { clearMainAccountInfoCache(); deleteAllScopedHealth("pool-a"); clearComboRecallForTests(); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(home); + // Saving a pool account starts the config-dir ACL harden without awaiting it; the + // shared teardown settles that flight and its icacls child before removing the home. + await drainAndRemoveFixtureRoots({ + roots: [{ path: home }], + restoreEnvironment: () => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }, + }); }); function observe(quota: Omit): void { diff --git a/tests/helpers/fixture-teardown.ts b/tests/helpers/fixture-teardown.ts index e64fde2fb87..86648bff577 100644 --- a/tests/helpers/fixture-teardown.ts +++ b/tests/helpers/fixture-teardown.ts @@ -1,4 +1,5 @@ import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { nativeMainOwnerHomesOpenAtOrBelow } from "../../src/codex/native-main-owner"; import { flushConfigDirHardening } from "../../src/config/paths"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; @@ -11,6 +12,7 @@ export interface FixtureTeardownDeps { settleConfigFlights(root: string): Promise; closeHistory(): void; drainAcl(root: string): Promise; + openLocks(root: string): string[]; remove(path: string): void; } export const defaultFixtureTeardownDeps: Readonly = { @@ -18,9 +20,18 @@ export const defaultFixtureTeardownDeps: Readonly = { settleConfigFlights: flushConfigDirHardening, closeHistory: closeRequestHistoryIndex, drainAcl: flushWindowsSecretAclReapsBeforeRemoval, + openLocks: nativeMainOwnerHomesOpenAtOrBelow, remove: removeTreeWithRetry, }; +/** A lock this process still holds keeps its root: the owner is named now, not after a retry budget. */ +function assertNoOpenLocks(root: string, openLocks: (root: string) => string[]): void { + const open = openLocks(root); + if (open.length === 0) return; + throw new Error(`[test] not removing ${root}: native-main owner still open for ${open.join(", ")}; ` + + "stop the server or release the owner before teardown"); +} + /** Remove only roots whose owners settled; restoration cannot mask the first failure. */ export async function drainAndRemoveFixtureRoots( plan: FixtureTeardownPlan, @@ -39,7 +50,8 @@ export async function drainAndRemoveFixtureRoots( const historyClosed = await attempt(() => deps.closeHistory()); for (const [index, root] of plan.roots.entries()) { const drained = await attempt(() => deps.drainAcl(root.path)); - ready[index] = ready[index] && drained; + const unlocked = drained && await attempt(() => assertNoOpenLocks(root.path, deps.openLocks)); + ready[index] = ready[index] && unlocked; } if (producersSettled && historyClosed) { for (const [index, root] of plan.roots.entries()) { diff --git a/tests/responses/compact-key-selection.test.ts b/tests/responses/compact-key-selection.test.ts index 289e0689872..277647b5c30 100644 --- a/tests/responses/compact-key-selection.test.ts +++ b/tests/responses/compact-key-selection.test.ts @@ -65,10 +65,10 @@ async function queue(config: OcxConfig, id: string) { await Bun.sleep(5); } } -function start(config: OcxConfig, abort: AbortController) { +function start(config: OcxConfig, abort: AbortController, input: unknown[] = [{ role: "user", content: "fixture question" }]) { const logCtx: RequestLogContext = { model: "", provider: "" }; const req = new Request("http://localhost/v1/responses/compact", { method: "POST", headers: { "content-type": "application/json" }, - signal: AbortSignal.any([abort.signal, AbortSignal.timeout(5_000)]), body: JSON.stringify({ model: `${config.defaultProvider}/test`, input: [{ role: "user", content: "fixture question" }], stream: false }) }); + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(5_000)]), body: JSON.stringify({ model: `${config.defaultProvider}/test`, input, stream: false }) }); const pending = handleResponsesCompact(req, config, logCtx).then(async response => ({ response, text: await response.text(), logCtx })); void pending.catch(() => {}); return pending; @@ -308,9 +308,15 @@ for (const reselect of [false, true]) test(`gateway compact preserves custom tra provider.supportsNativeCompactEndpoint = true; provider.headers = { "x-gateway-static": "initial" }; saveConfig(config); + const annotations = [ + { type: "url_citation", url: "https://gateway.example/source", start_index: "3", end_index: -1, gateway: { source: "fixture" } }, + { type: "url_citation", url: "https://gateway.example/untitled", title: null, start_index: 0, end_index: 1 }, + { type: "file_citation", file_id: "file_fixture", index: 0 }, + ]; + const input = [{ role: "user", content: "fixture question" }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "fixture answer", annotations }] }]; const held = reselect ? await waitForProviderRequestSlot(config.defaultProvider, provider) : undefined; const abort = new AbortController(); - const pending = start(config, abort); + const pending = start(config, abort, input); try { if (reselect) { await queue(config, "k1"); @@ -327,7 +333,45 @@ for (const reselect of [false, true]) test(`gateway compact preserves custom tra expect(seen[0]!.url).toBe(`https://gateway.example/v1/${reselect ? "refreshed-responses" : "custom-responses"}/compact`); expect(seen[0]!.headers.get("x-gateway-static")).toBe(reselect ? "replacement" : "initial"); expect(seen[0]!.headers.get("chatgpt-account-id")).toBeNull(); - expect(seen[0]!.body).toMatchObject({ model: "test", input: [{ role: "user", content: "fixture question" }] }); + expect(seen[0]!.body).toMatchObject({ model: "test" }); + expect((seen[0]!.body as { input: unknown }).input).toEqual(input); noLeaks(config); } finally { abort.abort(); held?.release(); await pending.catch(() => undefined); } }); + +for (const toOpenAi of [false, true]) test(`compact citation projection follows the destination refreshed after admission (OpenAI=${toOpenAi})`, async () => { + const config = fixture(); + const provider = config.providers[NAME]!; + config.providers = { "gateway-key-fixture": provider }; + config.defaultProvider = "gateway-key-fixture"; + const officialBase = "https://api.openai.com/v1"; + const gatewayBase = "https://gateway.example/v1"; + provider.baseUrl = toOpenAi ? gatewayBase : officialBase; + provider.supportsNativeCompactEndpoint = true; + saveConfig(config); + const citation = { type: "url_citation", url: "https://gateway.example/source", start_index: "3", end_index: -1, gateway: { source: "fixture" } }; + const user = { role: "user", content: "fixture question" }; + const assistant = { type: "message", role: "assistant", content: [{ type: "output_text", text: "fixture answer", annotations: [citation] }] }; + const input = [user, assistant]; + const before = structuredClone(input); + const held = await waitForProviderRequestSlot(config.defaultProvider, provider); + const abort = new AbortController(); + const pending = start(config, abort, input); + try { + await queue(config, "k1"); + expect(seen).toEqual([]); + provider.baseUrl = toOpenAi ? officialBase : gatewayBase; + held.release(); + const result = await pending; + expect(result.response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.url).toBe(`${provider.baseUrl}/responses/compact`); + expect(seen[0]!.key).toBe(`Bearer ${KEYS[0]}`); + const expectedCitation = toOpenAi ? { ...citation, title: citation.url, start_index: 3, end_index: 0 } : citation; + expect((seen[0]!.body as { input: unknown }).input).toEqual([ + user, { ...assistant, content: [{ ...assistant.content[0]!, annotations: [expectedCitation] }] }, + ]); + expect(input).toEqual(before); + noLeaks(config); + } finally { abort.abort(); held.release(); await pending.catch(() => undefined); } +}); diff --git a/tests/responses/responses-native-item-extensions-compact.test.ts b/tests/responses/responses-native-item-extensions-compact.test.ts index 2d32a987635..55def6f784a 100644 --- a/tests/responses/responses-native-item-extensions-compact.test.ts +++ b/tests/responses/responses-native-item-extensions-compact.test.ts @@ -44,7 +44,7 @@ describe("native compact item extensions (#7111)", () => { ]; for (const [name, provider] of providers) { - test(`strips Google replay metadata on the direct ${name} compact send`, async () => { + test(`strips Google replay metadata and repairs citations on the direct ${name} compact send`, async () => { const calls: Array<{ url: string; body: Record }> = []; const mockFetch = (async (input, init) => { const req = new Request(input, init); @@ -58,12 +58,17 @@ describe("native compact item extensions (#7111)", () => { }; const call = { type: "function_call", call_id: "call_probe", name: "probe", arguments: '{"value":1}' }; const output = { type: "function_call_output", call_id: "call_probe", output: '{"extra_content":"tool data"}' }; + const citation = { type: "url_citation", url: "https://source.example/reference", start_index: "3", end_index: -1, extra: "keep" }; + const assistant = { type: "message", role: "assistant", content: [ + { type: "output_text", text: "Fixture answer.", annotations: [citation] }, + ] }; const body = { model: `${name}/gpt-6-sol`, input: [ { role: "user", content: "Compact the completed tool call." }, { ...call, extra_content: { google: { thought_signature: "synthetic-signature-7111" } } }, output, + assistant, ], }; const before = structuredClone(body); @@ -76,7 +81,11 @@ describe("native compact item extensions (#7111)", () => { await response.text(); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe(`${provider.baseUrl}/responses/compact`); - expect(calls[0]!.body.input).toEqual([body.input[0], call, output]); + expect(calls[0]!.body.input).toEqual([body.input[0], call, output, { + ...assistant, content: [{ ...assistant.content[0], annotations: [ + { ...citation, title: citation.url, start_index: 3, end_index: 0 }, + ] }], + }]); expect(body).toEqual(before); }); } diff --git a/tests/responses/responses-url-citation-title-repair.test.ts b/tests/responses/responses-url-citation-title-repair.test.ts index aba11a1b83b..94fea6873ed 100644 --- a/tests/responses/responses-url-citation-title-repair.test.ts +++ b/tests/responses/responses-url-citation-title-repair.test.ts @@ -64,9 +64,24 @@ describe("url_citation annotation repair on outbound history", () => { expect(repairUrlCitationAnnotations(null)).toBeNull(); }); - test("stripProviderInputItemMetadata (the OpenAI-operated outbound projection) applies it", () => { - const body = { input: [{ role: "user", content: "hi" }, assistantWith([{ type: "url_citation", url: "https://a.example", start_index: 0, end_index: 0 }])] }; - expect(annotationsOf(stripProviderInputItemMetadata(body))[0]!.title).toBe("https://a.example"); + test("metadata stripping and citation repair remain independent projections", () => { + const citation = { type: "url_citation", url: "https://a.example", start_index: "3", end_index: -1, extra: "keep" }; + const message = { ...assistantWith([citation]), extra_content: { google: { thought_signature: "fixture-signature" } } }; + const body = { input: [{ role: "user", content: "hi" }, message] }; + const before = structuredClone(body); + + const stripped = stripProviderInputItemMetadata(body) as { input: Record[] }; + expect(stripped.input[1]).not.toHaveProperty("extra_content"); + expect(stripped.input[1]!.content).toBe(message.content); + expect(annotationsOf(stripped)).toBe(message.content[0]!.annotations); + expect(annotationsOf(stripped)).toEqual([citation]); + + const repaired = repairUrlCitationAnnotations(body) as { input: Record[] }; + expect(repaired.input[1]!.extra_content).toBe(message.extra_content); + expect(annotationsOf(repaired)).toEqual([ + { ...citation, title: citation.url, start_index: 3, end_index: 0 }, + ]); + expect(body).toEqual(before); }); const native: OcxProviderConfig[] = [ diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 062abbab23c..5cd0dd476ea 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -14,11 +14,12 @@ import { connect } from "node:net"; import { networkInterfaces, tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; -import { startServer } from "../../src/server"; +import { startServer, waitForFailedStartRollback } from "../../src/server"; import { runListenerShutdown } from "../../src/server/lifecycle"; import { AuxiliaryListenerBindError, findAvailablePort, + isAddrInUse, PortUnavailableError, setEphemeralPortAllocatorForTests, } from "../../src/server/ports"; @@ -73,6 +74,41 @@ async function startLoopbackTestServer(loopbackPort: number) { return startServer(publicPort); } +/** Fresh auxiliary draws one hub start may take; see startHubIngressServer. */ +const AUXILIARY_PORT_DRAWS = 3; + +/** + * Start a hub whose public listener the kernel assigns atomically, and read its port back. + * + * These cases used to probe a public port on 127.0.0.1, release it, and later bind it on the + * config's 0.0.0.0. Anything that took the port in between made startServer throw EADDRINUSE + * for a listener the case never asserted on (macOS lane=all run 39786507816, shard 3/3). Port 0 + * has no such window, so the public listener no longer has a probe at all. + * + * The management and data-loopback listeners cannot do the same: config requires them to name + * a concrete 1-65535 port before startServer runs, so a draw can still be taken before the + * server binds it. That outcome is not timing-dependent: startServer reports it as an + * AuxiliaryListenerBindError naming the listener, with the bind's EADDRINUSE as its cause, and + * only that verdict earns a fresh draw. Every other failure, including a wrong listener or a + * non-EADDRINUSE bind error, still fails the case on its first occurrence. + */ +async function startHubIngressServer( + configFor: (managementPort: number, loopbackPort: number) => OcxConfig, +): Promise<{ server: ReturnType; managementPort: number; loopbackPort: number }> { + for (let draw = 1; ; draw += 1) { + const managementPort = await freePort(); + const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + saveConfig(configFor(managementPort, loopbackPort)); + try { + return { server: startServer(0), managementPort, loopbackPort }; + } catch (error) { + await waitForFailedStartRollback(error); + const auxiliaryPortTaken = error instanceof AuxiliaryListenerBindError && isAddrInUse(error.cause); + if (!auxiliaryPortTaken || draw >= AUXILIARY_PORT_DRAWS) throw error; + } + } +} + function firstNonLoopbackIPv4(): string | null { for (const entries of Object.values(networkInterfaces())) { for (const entry of entries ?? []) { @@ -121,10 +157,7 @@ afterEach(() => { describe("hub management ingress", () => { test("binds only loopback and serves GUI plus authenticated management routes", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig(hubIngressConfig(managementPort)); - const server = startServer(publicPort); + const { server, managementPort } = await startHubIngressServer(managementPort => hubIngressConfig(managementPort)); try { const page = await fetch(`http://127.0.0.1:${managementPort}/`, { headers: { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }, @@ -158,9 +191,7 @@ describe("hub management ingress", () => { }, SERVER_BUDGET_MS); test("client export through management ingress names the public bound port and ingress refuses inference", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig({ + const { server, managementPort } = await startHubIngressServer(managementPort => ({ ...hubIngressConfig(managementPort), port: 0, unauthenticatedLoopbackListener: { enabled: false }, @@ -174,8 +205,7 @@ describe("hub management ingress", () => { models: ["static-model"], }, }, - }); - const server = startServer(publicPort); + })); const base = `http://127.0.0.1:${managementPort}`; const headers = { Host: "hub.example.test", @@ -199,10 +229,7 @@ describe("hub management ingress", () => { }, SERVER_BUDGET_MS); test("default-denies every data, health, readiness, WebSocket, and unknown-static route", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig(hubIngressConfig(managementPort)); - const server = startServer(publicPort); + const { server, managementPort } = await startHubIngressServer(managementPort => hubIngressConfig(managementPort)); const base = `http://127.0.0.1:${managementPort}`; try { const denied: Array<{ path: string; headers?: Record }> = [ @@ -249,11 +276,8 @@ describe("hub management ingress", () => { }); test("normal shutdown closes public, data-loopback, and management listeners", async () => { - const managementPort = await freePort(); - const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); - saveConfig(hubIngressConfig(managementPort, loopbackPort)); - const server = startServer(publicPort); + const { server, managementPort, loopbackPort } = await startHubIngressServer(hubIngressConfig); + const publicPort = server.port!; await server.stop(true); for (const port of [publicPort, loopbackPort, managementPort]) { const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index cf0c80f58a4..6469416ed34 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -28,6 +28,7 @@ import { hardenSecretPath, hardenedSecretPathCountForTests, resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setPlatformForTests, timedOutSecretPathCountForTests, @@ -117,6 +118,10 @@ beforeEach(() => { resetContextRelayActivationForTests(); process.env.OPENCODEX_API_AUTH_TOKEN = "data-secret"; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; + // These cases assert credential separation, not ACLs (tests/windows owns those). Real + // async icacls on the native-main owner lock under testHome left it with an empty DACL + // that the teardown could not delete (#7515). Sync-runner cases still inject their own. + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); }); afterEach(async () => { @@ -130,6 +135,7 @@ afterEach(async () => { else process.env.CODEX_HOME = previousCodexHome; setSystemRestartIoForTests(); setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); setPlatformForTests(null); resetHardenedStateForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME;