From ff03e5db576fcfdeb33cb3d8ee3582e3f3fc3811 Mon Sep 17 00:00:00 2001 From: CPbianma <114645664+CPbianma@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:18:20 +0800 Subject: [PATCH] fix(host-runtime): reject Desktop reuse of removed Codex runtime files --- docs/product/standalone-web.md | 2 + .../src/codex-runtime/backend-files.ts | 25 ++++++ .../host-runtime/src/local-shared-host.ts | 19 +++- .../host-runtime/src/native-account-host.ts | 10 +++ .../src/remote-app-server-session.ts | 12 ++- .../host-runtime/src/shared-host-desktop.ts | 87 +++++++++++-------- .../test/local-shared-host.test.ts | 77 +++++++++++++++- .../test/native-account-host.test.ts | 75 +++++++++++++++- 8 files changed, 265 insertions(+), 42 deletions(-) create mode 100644 packages/host-runtime/src/codex-runtime/backend-files.ts diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index f1dd6f1fd..59cf0d819 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -49,6 +49,8 @@ codexhost stop 版本、安装位置及选项一致时复用服务。`codexhost` 或 `codexhost web` 发现 Host 或 Web 的版本、运行入口不一致时切换为当前安装;Host 被替换会中断任务并输出任务数,会话记录保留,任务不自动续跑。后台 Web 就绪后崩溃会退避重启,启动阶段失败不重试。`codexhost status` 只读显示 Host/Web 状态与版本、不含 token 的 Web 地址、最近失败原因和自启状态,不启动服务。 +每代官方 Codex 后台记录创建时存在的 CLI 文件及 Windows 工具宿主。Desktop 重连若要保留正在运行的后台,会先检查这些文件;文件已被 Desktop 更新或清理移除时,连接返回固定的恢复提示和失败状态,现有 Host 与任务继续保留。确认可以中断后台任务后,运行 `codexhost stop` 再启动 CodexHost,以当前 Desktop 缓存重建后台。空闲后台仍可按原有规则切换到 Desktop 提供的新配置,新的一代重新记录实际使用的文件;可选工具宿主在创建时不存在则不要求补齐。检查覆盖文件缺失,不检测同一路径内的文件版本替换。 + `codexhost stop` 不确认,按 GUI、Web、Host、远程服务、Broker、Console 的顺序停止,并报告本地 Host 被中断的任务数;用户自行打开的 Desktop 不关闭。远程服务停止后,其他电脑重新连接可恢复远程服务。自启项、Serve 配置和会话数据保留。前台开发 Web 不属于此命令的停止范围,开发者使用下文的 `npm stop`。 diff --git a/packages/host-runtime/src/codex-runtime/backend-files.ts b/packages/host-runtime/src/codex-runtime/backend-files.ts new file mode 100644 index 000000000..3959875dd --- /dev/null +++ b/packages/host-runtime/src/codex-runtime/backend-files.ts @@ -0,0 +1,25 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +// Fixed text is safe to carry in a WebSocket close reason (under 123 UTF-8 bytes). +export const CODEX_RUNTIME_REMOVED_MESSAGE = + "Codex runtime files removed. Run codexhost stop, then restart CodexHost. Stopping interrupts Host tasks."; + +export class CodexRuntimeFilesRemovedError extends Error { + constructor() { + super(CODEX_RUNTIME_REMOVED_MESSAGE); + this.name = "CodexRuntimeFilesRemovedError"; + } +} + +/** Capture the files present in the cache cohort used by this backend generation. */ +export function captureCodexRuntimeFiles(executable: string): string[] { + const files = [executable]; + if (process.platform === "win32") + files.push(path.join(path.dirname(executable), "codex-code-mode-host.exe")); + return files.filter((file) => existsSync(file)); +} + +export function assertCodexRuntimeFilesPresent(files: readonly string[]): void { + if (files.some((file) => !existsSync(file))) throw new CodexRuntimeFilesRemovedError(); +} diff --git a/packages/host-runtime/src/local-shared-host.ts b/packages/host-runtime/src/local-shared-host.ts index 840125f5e..f7e2c2fc0 100644 --- a/packages/host-runtime/src/local-shared-host.ts +++ b/packages/host-runtime/src/local-shared-host.ts @@ -7,6 +7,10 @@ import { } from "@codexhost/desktop-control"; import { WebSocket, createWebSocketStream } from "ws"; import { StreamThreadPeer, type SharedThreadPeer } from "./shared-thread-peer.js"; +import { + CODEX_RUNTIME_REMOVED_MESSAGE, + CodexRuntimeFilesRemovedError, +} from "./codex-runtime/backend-files.js"; /** Matches both npm payloads and the signed macOS app's Resources/MacOS split. */ export function sharedHostLauncherPath( @@ -81,6 +85,15 @@ async function connectStream( }); }); const stream = createWebSocketStream(socket); + socket.once("close", (code, reason) => { + if (code === 1000 || code === 1005) return; + // Only this fixed authenticated recovery reason is exposed to the Desktop. + const error = + code === 1011 && reason.toString() === CODEX_RUNTIME_REMOVED_MESSAGE + ? new CodexRuntimeFilesRemovedError() + : new Error("Independent Host connection closed unexpectedly"); + stream.destroy(error); + }); socket.on("error", () => stream.destroy()); return stream; } @@ -128,7 +141,11 @@ export async function runLocalDesktopBridge( ); try { const closed = new Promise((resolve) => { - stream.once("error", () => resolve(1)); + stream.once("error", (error) => { + if (error instanceof CodexRuntimeFilesRemovedError) + process.stderr.write(`${error.message}\n`); + resolve(1); + }); stream.once("close", () => resolve(0)); }); process.stdin.pipe(stream); diff --git a/packages/host-runtime/src/native-account-host.ts b/packages/host-runtime/src/native-account-host.ts index 2006129a8..34ea9ac86 100644 --- a/packages/host-runtime/src/native-account-host.ts +++ b/packages/host-runtime/src/native-account-host.ts @@ -12,6 +12,10 @@ 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 { resolveOfficialCodexExecutable } from "./codex-runtime/command.js"; +import { + captureCodexRuntimeFiles, + assertCodexRuntimeFilesPresent, +} from "./codex-runtime/backend-files.js"; export interface PreparedLocalCodex { officialRuntimeScope: OfficialRuntimeScope; @@ -87,6 +91,7 @@ export async function prepareLocalCodex(input: { startup.stockCodexPath = (await resolveOfficialCodexExecutable(startup.stockCodexPath, input.environment)) ?? ""; let startupConfigured = false; + let backendFiles: string[] = []; const scope = new OfficialRuntimeScope({ permanentHome: home, diagnosticOutput: input.diagnosticOutput, @@ -95,6 +100,7 @@ export async function prepareLocalCodex(input: { createBackend: () => { startupConfigured = true; if (!startup.stockCodexPath) throw new Error("Official Codex is unavailable"); + backendFiles = captureCodexRuntimeFiles(startup.stockCodexPath); return createOwnedLoopbackBackend({ stockCodexPath: startup.stockCodexPath, cwd: home, @@ -168,6 +174,10 @@ export async function prepareLocalCodex(input: { const changed = startup.stockCodexPath !== next.stockCodexPath || JSON.stringify(startup.arguments) !== JSON.stringify(next.arguments); + // The running generation, not the next requested invocation, owns these files. + // An idle changed backend can still be replaced through the normal path below. + if (scope.owner.running && (!changed || scope.owner.hasActiveWork)) + assertCodexRuntimeFilesPresent(backendFiles); if (changed && scope.owner.generation > 0 && scope.owner.hasActiveWork) { input.diagnosticOutput.write( "codexhost: Desktop startup differs; preserving active official Codex work\n", diff --git a/packages/host-runtime/src/remote-app-server-session.ts b/packages/host-runtime/src/remote-app-server-session.ts index 973e08388..5b635a41d 100644 --- a/packages/host-runtime/src/remote-app-server-session.ts +++ b/packages/host-runtime/src/remote-app-server-session.ts @@ -1,6 +1,10 @@ import { PassThrough, type Writable } from "node:stream"; import type { RawData, WebSocket } from "ws"; import type { RemoteAppServerSession, RemoteAppServerSessionStreams } from "./remote-app-server.js"; +import { + CODEX_RUNTIME_REMOVED_MESSAGE, + CodexRuntimeFilesRemovedError, +} from "./codex-runtime/backend-files.js"; function bytes(data: RawData): Buffer { if (Buffer.isBuffer(data)) return data; @@ -117,7 +121,13 @@ export function attachRemoteAppServerSession(options: { }) .catch((error: unknown) => { diagnosticOutput.write(`codexhost app-server: ${String(error)}\n`); - if (socket.readyState === socket.OPEN) socket.close(1011, "Host Runtime failed"); + if (socket.readyState === socket.OPEN) + socket.close( + 1011, + error instanceof CodexRuntimeFilesRemovedError + ? CODEX_RUNTIME_REMOVED_MESSAGE + : "Host Runtime failed", + ); }) .finally(() => { finished = true; diff --git a/packages/host-runtime/src/shared-host-desktop.ts b/packages/host-runtime/src/shared-host-desktop.ts index 0134d1080..18135aa81 100644 --- a/packages/host-runtime/src/shared-host-desktop.ts +++ b/packages/host-runtime/src/shared-host-desktop.ts @@ -96,53 +96,66 @@ export function sharedHostDesktop( const entry = runtimePath ? consoleEntrypoint(runtimePath) : null; const delegation = options.delegation(); const webOpener = createHostWebOpener(options.environment); - const host = new AppServerHost({ - stockCodexPath: options.stockCodex, - arguments: [], - environment, - desktopInput: streams.input, - desktopOutput: streams.output, - diagnosticOutput: streams.diagnosticOutput, - officialRuntimeScope: options.official.officialRuntimeScope, - accountControl: options.official.accountControl, - mappingStore: options.store, - closeMappingStoreOnExit: false, - modelPrices: options.prices, - sharedThreads: new SharedThreadBridge({ - connect: async () => options.owner.connect(), - delegateCreates: true, - diagnose: (error) => streams.diagnosticOutput.write(`Shared Host: ${String(error)}\n`), - }), - ...(delegation ? { sharedDelegation: delegation } : {}), - ...installedHarnessPluginOptions( + const createHost = () => + new AppServerHost({ + stockCodexPath: options.stockCodex, + arguments: [], environment, - false, - runtimePath ? pathToFileURL(runtimePath).href : options.runtimeUrl, - ), - ...(runtimePath - ? { runtimeMaintenance: new RuntimeMaintenance({ runtimePath, remote: false, environment }) } - : {}), - ...(options.updateCoordinator ? { updateCoordinator: options.updateCoordinator } : {}), - ...(entry - ? { consoleOpener: createHostConsoleOpener({ entrypoint: entry, environment }) } - : {}), - ...(webOpener ? { webOpener } : {}), - onDelegationApi: options.register, - }); + desktopInput: streams.input, + desktopOutput: streams.output, + diagnosticOutput: streams.diagnosticOutput, + officialRuntimeScope: options.official.officialRuntimeScope, + accountControl: options.official.accountControl, + mappingStore: options.store, + closeMappingStoreOnExit: false, + modelPrices: options.prices, + sharedThreads: new SharedThreadBridge({ + connect: async () => options.owner.connect(), + delegateCreates: true, + diagnose: (error) => streams.diagnosticOutput.write(`Shared Host: ${String(error)}\n`), + }), + ...(delegation ? { sharedDelegation: delegation } : {}), + ...installedHarnessPluginOptions( + environment, + false, + runtimePath ? pathToFileURL(runtimePath).href : options.runtimeUrl, + ), + ...(runtimePath + ? { + runtimeMaintenance: new RuntimeMaintenance({ runtimePath, remote: false, environment }), + } + : {}), + ...(options.updateCoordinator ? { updateCoordinator: options.updateCoordinator } : {}), + ...(entry + ? { consoleOpener: createHostConsoleOpener({ entrypoint: entry, environment }) } + : {}), + ...(webOpener ? { webOpener } : {}), + onDelegationApi: options.register, + }); + let host: AppServerHost | undefined; + let closed = false; return { run: async () => { await startup; - options.connected.add(host); - const running = host.run(); + if (closed) return 0; + const active = (host = createHost()); + options.connected.add(active); + const running = active.run(); options.running.add(running); try { return await running; } finally { - options.connected.delete(host); + options.connected.delete(active); options.running.delete(running); } }, - disconnect: () => host.disconnect(), - close: () => host.close(), + disconnect: () => { + closed = true; + host?.disconnect(); + }, + close: () => { + closed = true; + host?.close(); + }, }; } diff --git a/packages/host-runtime/test/local-shared-host.test.ts b/packages/host-runtime/test/local-shared-host.test.ts index 0f91b677a..2645d66f3 100644 --- a/packages/host-runtime/test/local-shared-host.test.ts +++ b/packages/host-runtime/test/local-shared-host.test.ts @@ -15,9 +15,17 @@ import { SharedThreadBridge } from "../src/shared-thread-bridge.js"; import { connectLocalSharedHost, connectLocalSharedExternalHost, + runLocalDesktopBridge, } from "../src/local-shared-host.js"; +import { + CODEX_RUNTIME_REMOVED_MESSAGE, + CodexRuntimeFilesRemovedError, +} from "../src/codex-runtime/backend-files.js"; import { startClientChannelServer } from "../src/client-channel-server.js"; import { runSharedHostService } from "../src/shared-host-service.js"; +import { sharedHostDesktop } from "../src/shared-host-desktop.js"; +import { prepareLocalCodex } from "../src/native-account-host.js"; +import { ModelPriceCatalog } from "../src/model-prices.js"; import { readNativeWorkspaceSnapshot } from "../src/native-workspace-snapshot.js"; import { createFixture, @@ -31,7 +39,9 @@ afterEach(async () => { for (const run of cleanup.splice(0).reverse()) await run(); }); -async function setup() { +async function setup( + desktopSession?: Parameters[0]["desktopSession"], +) { const root = mkdtempSync(path.join(tmpdir(), "local-shared-owner-")); cleanup.push(() => rmSync(root, { recursive: true, force: true })); const store = new MappingStore({ directory: path.join(root, "mapping-store") }); @@ -62,7 +72,7 @@ async function setup() { owner: "service", externalSession: (streams) => owner.createSession(streams), shutdown: { runningTasks: () => new Set(host.runningThreadIds()).size, stop }, - desktopSession: (streams) => owner.createSession(streams), + desktopSession: desktopSession ?? ((streams) => owner.createSession(streams)), }); cleanup.push(() => server.close()); const web = new HostClientChannel(path.join(root, "client-hosts"), server.descriptor); @@ -94,6 +104,69 @@ async function setup() { return { root, owner, adapter, host, server, web, store, gui, stop }; } +it("returns an actionable failure when authenticated Desktop admission finds a removed runtime", async () => { + const register = vi.fn(); + const connected = new Set(); + const running = new Set>(); + const f = await setup((streams, request) => { + expect(request.headers["x-codexhost-desktop-context"]).toBeTypeOf("string"); + return sharedHostDesktop( + { + environment: { CODEXHOST_DATA_DIR: f.root }, + runtimeUrl: import.meta.url, + stockCodex: path.join(f.root, "codex.exe"), + official, + owner: f.owner, + store: f.store, + prices: new ModelPriceCatalog({ + directory: path.join(f.root, "prices"), + diagnose: () => {}, + }), + delegation: () => undefined, + register, + connected, + running, + }, + streams, + request, + ); + }); + const official = await prepareLocalCodex({ + stockCodexPath: path.join(f.root, "codex.exe"), + arguments: ["app-server"], + environment: { CODEX_HOME: path.join(f.root, "native-home"), CODEXHOST_DATA_DIR: f.root }, + diagnosticOutput: new PassThrough(), + deferStart: true, + }); + vi.spyOn(official, "configureStartup").mockRejectedValue(new CodexRuntimeFilesRemovedError()); + const stdin = vi.spyOn(process.stdin, "pipe").mockImplementation((destination) => destination); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const result = await runLocalDesktopBridge( + { + CODEXHOST_LAUNCHER_EXECUTABLE: path.join(f.root, "unused-launcher"), + CODEXHOST_DATA_DIR: f.root, + }, + path.join(f.root, "unused-runtime.mjs"), + path.join(f.root, "codex.exe"), + ["app-server"], + ); + expect(result).toBe(1); + expect(stderr.mock.calls.map(([message]) => String(message)).join("")).toContain( + CODEX_RUNTIME_REMOVED_MESSAGE, + ); + expect(f.stop).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + expect(connected.size).toBe(0); + expect(running.size).toBe(0); + await expect(f.web.request("thread/list", {})).resolves.toMatchObject({ data: [] }); + } finally { + stdin.mockRestore(); + stderr.mockRestore(); + await official.close(); + } +}); + it("SSH and local clients create and resume the same external sessions across separate stores", async () => { const { root, web, adapter, store } = await setup(); const bridge = new SharedThreadBridge({ diff --git a/packages/host-runtime/test/native-account-host.test.ts b/packages/host-runtime/test/native-account-host.test.ts index 5c9b721e4..cf59a3b14 100644 --- a/packages/host-runtime/test/native-account-host.test.ts +++ b/packages/host-runtime/test/native-account-host.test.ts @@ -105,13 +105,14 @@ async function startLocal( deferStart = false, savedStartup?: { stockCodexPath: string; arguments: string[] }, diagnosticOutput = new PassThrough(), + stockCodexPath = "/synthetic/codex", ) { const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-native-account-host-")); temporaryDirectories.add(directory); if (savedStartup) await writeFile(path.join(directory, "official-startup-v1.json"), JSON.stringify(savedStartup)); const local = await prepareLocalCodex({ - stockCodexPath: "/synthetic/codex", + stockCodexPath, arguments: ["app-server"], environment: { CODEX_HOME: path.join(directory, "missing-codex-home"), @@ -125,6 +126,78 @@ async function startLocal( } describe("local Codex read-only identity startup", () => { + async function runtimeFiles(name: string, companion = true) { + const directory = await mkdtemp(path.join(os.tmpdir(), `native-runtime-${name}-`)); + temporaryDirectories.add(directory); + const executable = path.join(directory, "codex.exe"); + const tool = path.join(directory, "codex-code-mode-host.exe"); + await writeFile(executable, "backend fixture"); + if (companion) await writeFile(tool, "tool fixture"); + return { executable, tool }; + } + + it("rejects a retained generation whose executable was removed without stopping active work", async () => { + const files = await runtimeFiles("active"); + const first = nativeFixture(); + const local = await startLocal(true, undefined, new PassThrough(), files.executable); + await local.officialRuntimeScope.start(); + first.notify({ + method: "turn/started", + params: { threadId: "active", turn: { id: "turn", status: "inProgress" } }, + }); + await vi.waitFor(() => expect(local.officialRuntimeScope.owner.hasActiveWork).toBe(true)); + await rm(files.executable); + for (const executable of [files.executable, (await runtimeFiles("proposed")).executable]) { + await expect( + local.configureStartup({ + stockCodexPath: executable, + arguments: ["-c", "features.code_mode_host=true", "app-server"], + environment: {}, + }), + ).rejects.toThrow("codexhost stop"); + } + expect(first.stop).not.toHaveBeenCalled(); + expect(local.officialRuntimeScope.owner.hasActiveWork).toBe(true); + expect(createOwnedLoopbackBackend).toHaveBeenCalledOnce(); + }); + + it.runIf(process.platform === "win32")( + "tracks the current generation's companion after an idle runtime replacement", + async () => { + const a = await runtimeFiles("a"); + const b = await runtimeFiles("b"); + const first = nativeFixture(); + const local = await startLocal(true, undefined, new PassThrough(), a.executable); + await local.officialRuntimeScope.start(); + await rm(a.executable); + nativeFixture(); + const configuration = { + stockCodexPath: b.executable, + arguments: ["app-server"], + environment: {}, + }; + await local.configureStartup(configuration); + expect(first.stop).toHaveBeenCalledOnce(); + await local.officialRuntimeScope.start(); + await expect(local.configureStartup(configuration)).resolves.toBeUndefined(); + await rm(b.tool); + await expect(local.configureStartup(configuration)).rejects.toThrow("codexhost stop"); + }, + ); + + it("keeps a companion optional when it was absent at generation creation", async () => { + const files = await runtimeFiles("custom", false); + const first = nativeFixture(); + const local = await startLocal(true, undefined, new PassThrough(), files.executable); + await local.officialRuntimeScope.start(); + await local.configureStartup({ + stockCodexPath: files.executable, + arguments: ["-c", "features.code_mode_host=true", "app-server"], + environment: {}, + }); + expect(first.stop).not.toHaveBeenCalled(); + }); + it("records the Desktop invocation for subsequent Web-first startup", async () => { const f = nativeFixture(); const local = await startLocal(true);