Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/product/standalone-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。


Expand Down
25 changes: 25 additions & 0 deletions packages/host-runtime/src/codex-runtime/backend-files.ts
Original file line number Diff line number Diff line change
@@ -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();
}
19 changes: 18 additions & 1 deletion packages/host-runtime/src/local-shared-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -128,7 +141,11 @@ export async function runLocalDesktopBridge(
);
try {
const closed = new Promise<number>((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);
Expand Down
10 changes: 10 additions & 0 deletions packages/host-runtime/src/native-account-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion packages/host-runtime/src/remote-app-server-session.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
87 changes: 50 additions & 37 deletions packages/host-runtime/src/shared-host-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
};
}
77 changes: 75 additions & 2 deletions packages/host-runtime/test/local-shared-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,7 +39,9 @@ afterEach(async () => {
for (const run of cleanup.splice(0).reverse()) await run();
});

async function setup() {
async function setup(
desktopSession?: Parameters<typeof startClientChannelServer>[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") });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<AppServerHost>();
const running = new Set<Promise<number>>();
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({
Expand Down
Loading
Loading