Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/architecture/official-traffic-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#原生听写实时语音与统一历史)。

## 请求必有回应

Expand Down
8 changes: 8 additions & 0 deletions docs/product/codex-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 天额度;额度继续由专属额度入口展示。
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions packages/host-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions packages/host-runtime/scripts/build-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const allowedRuntimePackages = new Set([
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"diff",
"smol-toml",
"ws",
"zod",
]);
Expand Down
11 changes: 9 additions & 2 deletions packages/host-runtime/src/app-server-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
),
);
}
}
Expand Down
206 changes: 206 additions & 0 deletions packages/host-runtime/src/codex-runtime/native-voice-compatibility.ts
Original file line number Diff line number Diff line change
@@ -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<JsonObject>;
}

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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

/** Recognize only CC Switch's endpoint-free official shared-history alias. */
function officialHistoryAlias(config: Record<string, unknown>): 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<Record<string, unknown> | 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<void> {
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<string, unknown>;
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<string | undefined>;
}): Promise<NativeVoiceCompatibility> {
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;
},
};
}
21 changes: 16 additions & 5 deletions packages/host-runtime/src/codex-runtime/official-runtime-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonObject>;
}
const object = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
Expand All @@ -74,6 +76,7 @@ export class OfficialRuntimeOwner {
readonly gate: OfficialWorkGate;
readonly #factory: () => OwnedOfficialBackend;
readonly #diagnosticOutput: Writable;
readonly #prepareRequest: OfficialRuntimeOwnerOptions["prepareRequest"];
readonly #clients = new Set<Client>();
#backend: OwnedOfficialBackend | undefined;
#generation = 0;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading