Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions gui/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-responses/request-strips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 46 additions & 12 deletions src/codex/desktop-compatibility/windows-certificate-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DesktopCertificateTrust>;

/** 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",
Expand All @@ -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<DesktopCertificateTrust> {
Expand Down
28 changes: 27 additions & 1 deletion src/codex/native-main-owner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { isAbsolute, join, relative, resolve, sep } from "node:path";

import { Database } from "bun:sqlite";

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading