From 5955a44be853d5e0103815c05aaa82f4e2ca1f0b Mon Sep 17 00:00:00 2001 From: Llugaes <249094896@qq.com> Date: Sat, 24 Oct 2026 16:31:46 +0800 Subject: [PATCH 1/2] fix(runtime): preserve native voice for official history aliases --- .../official-traffic-ownership.md | 1 + docs/product/codex-accounts.md | 8 + package-lock.json | 1 + packages/host-runtime/package.json | 1 + .../host-runtime/scripts/build-release.mjs | 1 + packages/host-runtime/src/app-server-host.ts | 11 +- .../native-voice-compatibility.ts | 206 ++++++++++++++++++ .../codex-runtime/official-runtime-owner.ts | 21 +- .../codex-runtime/official-runtime-scope.ts | 1 + .../host-runtime/src/native-account-host.ts | 26 ++- .../test/native-account-host.test.ts | 39 +++- .../test/native-voice-compatibility.test.ts | 175 +++++++++++++++ .../test/official-runtime-owner.test.ts | 39 +++- tests/release/host-bundle.test.mjs | 1 + 14 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 packages/host-runtime/src/codex-runtime/native-voice-compatibility.ts create mode 100644 packages/host-runtime/test/native-voice-compatibility.test.ts diff --git a/docs/architecture/official-traffic-ownership.md b/docs/architecture/official-traffic-ownership.md index a910974f8..d5383e394 100644 --- a/docs/architecture/official-traffic-ownership.md +++ b/docs/architecture/official-traffic-ownership.md @@ -23,6 +23,7 @@ Host 夹在 Codex Desktop 与官方 app-server 之间。官方协议的方法和 ## 已知例外 - Mapping Store 读取失败时,带 `threadId` 的请求返回 `-32081`,不放行官方,避免外部 Thread 请求误发官方。 +- 本地官方历史别名的语音兼容启动模式使用真实的 `openai` 后端配置,保留原文件。该模式的原生 `thread/list` 默认查询同时包含 `openai` 与旧 `custom` 历史;明确的其他 Provider 过滤、空过滤及原生游标不变。路由改变后拒绝新工作并要求重启。该模式不修改 `config/read`、账号、功能开关或语音协议响应;其他启动形态继续遵循默认放行规则。详见[账号设置](../product/codex-accounts.md#原生听写实时语音与统一历史)。 ## 请求必有回应 diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md index 2435727a1..0d5d52643 100644 --- a/docs/product/codex-accounts.md +++ b/docs/product/codex-accounts.md @@ -66,6 +66,14 @@ Desktop `account/login/*` 和 `account/logout` 原样交给官方后端。Host SSH 维持远端原生单账号,不传输本地凭据。 +## 原生听写、实时语音与统一历史 + +原生 Codex 的听写和实时语音由官方 Desktop 与 app-server 提供,继续遵循原生登录、工作区策略与功能开关。CodexHost 不实现另一套录音或语音服务。 + +CC Switch 的统一会话历史可把官方 Provider 保存为 `custom` 别名。对于无地址、无附加认证字段且仅包含 `name = "OpenAI"`、`requires_openai_auth = true`、`supports_websockets = true`、`wire_api = "responses"` 的官方别名,本地 Host 以原生 `model_provider="openai"` 启动覆盖运行后端,使 Desktop 能正确识别官方语音能力。用户的 Codex 配置、CC Switch 配置及历史文件不被修改;历史查询同时包含 `openai` 与原来的 `custom` 分桶,旧 Thread 仍通过原生 resume 续接。 + +显式 Profile、命令行路由或启动目录覆盖、第三方或本地代理地址、附加认证字段及未知形态保持原生路径。兼容模式遇到项目自己的 Provider 覆盖时会拒绝新工作并提示用显式 Profile 重启,避免官方启动覆盖改变项目路由。官方账号与第三方 Provider 之间切换后需重启 CodexHost;若在兼容后端运行期间改成其他路由,新建或续接工作会明确提示重启,既有实时会话的停止操作仍可执行。单纯恢复入口不代表已验证麦克风权限、转写或实时音频往返。 + ## 用量浮窗 用量浮窗不重复展示 5 小时和 7 天额度;额度继续由专属额度入口展示。 diff --git a/package-lock.json b/package-lock.json index 701c49ae7..c1bbdbf35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6389,6 +6389,7 @@ "@codexhost/shared-contracts": "0.0.0", "@codexhost/update-manager": "0.0.0", "@deepseek-ai/cordis": "4.0.4", + "smol-toml": "^1.8.0", "ws": "8.21.3" } }, diff --git a/packages/host-runtime/package.json b/packages/host-runtime/package.json index e979728cf..2ed4432b2 100644 --- a/packages/host-runtime/package.json +++ b/packages/host-runtime/package.json @@ -26,6 +26,7 @@ "@codexhost/shared-contracts": "0.0.0", "@codexhost/update-manager": "0.0.0", "@deepseek-ai/cordis": "4.0.4", + "smol-toml": "^1.8.0", "ws": "8.21.3" } } diff --git a/packages/host-runtime/scripts/build-release.mjs b/packages/host-runtime/scripts/build-release.mjs index 4a1518c0b..a227dc3e1 100644 --- a/packages/host-runtime/scripts/build-release.mjs +++ b/packages/host-runtime/scripts/build-release.mjs @@ -20,6 +20,7 @@ const allowedRuntimePackages = new Set([ "@deepseek-ai/cordis", "@deepseek-ai/cosmokit", "diff", + "smol-toml", "ws", "zod", ]); diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index 28ff005fd..e04e75c11 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -14,6 +14,7 @@ import { import { requestDesktopRemoteConnections } from "@codexhost/desktop-control"; import type { RuntimeMaintenance } from "./runtime-maintenance.js"; import { ExternalThreadPrewarms } from "./external-thread-prewarms.js"; +import { NativeProviderRestartRequiredError } from "./codex-runtime/native-voice-compatibility.js"; import { HarnessLaunchSettingsStore } from "@codexhost/harness-plugin-files"; import type { SharedThreadBridge } from "./shared-thread-bridge.js"; import { @@ -2205,13 +2206,19 @@ export class AppServerHost { } await this.#officialRuntime.sendFrame(frame); this.#markDesktopRequestAnswered(request.id); - } catch { + } catch (error) { if (request.method === "turn/start") { this.#pendingOfficialTurnStarts.delete(request.id); this.#signalActiveWorkChanged(); } await this.#writer.json( - rpcError(request, -32001, "Official request failed; retry explicitly"), + rpcError( + request, + -32001, + error instanceof NativeProviderRestartRequiredError + ? error.message + : "Official request failed; retry explicitly", + ), ); } } diff --git a/packages/host-runtime/src/codex-runtime/native-voice-compatibility.ts b/packages/host-runtime/src/codex-runtime/native-voice-compatibility.ts new file mode 100644 index 000000000..5d48f2e8c --- /dev/null +++ b/packages/host-runtime/src/codex-runtime/native-voice-compatibility.ts @@ -0,0 +1,206 @@ +import type { JsonObject } from "@codexhost/protocol-core"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "smol-toml"; + +export interface NativeVoiceCompatibility { + arguments: string[]; + prepareRequest?: (method: string, params: JsonObject) => Promise; +} + +export class NativeProviderRestartRequiredError extends Error { + constructor(project = false) { + super( + project + ? "Project Provider override cannot use native voice compatibility; select an explicit Profile and restart CodexHost" + : "Native Provider changed; restart CodexHost to apply the new route", + ); + this.name = "NativeProviderRestartRequiredError"; + } +} + +const startsWork = new Set([ + "thread/start", + "thread/resume", + "thread/fork", + "turn/start", + "turn/steer", + "thread/compact/start", + "thread/queue/start", + "review/start", + "thread/realtime/start", +]); +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Recognize only CC Switch's endpoint-free official shared-history alias. */ +function officialHistoryAlias(config: Record): boolean { + if ( + config.model_provider !== "custom" || + config.profile !== undefined || + config.openai_base_url !== undefined + ) + return false; + const providers = config.model_providers; + if (!record(providers) || providers.openai !== undefined || !record(providers.custom)) + return false; + const provider = providers.custom; + return ( + Object.keys(provider).length === 4 && + provider.name === "OpenAI" && + provider.requires_openai_auth === true && + provider.supports_websockets === true && + provider.wire_api === "responses" + ); +} + +function explicitRoute(arguments_: readonly string[]): boolean { + for (let index = 0; index < arguments_.length; index++) { + const argument = arguments_[index] ?? ""; + if ( + (argument.startsWith("-p") && !argument.startsWith("--")) || + argument === "--profile" || + argument.startsWith("--profile=") || + argument === "-C" || + argument.startsWith("-C") || + argument === "--cd" || + argument.startsWith("--cd=") + ) + return true; + const config = + argument === "-c" || argument === "--config" + ? arguments_[++index] + : argument.startsWith("--config=") + ? argument.slice("--config=".length) + : argument.startsWith("-c") && !argument.startsWith("--") + ? argument.slice(2) + : undefined; + if ((argument === "-c" || argument === "--config") && config === undefined) return true; + if (config !== undefined) { + // Native CLI overrides accept plugin ids containing @ in dotted keys; + // those are not bare TOML assignments. Inspect the root key first. + const key = config.split("=", 1)[0]?.trim() ?? ""; + const bareRoot = /^[A-Za-z0-9_-]+(?=\.|$)/u.exec(key)?.[0]; + if (bareRoot) { + if (["model_provider", "model_providers", "openai_base_url", "profile"].includes(bareRoot)) + return true; + continue; + } + try { + if ( + Object.keys(parse(config)).some((key) => + ["model_provider", "model_providers", "openai_base_url", "profile"].includes(key), + ) + ) + return true; + } catch { + return true; + } + } + } + return false; +} + +async function readConfig(file: string): Promise | null> { + try { + return parse(await readFile(file, "utf8")); + } catch { + // Unknown configuration stays on the native path; never infer a route from partial TOML. + return null; + } +} + +async function checkProjectRoute(cwd: string, home: string): Promise { + for (let directory = path.resolve(home, cwd); ; directory = path.dirname(directory)) { + const file = path.join(directory, ".codex", "config.toml"); + if (file !== path.join(home, "config.toml")) { + let text: string | undefined; + try { + text = await readFile(file, "utf8"); + } catch (error) { + if (!( + error instanceof Error && + "code" in error && + ["ENOENT", "ENOTDIR"].includes(String(error.code)) + )) + throw new NativeProviderRestartRequiredError(true); + } + if (text !== undefined) { + let config: Record; + try { + config = parse(text); + } catch { + throw new NativeProviderRestartRequiredError(true); + } + const providers = config.model_providers; + if ( + config.profile !== undefined || + config.openai_base_url !== undefined || + (config.model_provider !== undefined && + !["custom", "openai"].includes(String(config.model_provider))) || + (providers !== undefined && + (!record(providers) || + providers.openai !== undefined || + (providers.custom !== undefined && + !officialHistoryAlias({ ...config, model_provider: "custom" })))) + ) { + throw new NativeProviderRestartRequiredError(true); + } + } + } + if (path.dirname(directory) === directory) break; + } +} + +function includeHistoryAlias(params: JsonObject): JsonObject { + const filters = params.modelProviders; + if (filters === undefined) return { ...params, modelProviders: ["openai", "custom"] }; + if (!Array.isArray(filters) || !filters.every((value) => typeof value === "string")) + return params; + if (filters.includes("openai") === filters.includes("custom")) return params; + return { + ...params, + modelProviders: [...filters, filters.includes("openai") ? "custom" : "openai"], + }; +} + +export async function prepareNativeVoiceCompatibility(input: { + home: string; + arguments: readonly string[]; + environment: NodeJS.ProcessEnv; + lookupThreadCwd?: (threadId: string) => Promise; +}): Promise { + const unchanged = { arguments: [...input.arguments] }; + if (explicitRoute(input.arguments) || input.environment.OPENAI_BASE_URL) return unchanged; + const file = path.join(input.home, "config.toml"); + const config = await readConfig(file); + if (!config || !officialHistoryAlias(config)) return unchanged; + return { + // This is a real native startup override, not a projected config/capability response. + // Both Providers use the same stock endpoint, OAuth owner and websocket defaults. + arguments: [...input.arguments, "-c", 'model_provider="openai"'], + async prepareRequest(method, params) { + if (startsWork.has(method)) { + const current = await readConfig(file); + const builtInOfficial = + current && + (current.model_provider === undefined || current.model_provider === "openai") && + current.profile === undefined && + current.openai_base_url === undefined && + (current.model_providers === undefined || + (record(current.model_providers) && current.model_providers.openai === undefined)); + if (!current || (!officialHistoryAlias(current) && !builtInOfficial)) { + throw new NativeProviderRestartRequiredError(); + } + const cwd = + typeof params.cwd === "string" + ? params.cwd + : typeof params.threadId === "string" + ? await input.lookupThreadCwd?.(params.threadId) + : undefined; + if (cwd) await checkProjectRoute(cwd, input.home); + } + return method === "thread/list" ? includeHistoryAlias(params) : params; + }, + }; +} diff --git a/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts b/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts index ea8131657..648787f98 100644 --- a/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts +++ b/packages/host-runtime/src/codex-runtime/official-runtime-owner.ts @@ -49,6 +49,8 @@ export interface OfficialRuntimeOwnerOptions { createBackend(): OwnedOfficialBackend; diagnosticOutput: Writable; gate: OfficialWorkGate; + /** Local startup compatibility may adjust native history filters and reject stale routes. */ + prepareRequest?: (method: string, params: JsonObject) => Promise; } const object = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value); @@ -74,6 +76,7 @@ export class OfficialRuntimeOwner { readonly gate: OfficialWorkGate; readonly #factory: () => OwnedOfficialBackend; readonly #diagnosticOutput: Writable; + readonly #prepareRequest: OfficialRuntimeOwnerOptions["prepareRequest"]; readonly #clients = new Set(); #backend: OwnedOfficialBackend | undefined; #generation = 0; @@ -84,6 +87,7 @@ export class OfficialRuntimeOwner { constructor(input: OfficialRuntimeOwnerOptions) { this.#factory = input.createBackend; this.#diagnosticOutput = input.diagnosticOutput; + this.#prepareRequest = input.prepareRequest; this.gate = input.gate; } get generation(): number { @@ -341,6 +345,7 @@ export class OfficialRuntimeOwner { const finish = this.gate.admit(); let response: JsonObject | undefined; try { + if (this.#prepareRequest) params = await this.#prepareRequest(method, params); const runtime = await this.#connection(client); await this.#restore(client, runtime, method, params); response = await runtime.request(method, params); @@ -371,23 +376,29 @@ export class OfficialRuntimeOwner { client.serverRequests.delete(String(value.id)); return; } - if (value.method === "initialized") return; + const method = value.method; + if (method === "initialized") return; if (typeof value.id !== "string" && typeof value.id !== "number") throw new Error("Official methods require a request ID"); - const params = object(value.params) ? value.params : {}; - this.#checkMethod(value.method); + let params = object(value.params) ? value.params : {}; + this.#checkMethod(method); const finish = this.gate.admit(); let pendingKey: string | undefined; try { + if (this.#prepareRequest && (value.params === undefined || object(value.params))) { + const prepared = await this.#prepareRequest(method, params); + if (prepared !== params) value = { ...value, params: prepared }; + params = prepared; + } const runtime = await this.#connection(client); - await this.#restore(client, runtime, value.method, params); + await this.#restore(client, runtime, method, params); if (typeof value.id === "string" || typeof value.id === "number") { const key = requestKey(value.id); if (client.pending.has(key)) throw new Error("Duplicate official request ID"); pendingKey = key; client.pending.set(pendingKey, { id: value.id, - method: value.method, + method, params, finish, }); diff --git a/packages/host-runtime/src/codex-runtime/official-runtime-scope.ts b/packages/host-runtime/src/codex-runtime/official-runtime-scope.ts index dcc9952fa..a8b5d479a 100644 --- a/packages/host-runtime/src/codex-runtime/official-runtime-scope.ts +++ b/packages/host-runtime/src/codex-runtime/official-runtime-scope.ts @@ -60,6 +60,7 @@ export class OfficialRuntimeScope { }, diagnosticOutput: input.diagnosticOutput, gate: this.gate, + ...(input.prepareRequest ? { prepareRequest: input.prepareRequest } : {}), }); this.gate.subscribe(() => { if (this.#started && this.gate.phase === "unavailable") this.#generationFailed(); diff --git a/packages/host-runtime/src/native-account-host.ts b/packages/host-runtime/src/native-account-host.ts index f5b723cb4..76420dcc8 100644 --- a/packages/host-runtime/src/native-account-host.ts +++ b/packages/host-runtime/src/native-account-host.ts @@ -10,6 +10,7 @@ import { import { officialEnvironment } from "./app-server-host.js"; import { OfficialRuntimeScope } from "./codex-runtime/official-runtime-scope.js"; import { createOwnedLoopbackBackend } from "./codex-runtime/owned-official-backends.js"; +import { prepareNativeVoiceCompatibility } from "./codex-runtime/native-voice-compatibility.js"; export interface PreparedLocalCodex { officialRuntimeScope: OfficialRuntimeScope; @@ -39,16 +40,39 @@ export async function prepareLocalCodex(input: { input.environment.CODEX_HOME ?? path.join(homedir(), ".codex"), ); await mkdir(home, { recursive: true }); + const voiceCompatibility = await prepareNativeVoiceCompatibility({ + home, + arguments: input.arguments, + environment: input.environment, + lookupThreadCwd: async (threadId) => { + const response = await scope.owner.controlRequest("thread/read", { + threadId, + includeTurns: false, + }); + const result = response.result; + if (!result || typeof result !== "object" || Array.isArray(result)) return undefined; + const thread = result.thread; + return thread && + typeof thread === "object" && + !Array.isArray(thread) && + typeof thread.cwd === "string" + ? thread.cwd + : undefined; + }, + }); const scope = new OfficialRuntimeScope({ permanentHome: home, diagnosticOutput: input.diagnosticOutput, // Local Desktop connections outlive a failed official backend generation. recovery: {}, + ...(voiceCompatibility.prepareRequest + ? { prepareRequest: voiceCompatibility.prepareRequest } + : {}), createBackend: () => createOwnedLoopbackBackend({ stockCodexPath: input.stockCodexPath, cwd: home, - arguments: input.arguments, + arguments: voiceCompatibility.arguments, environment: { ...officialEnvironment(input.environment), CODEX_HOME: home }, }), }); diff --git a/packages/host-runtime/test/native-account-host.test.ts b/packages/host-runtime/test/native-account-host.test.ts index dbcb66182..f6d15018b 100644 --- a/packages/host-runtime/test/native-account-host.test.ts +++ b/packages/host-runtime/test/native-account-host.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; @@ -61,6 +61,8 @@ function nativeFixture() { result = { thread: { id: "voice-thread" } }; } else if (request.method === "thread/realtime/start") { result = {}; + } else if (request.method === "thread/list") { + result = { data: [], nextCursor: null }; } else { throw new Error("Unexpected native request"); } @@ -98,9 +100,13 @@ function nativeFixture() { }; } -async function startLocal() { +async function startLocal(config?: string) { const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-native-account-host-")); temporaryDirectories.add(directory); + if (config !== undefined) { + await mkdir(path.join(directory, "missing-codex-home")); + await writeFile(path.join(directory, "missing-codex-home", "config.toml"), config); + } const local = await prepareLocalCodex({ stockCodexPath: "/synthetic/codex", arguments: ["app-server"], @@ -112,6 +118,35 @@ async function startLocal() { } describe("local Codex read-only identity startup", () => { + it("starts an official history alias on the real openai runtime without editing configuration", async () => { + const fixture = nativeFixture(); + const local = await startLocal(`model_provider = "custom" +[model_providers.custom] +name = "OpenAI" +requires_openai_auth = true +supports_websockets = true +wire_api = "responses" +`); + expect(createOwnedLoopbackBackend).toHaveBeenCalledWith( + expect.objectContaining({ arguments: ["app-server", "-c", 'model_provider="openai"'] }), + ); + const desktop = new OfficialRuntimeClient({ + scope: local.officialRuntimeScope, + output: async () => {}, + }); + try { + await desktop.initializeProtocol({ + clientInfo: { name: "codex_desktop", version: "synthetic" }, + }); + await desktop.request("thread/list", { modelProviders: ["openai"], limit: 7 }); + expect(fixture.requests.find((request) => request.method === "thread/list")?.params).toEqual({ + modelProviders: ["openai", "custom"], + limit: 7, + }); + } finally { + desktop.close(); + } + }); it("recovers the local backend so an existing client can explicitly retry voice startup", async () => { const first = nativeFixture(); const local = await startLocal(); diff --git a/packages/host-runtime/test/native-voice-compatibility.test.ts b/packages/host-runtime/test/native-voice-compatibility.test.ts new file mode 100644 index 000000000..2f086d310 --- /dev/null +++ b/packages/host-runtime/test/native-voice-compatibility.test.ts @@ -0,0 +1,175 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + prepareNativeVoiceCompatibility, + type NativeVoiceCompatibility, +} from "../src/codex-runtime/native-voice-compatibility.js"; + +const homes: string[] = []; +function prepareRequest(mode: NativeVoiceCompatibility) { + if (!mode.prepareRequest) throw new Error("Missing native compatibility policy"); + return mode.prepareRequest; +} +afterEach(async () => { + for (const home of homes.splice(0)) await rm(home, { recursive: true }); +}); +const officialAlias = `model_provider = "custom" +[model_providers.custom] +name = "OpenAI" +requires_openai_auth = true +supports_websockets = true +wire_api = "responses" +`; +async function prepare(config = officialAlias, args = ["app-server"]) { + const home = await mkdtemp(path.join(os.tmpdir(), "codexhost-voice-")); + homes.push(home); + const file = path.join(home, "config.toml"); + await writeFile(file, config); + return { + file, + mode: await prepareNativeVoiceCompatibility({ home, arguments: args, environment: {} }), + }; +} + +describe("native voice compatibility for official history aliases", () => { + it("uses a real official runtime Provider while leaving the user's configuration intact", async () => { + const { file, mode } = await prepare(); + expect(mode.arguments).toEqual(["app-server", "-c", 'model_provider="openai"']); + expect(await readFile(file, "utf8")).toBe(officialAlias); + expect(mode.prepareRequest).toBeTypeOf("function"); + }); + + it("accepts the real Desktop plugin overrides without treating them as route changes", async () => { + const args = [ + "-c", + "features.code_mode_host=true", + "app-server", + "--analytics-default-enabled", + "-c", + "plugins.codex-app-tools@openai-bundled.mcp_servers.codex_app.enabled=false", + ]; + const { mode } = await prepare(officialAlias, args); + expect(mode.arguments).toEqual([...args, "-c", 'model_provider="openai"']); + }); + + it.each([ + ["a third-party endpoint", officialAlias + 'base_url = "https://relay.example/v1"\n'], + ["a local proxy", officialAlias + 'base_url = "http://127.0.0.1:15721/v1"\n'], + ["provider authentication", officialAlias + 'env_key = "PROVIDER_KEY"\n'], + ["a nonofficial name", officialAlias.replace('name = "OpenAI"', 'name = "Relay"')], + [ + "a changed transport", + officialAlias.replace("supports_websockets = true", "supports_websockets = false"), + ], + ["a selected profile", 'profile = "relay"\n' + officialAlias], + ["a root endpoint override", 'openai_base_url = "https://relay.example/v1"\n' + officialAlias], + ["an existing built-in override", officialAlias + '[model_providers.openai]\nname = "Relay"\n'], + ["a future field", officialAlias + 'future_route = "unknown"\n'], + ["an ordinary official configuration", 'model_provider = "openai"\n'], + ])("keeps %s on the unchanged native path", async (_name, config) => { + const { mode } = await prepare(config); + expect(mode.arguments).toEqual(["app-server"]); + expect(mode.prepareRequest).toBeUndefined(); + }); + + it.each([ + ["app-server", "-c", 'model_provider="other"'], + ["app-server", '--config=model_providers.custom.base_url="https://relay.example"'], + ["-p", "work", "app-server"], + ["app-server", '-cmodel_provider="other"'], + ["app-server", '--config="model_provider"="other"'], + ["-pwork", "app-server"], + ["-C", "/project", "app-server"], + ["app-server", "-c"], + ])("does not override an explicit native route: %j", async (...args) => { + const { mode } = await prepare(officialAlias, args); + expect(mode.arguments).toEqual(args); + expect(mode.prepareRequest).toBeUndefined(); + }); + + it("keeps old custom history visible, preserves explicit unrelated filters and cursor fields", async () => { + const { mode } = await prepare(); + const transform = prepareRequest(mode); + expect(await transform("thread/list", { limit: 7, cursor: "native-page" })).toEqual({ + limit: 7, + cursor: "native-page", + modelProviders: ["openai", "custom"], + }); + expect(await transform("thread/list", { modelProviders: ["openai"] })).toEqual({ + modelProviders: ["openai", "custom"], + }); + expect(await transform("thread/list", { modelProviders: ["custom"] })).toEqual({ + modelProviders: ["custom", "openai"], + }); + for (const filter of [null, [], ["other"], ["openai", "custom"]]) { + const params = { modelProviders: filter }; + expect(await transform("thread/list", params)).toBe(params); + } + const resume = { threadId: "legacy-thread" }; + expect(await transform("thread/resume", resume)).toBe(resume); + }); + + it("requires a restart before new work can follow a changed third-party route, without blocking stop", async () => { + const { file, mode } = await prepare(); + await writeFile(file, officialAlias + 'base_url = "https://relay.example/v1"\n'); + for (const method of [ + "thread/start", + "thread/resume", + "thread/fork", + "turn/start", + "thread/realtime/start", + "review/start", + ]) { + await expect(prepareRequest(mode)(method, {})).rejects.toThrow(/restart CodexHost/); + } + const stop = { threadId: "existing-thread" }; + expect(await prepareRequest(mode)("thread/realtime/stop", stop)).toBe(stop); + }); + + it("allows ordinary preference changes and the official route without changing history files", async () => { + const { file, mode } = await prepare(); + await writeFile(file, 'model = "gpt-6.1-sol"\n' + officialAlias); + await expect(prepareRequest(mode)("turn/start", {})).resolves.toEqual({}); + await writeFile(file, 'model_provider = "openai"\n'); + await expect(prepareRequest(mode)("thread/realtime/start", {})).resolves.toEqual({}); + }); + + it("does not pin a project-specific Provider to the official startup override", async () => { + const { file, mode } = await prepare(); + const project = path.join(path.dirname(file), "project"); + const nested = path.join(project, "nested"); + await mkdir(path.join(project, ".codex"), { recursive: true }); + await mkdir(nested); + await writeFile(path.join(project, ".codex", "config.toml"), 'model_provider = "other"\n'); + await expect(prepareRequest(mode)("thread/start", { cwd: nested })).rejects.toThrow( + /Project Provider/, + ); + const home = path.dirname(file); + const resumed = await prepareNativeVoiceCompatibility({ + home, + arguments: ["app-server"], + environment: {}, + lookupThreadCwd: async () => nested, + }); + await expect( + prepareRequest(resumed)("thread/resume", { threadId: "project-thread" }), + ).rejects.toThrow(/Project Provider/); + await writeFile(path.join(project, ".codex", "config.toml"), 'model = "gpt-6.1-sol"\n'); + await expect(prepareRequest(mode)("thread/start", { cwd: nested })).resolves.toEqual({ + cwd: nested, + }); + }); + + it("leaves environment endpoint overrides on the native path", async () => { + const { file } = await prepare(); + const mode = await prepareNativeVoiceCompatibility({ + home: path.dirname(file), + arguments: ["app-server"], + environment: { OPENAI_BASE_URL: "https://relay.example/v1" }, + }); + expect(mode.prepareRequest).toBeUndefined(); + expect(mode.arguments).toEqual(["app-server"]); + }); +}); diff --git a/packages/host-runtime/test/official-runtime-owner.test.ts b/packages/host-runtime/test/official-runtime-owner.test.ts index 6a3f766c3..1749a3d5f 100644 --- a/packages/host-runtime/test/official-runtime-owner.test.ts +++ b/packages/host-runtime/test/official-runtime-owner.test.ts @@ -13,7 +13,7 @@ import { OfficialWorkGate } from "../src/codex-runtime/official-work-gate.js"; import { createOwnedLoopbackBackend } from "../src/codex-runtime/owned-official-backends.js"; import type { OfficialAppServerExit } from "../src/official-app-server-connection.js"; -function fixture() { +function fixture(prepareRequest?: (method: string, params: JsonObject) => Promise) { let live = 0; let peak = 0; const events: string[] = []; @@ -129,6 +129,7 @@ function fixture() { createBackend: create, diagnosticOutput: new PassThrough(), gate, + ...(prepareRequest ? { prepareRequest } : {}), }); const attach = () => { const output: JsonObject[] = []; @@ -204,6 +205,42 @@ describe("owned loopback backend", () => { }); describe("single official runtime owner", () => { + it.each(["request", "send"])( + "applies local native compatibility before %s and never sends rejected work", + async (via) => { + const prepareRequest = vi.fn(async (method: string, params: JsonObject) => { + if (method === "turn/start") throw new Error("restart CodexHost"); + return method === "thread/list" + ? { ...params, modelProviders: ["openai", "custom"] } + : params; + }); + const f = fixture(prepareRequest); + const { client } = f.attach(); + try { + await f.owner.start(); + await client.initialize(initialization); + f.gate.initialized(); + if (via === "request") { + await client.request("thread/list", { limit: 5 }); + await expect(client.request("turn/start", { threadId: "thread-one" })).rejects.toThrow( + "restart CodexHost", + ); + } else { + await client.send({ id: 4, method: "thread/list", params: { limit: 5 } }); + await expect( + client.send({ id: 5, method: "turn/start", params: { threadId: "thread-one" } }), + ).rejects.toThrow("restart CodexHost"); + } + expect(f.connection().requests.find((r) => r.method === "thread/list")?.params).toEqual({ + limit: 5, + modelProviders: ["openai", "custom"], + }); + expect(f.connection().requests.some((r) => r.method === "turn/start")).toBe(false); + } finally { + await f.owner.stop(); + } + }, + ); it.each([ { via: "request", method: "account/rateLimits/read" }, { via: "request", method: "thread/read" }, diff --git a/tests/release/host-bundle.test.mjs b/tests/release/host-bundle.test.mjs index fb54f4ce1..21503c9ab 100644 --- a/tests/release/host-bundle.test.mjs +++ b/tests/release/host-bundle.test.mjs @@ -178,6 +178,7 @@ describe("release Host and independent plugin Bundles", () => { "@deepseek-ai/cordis", "@deepseek-ai/cosmokit", "diff", + "smol-toml", "ws", "zod", ]); From d086a5b4ce5a1cb0ca677f15cd1130f0cc2d510f Mon Sep 17 00:00:00 2001 From: Llugaes <249094896@qq.com> Date: Sat, 24 Oct 2026 16:33:34 +0800 Subject: [PATCH 2/2] docs: clarify native history storage in voice compatibility --- docs/product/codex-accounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md index 0d5d52643..768de9adc 100644 --- a/docs/product/codex-accounts.md +++ b/docs/product/codex-accounts.md @@ -70,7 +70,7 @@ SSH 维持远端原生单账号,不传输本地凭据。 原生 Codex 的听写和实时语音由官方 Desktop 与 app-server 提供,继续遵循原生登录、工作区策略与功能开关。CodexHost 不实现另一套录音或语音服务。 -CC Switch 的统一会话历史可把官方 Provider 保存为 `custom` 别名。对于无地址、无附加认证字段且仅包含 `name = "OpenAI"`、`requires_openai_auth = true`、`supports_websockets = true`、`wire_api = "responses"` 的官方别名,本地 Host 以原生 `model_provider="openai"` 启动覆盖运行后端,使 Desktop 能正确识别官方语音能力。用户的 Codex 配置、CC Switch 配置及历史文件不被修改;历史查询同时包含 `openai` 与原来的 `custom` 分桶,旧 Thread 仍通过原生 resume 续接。 +CC Switch 的统一会话历史可把官方 Provider 保存为 `custom` 别名。对于无地址、无附加认证字段且仅包含 `name = "OpenAI"`、`requires_openai_auth = true`、`supports_websockets = true`、`wire_api = "responses"` 的官方别名,本地 Host 以原生 `model_provider="openai"` 启动覆盖运行后端,使 Desktop 能正确识别官方语音能力。兼容层不写回 Codex 或 CC Switch 配置,不迁移或重写已有历史;新会话仍由原生后端保存。历史查询同时包含新的 `openai` 与原来的 `custom` 分桶,旧 Thread 仍通过原生 resume 续接。 显式 Profile、命令行路由或启动目录覆盖、第三方或本地代理地址、附加认证字段及未知形态保持原生路径。兼容模式遇到项目自己的 Provider 覆盖时会拒绝新工作并提示用显式 Profile 重启,避免官方启动覆盖改变项目路由。官方账号与第三方 Provider 之间切换后需重启 CodexHost;若在兼容后端运行期间改成其他路由,新建或续接工作会明确提示重启,既有实时会话的停止操作仍可执行。单纯恢复入口不代表已验证麦克风权限、转写或实时音频往返。