From 66d2b4a37ab833e96a913c8193852dc984bb7925 Mon Sep 17 00:00:00 2001 From: felenwu Date: Wed, 11 Nov 2026 09:49:52 +0800 Subject: [PATCH] fix(web): page remote histories and preserve Harness identity --- docs/assets/web-remote-harness-identity.png | Bin 0 -> 56121 bytes docs/product/standalone-web.md | 6 +- package-lock.json | 3 +- packages/desktop-control/package.json | 3 +- .../desktop-control/scripts/build-release.mjs | 3 + packages/desktop-control/src/cdp-client.ts | 4 +- .../src/remote-connections-control.ts | 12 +- .../src/remote-session-client.ts | 3 +- .../src/renderer-remote-sessions.ts | 74 +++++++- .../test/cdp-client-large-response.test.ts | 163 ++++++++++++++++++ .../test/renderer-remote-sessions.test.ts | 123 ++++++++++++- .../shared-contracts/src/remote-sessions.ts | 8 +- packages/web-server/src/ch-thread-history.ts | 2 +- packages/web-server/src/ch-thread-view.ts | 1 + packages/web-server/src/remote-sessions.ts | 98 ++++++++--- .../test/browser/remote-sessions.test.ts | 161 +++++++++++++---- .../web-server/test/remote-history.test.ts | 142 +++++++++++++++ .../web-server/test/remote-sessions.test.ts | 64 +++++++ .../desktop-controller-bundle.test.mjs | 9 + 19 files changed, 812 insertions(+), 67 deletions(-) create mode 100644 docs/assets/web-remote-harness-identity.png create mode 100644 packages/desktop-control/test/cdp-client-large-response.test.ts create mode 100644 packages/web-server/test/remote-history.test.ts diff --git a/docs/assets/web-remote-harness-identity.png b/docs/assets/web-remote-harness-identity.png new file mode 100644 index 0000000000000000000000000000000000000000..f8e70eb93dbe7852dc65bab6f1a468a7cd1dfb00 GIT binary patch literal 56121 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�?eB!wQoe>3uW5j5A~LC;Si;A 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 X0-U;l8}KJ%00000NkvXXu0mjf!>toO literal 0 HcmV?d00001 diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index ecfdd5b91..8bb25573a 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -66,7 +66,11 @@ Linux 的 Web 与 Desktop 环境不同时,发现过程还检查当前用户标 支持读取会话、空闲时文本续聊、停止当前 Turn、改名、归档,以及 manager 提供原生 pending request 和 responder 时的命令/文件变更审批(允许一次、拒绝)。线程 resume 和 turn/start 使用原生 ID,owner 和鉴权由原生 manager 保持。缺少原生审批方法的旧 manager 或其他交互类型会提示回 Desktop 操作,不伪造权限能力。远程文件上传、终端、本机路径打开、模型/权限切换、fork、编辑、队列和 steering 不提供;这些请求不会落到本机 Thread handler。远程项目来自 Desktop 保存的 remote-projects,按 hostId+项目 ID 隔离;归属优先使用原生项目分配,其次在该主机已登记项目的路径范围内匹配,未匹配的会话保留在对应主机分组。分组仅用于只读导航,不把远程路径交给本机目录创建、终端或文件操作。 -列表每个 Host 最多读取 100 页,每页 100 条;超过限制不发布不完整目录。打开的会话每 1.5 秒读取不消费事件的绝对快照,多个浏览器独立可见;关闭跟随即停止读取。快照保留原生 Turn/Item ID,单次 Controller 响应上限 16 MiB,超大完整历史当前不可用,应在 Codex Desktop 打开;远程快照尚无端到端分段读取能力,调整浏览器消息页大小不能绕过这一上限。连接失败保留已见列表并标记离线;已打开的跟随保留订阅,读取按指数退避重试,最长间隔 30 秒,连接恢复后无需重新打开。关闭视图后停止重试。连接代际变化会使旧审批失效,重建浏览器投影。所有写请求只发送一次,超时或断线报告结果未知,不自动重发消息或审批;读后同步失败不否认已确认的写入。 +列表每个 Host 最多读取 100 页,每页 100 条;超过限制不发布不完整目录。远程外部会话的 Harness 标识和显示名来自同一 Host 的归属及插件目录,用于显示真实图标;查询不支持时保留目录,不根据模型名称猜测归属。 + +打开的会话每 1.5 秒读取不消费事件的绝对状态,多个浏览器独立可见;关闭跟随即停止读取。分页会话先读取元数据,再通过同一原生 manager 的 `thread/turns/list` 每次加载五轮,复用已有历史分页、重连与滚动锚点;旧会话保留完整快照读取。发送前使用 `thread/resume {threadId, excludeTurns: true}` 建立订阅,不重新传输完整历史。快照保留原生 Turn/Item ID,单次 Controller 响应上限仍为 16 MiB;超大单页明确报错,不因此将整个 Host 标记为离线。 + +连接失败保留已见列表并标记离线;已打开的跟随保留订阅,读取按指数退避重试,最长间隔 30 秒,连接恢复后无需重新打开。关闭视图后停止重试。连接代际变化会使旧审批失效,重建浏览器投影。所有写请求只发送一次,超时或断线报告结果未知,不自动重发消息或审批;读后同步失败不否认已确认的写入。 本机 native Codex 目录及其共享 app-server 生命周期由本机来源负责,与此远程聚合独立。隔离 fixtures 覆盖复合身份、断线、代际、未知结果和审批路由;这些检查不代替 Windows/macOS 上真实 SSH、RC 活跃 Turn、Desktop 与多浏览器同时响应的实机验收。 diff --git a/package-lock.json b/package-lock.json index ed2a1905a..ff6c5dfe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16804,7 +16804,8 @@ "name": "@codexhost/desktop-control", "version": "0.0.0", "dependencies": { - "@codexhost/shared-contracts": "0.0.0" + "@codexhost/shared-contracts": "0.0.0", + "ws": "8.21.3" } }, "packages/harness-adapter": { diff --git a/packages/desktop-control/package.json b/packages/desktop-control/package.json index 5b2a00dc8..d0753fb84 100644 --- a/packages/desktop-control/package.json +++ b/packages/desktop-control/package.json @@ -21,6 +21,7 @@ "build:release": "node scripts/build-release.mjs" }, "dependencies": { - "@codexhost/shared-contracts": "0.0.0" + "@codexhost/shared-contracts": "0.0.0", + "ws": "8.21.3" } } diff --git a/packages/desktop-control/scripts/build-release.mjs b/packages/desktop-control/scripts/build-release.mjs index 75f812783..2204a78d1 100644 --- a/packages/desktop-control/scripts/build-release.mjs +++ b/packages/desktop-control/scripts/build-release.mjs @@ -49,6 +49,9 @@ export async function buildDesktopControllerBundle({ repositoryRoot, outputPath bundle: true, platform: "node", format: "esm", + banner: { + js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', + }, target: "node22", packages: "bundle", sourcemap: false, diff --git a/packages/desktop-control/src/cdp-client.ts b/packages/desktop-control/src/cdp-client.ts index 67383584d..f2390e7e6 100644 --- a/packages/desktop-control/src/cdp-client.ts +++ b/packages/desktop-control/src/cdp-client.ts @@ -1,3 +1,5 @@ +import WebSocket from "ws"; + export interface CdpTarget { id: string; type: string; @@ -138,7 +140,7 @@ function defaultFetch(url: string, init?: { signal?: AbortSignal }): Promise { try { return {result:await (${rendererRemoteSession.toString()})(window,${JSON.stringify(request.operation)})}; } catch(error) {return {error:{code:-32090,message:String(error?.message ?? error)}};} })()`; + if (request.action === "session") { + const readOnly = ["hosts", "list", "read", "turns"].includes(request.operation.kind); + // Reject oversized history before it can close the shared CDP transport. + return `(async () => { try { + const reply = {result:await (${rendererRemoteSession.toString()})(window,${JSON.stringify(request.operation)})}; + if (${readOnly} && new TextEncoder().encode(JSON.stringify(reply)).byteLength + 1 > ${REMOTE_THREAD_REPLY_MAX_BYTES}) + return {error:{code:-32094,message:"Remote history response exceeds the 16 MiB limit; open this Thread in Codex Desktop."}}; + return reply; + } catch(error) {return {error:{code:-32090,message:String(error?.message ?? error)}};} })()`; + } return `(async () => { try { const api = window.__codexhostRendererBindingProbeV1; diff --git a/packages/desktop-control/src/remote-session-client.ts b/packages/desktop-control/src/remote-session-client.ts index 8914cadab..7b7e7ce2c 100644 --- a/packages/desktop-control/src/remote-session-client.ts +++ b/packages/desktop-control/src/remote-session-client.ts @@ -79,7 +79,8 @@ export async function requestRemoteSession( }, { action: "session", operation: input }, ); - if ("error" in reply) throw new Error(reply.error.message); + if ("error" in reply) + throw Object.assign(new Error(reply.error.message), { code: reply.error.code }); if (input.kind === "hosts" && Array.isArray(reply.result)) { const projects = await readRemoteProjectMetadata().catch(() => []); return reply.result.map((host) => diff --git a/packages/desktop-control/src/renderer-remote-sessions.ts b/packages/desktop-control/src/renderer-remote-sessions.ts index a07f40778..db6afd738 100644 --- a/packages/desktop-control/src/renderer-remote-sessions.ts +++ b/packages/desktop-control/src/renderer-remote-sessions.ts @@ -95,13 +95,60 @@ export async function rendererRemoteSession( } if ("epoch" in operation && operation.epoch !== generation.epoch) throw new Error("Remote connection changed; refresh before operating"); - if (operation.kind === "list") - return manager.sendRequest("thread/list", { + if (operation.kind === "list") { + const page = (await manager.sendRequest("thread/list", { cursor: operation.cursor, archived: operation.archived, limit: 100, - }); + })) as { data: Array<{ id: string; modelProvider: string }>; nextCursor: string | null }; + const threadIds = page.data + .filter((row) => row.modelProvider === "codexhost") + .map((row) => row.id); + if (!threadIds.length) return page; + // Ownership is independent of model/provider names, and never requires history reads. + const [ownership, catalog] = await Promise.all([ + Promise.resolve(manager.sendRequest("codexhost/thread/ownership/list", { threadIds })).catch( + () => null, + ), + Promise.resolve(manager.sendRequest("codexhost/harness/plugins/list", {})).catch(() => null), + ]); + if (routing.forHost(operation.hostId) !== route) + throw new Error("Remote connection changed during ownership read"); + const owners = ( + ownership as { + threads?: Array<{ threadId: string; owner: string; harnessId?: string }>; + } | null + )?.threads; + const plugins = (catalog as { plugins?: Array<{ id: string; name: string }> } | null)?.plugins; + return { + ...page, + data: page.data.map((row) => { + const owner = Array.isArray(owners) + ? owners.find((entry) => entry?.threadId === row.id && entry.owner === "external") + : undefined; + const id = owner?.harnessId; + if (row.modelProvider !== "codexhost" || typeof id !== "string" || !id) return row; + const plugin = Array.isArray(plugins) + ? plugins.find((entry) => entry?.id === id) + : undefined; + const name = typeof plugin?.name === "string" && plugin.name ? plugin.name : id; + return { ...row, harnessIdentity: { id, name } }; + }), + }; + } const threadId = operation.threadId; + if (operation.kind === "turns") { + const page = await manager.sendRequest("thread/turns/list", { + threadId, + cursor: operation.cursor, + limit: 5, + sortDirection: "desc", + itemsView: "full", + }); + if (routing.forHost(operation.hostId) !== route) + throw new Error("Remote connection changed during history read"); + return page; + } const pendingRequests = async (): Promise => { const conversation = (await manager.getConversation?.(threadId)) as { requests?: Request[] } | undefined; @@ -110,7 +157,21 @@ export async function rendererRemoteSession( return Array.isArray(conversation?.requests) ? conversation.requests : []; }; if (operation.kind === "read") { - const result = await manager.sendRequest("thread/read", { threadId, includeTurns: true }); + let result; + try { + result = await manager.sendRequest("thread/read", { + threadId, + includeTurns: operation.metadataOnly !== true, + }); + } catch (error) { + if ( + operation.metadataOnly || + !(error instanceof Error) || + !error.message.includes("Paginated External Threads require thread/turns/list") + ) + throw error; + result = await manager.sendRequest("thread/read", { threadId, includeTurns: false }); + } if (routing.forHost(operation.hostId) !== route) throw new Error("Remote connection changed during read"); const requests = await pendingRequests(); @@ -161,7 +222,10 @@ export async function rendererRemoteSession( } switch (operation.kind) { case "prompt": - await manager.sendRequest("thread/resume", { threadId }); + // Resuming is required before a native turn, but Web already reads history + // in pages. Returning every old Turn here can overwhelm the shared native + // connection before the new message is even dispatched. + await manager.sendRequest("thread/resume", { threadId, excludeTurns: true }); if (routing.forHost(operation.hostId) !== route) throw new Error("Remote connection changed during resume"); return manager.sendRequest("turn/start", { diff --git a/packages/desktop-control/test/cdp-client-large-response.test.ts b/packages/desktop-control/test/cdp-client-large-response.test.ts new file mode 100644 index 000000000..a79d946b0 --- /dev/null +++ b/packages/desktop-control/test/cdp-client-large-response.test.ts @@ -0,0 +1,163 @@ +import { once } from "node:events"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { chromium } from "@playwright/test"; +import type { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; +import { expect, it } from "vitest"; +import { CdpClient } from "../src/cdp-client.js"; +import { remoteConnectionsExpression } from "../src/remote-connections-control.js"; +import type { RemoteSessionOperation } from "@codexhost/shared-contracts"; + +it("receives a fragmented multi-megabyte history response and keeps the CDP connection usable", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0, perMessageDeflate: false }); + await once(server, "listening"); + const history = "history-历史-".repeat(600_000); + server.on("connection", (socket) => { + socket.on("message", (data) => { + const request = JSON.parse(String(data)) as { id: number; method: string }; + if (request.method !== "Runtime.evaluate") { + socket.send(JSON.stringify({ id: request.id, result: { accepted: true } })); + return; + } + const reply = Buffer.from( + JSON.stringify({ + id: request.id, + result: { result: { type: "object", value: { history } } }, + }), + ); + // CDP can fragment a large response, including across a UTF-8 code point. + for (let offset = 0; offset < reply.length; offset += 1_048_576) { + socket.send(reply.subarray(offset, offset + 1_048_576), { + binary: false, + fin: offset + 1_048_576 >= reply.length, + }); + if (offset === 0) socket.ping(); + } + }); + }); + let client: CdpClient | undefined; + try { + const address = server.address() as AddressInfo; + client = await CdpClient.connect(`ws://127.0.0.1:${address.port}/devtools/page/history`, { + commandTimeoutMs: 10_000, + }); + expect((await client.evaluate<{ history: string }>("history")).history).toBe(history); + await expect(client.command("Runtime.enable")).resolves.toEqual({ accepted: true }); + } finally { + client?.close(); + for (const socket of server.clients) socket.terminate(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}, 15_000); + +it.skipIf(!process.env.CODEXHOST_TEST_CDP_BROWSER)( + "receives a large real Chromium CDP result", + async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codexhost-cdp-history-")); + const executablePath = process.env.CODEXHOST_TEST_CDP_BROWSER; + const browser = await chromium.launchPersistentContext(directory, { + ...(executablePath ? { executablePath } : {}), + headless: true, + args: ["--remote-debugging-port=0", "--remote-debugging-address=127.0.0.1"], + }); + let client: CdpClient | undefined; + try { + const port = (await readFile(path.join(directory, "DevToolsActivePort"), "utf8")).split( + "\n", + )[0]; + await browser.pages()[0]?.goto(`http://127.0.0.1:${port}/json/version`); + const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) as Array<{ + type: string; + webSocketDebuggerUrl: string; + }>; + const target = targets.find((candidate) => candidate.type === "page"); + if (!target) throw new Error("Missing isolated Chromium page"); + client = await CdpClient.connect(target.webSocketDebuggerUrl); + const value = await client.evaluate("'history-\\u5386\\u53f2-'.repeat(600000)"); + expect(value.length).toBe("history-历史-".length * 600_000); + await expect(client.evaluate("6*7")).resolves.toBe(42); + await client.evaluate(`(()=>{ + window.fixtureSends=[]; + const manager={ + getConversation:()=>({requests:[]}), + async sendRequest(method,params){ + if(method==='thread/resume'&¶ms.excludeTurns!==true) + throw Error('Native resume must not transfer unbounded history'); + if(method==='thread/read') return {thread:{id:params.threadId,turns:[{ + id:'history',items:[{type:'agentMessage',text:params.threadId==='oversized' + ? 'x'.repeat(17*1024*1024) + : window.fixtureSends.length?'history-\\u5386\\u53f2-'.repeat(600000):'small'}] + }]}}; + if(method==='turn/start'){window.fixtureSends.push(params.threadId);return {accepted:true}} + return {}; + } + }; + const route={manager}; + window.__codexhostHostRoutingV1={knownHostIds:()=>['remote-ssh-discovered:fixture'],forHost:()=>route}; + return true; + })()`); + const activeClient = client; + const call = (operation: RemoteSessionOperation) => + activeClient.evaluate<{ + result?: { epoch: string; result: { thread: { id: string } } }; + error?: { code: number }; + }>(remoteConnectionsExpression({ action: "session", operation })); + for (const threadId of ["a", "b", "c", "a"]) { + const snapshot = await call({ + kind: "read", + hostId: "remote-ssh-discovered:fixture", + threadId, + }); + expect(snapshot.error).toBeUndefined(); + expect(snapshot.result?.result.thread.id).toBe(threadId); + if (!snapshot.result) throw new Error("Missing fixture snapshot"); + const sent = await call({ + kind: "prompt", + hostId: "remote-ssh-discovered:fixture", + threadId, + epoch: snapshot.result.epoch, + text: "isolated regression", + }); + expect(sent.error).toBeUndefined(); + } + expect( + ( + await call({ + kind: "read", + hostId: "remote-ssh-discovered:fixture", + threadId: "oversized", + }) + ).error?.code, + ).toBe(-32094); + const recovered = await call({ + kind: "read", + hostId: "remote-ssh-discovered:fixture", + threadId: "b", + }); + if (!recovered.result) throw new Error("Unrelated fixture Thread became unavailable"); + expect( + ( + await call({ + kind: "prompt", + hostId: "remote-ssh-discovered:fixture", + threadId: "b", + epoch: recovered.result.epoch, + text: "after oversized history", + }) + ).error, + ).toBeUndefined(); + expect(await client.evaluate("window.fixtureSends")).toEqual(["a", "b", "c", "a", "b"]); + } finally { + client?.close(); + await browser.close(); + if (!path.resolve(directory).startsWith(path.resolve(tmpdir()) + path.sep)) + throw new Error("Unexpected test directory"); + await rm(directory, { recursive: true, force: true }); + } + }, + 20_000, +); diff --git a/packages/desktop-control/test/renderer-remote-sessions.test.ts b/packages/desktop-control/test/renderer-remote-sessions.test.ts index 50743b459..db01ab36b 100644 --- a/packages/desktop-control/test/renderer-remote-sessions.test.ts +++ b/packages/desktop-control/test/renderer-remote-sessions.test.ts @@ -10,7 +10,7 @@ function fixture() { { id: 7, method: "item/commandExecution/requestApproval", params: { threadId: "same" } }, ]; const manager = () => ({ - sendRequest: vi.fn(async (method: string) => + sendRequest: vi.fn(async (method: string): Promise => method === "thread/read" ? { thread: { id: "same" } } : {}, ), getConversation: () => ({ requests }), @@ -40,6 +40,57 @@ function fixture() { return { target, routes, read, requests, route }; } describe("Desktop existing remote session boundary", () => { + it("carries authoritative external Harness identity without reading or resuming history", async () => { + const f = fixture(); + const manager = f.route("remote-ssh-discovered:a").manager; + manager.sendRequest.mockImplementation(async (method: string) => { + if (method === "thread/list") + return { + data: [ + { id: "native", modelProvider: "custom" }, + { id: "external", modelProvider: "codexhost" }, + ], + nextCursor: "next", + }; + if (method === "codexhost/thread/ownership/list") + return { threads: [{ threadId: "external", owner: "external", harnessId: "claude-code" }] }; + if (method === "codexhost/harness/plugins/list") + return { plugins: [{ id: "claude-code", name: "Claude Code" }] }; + throw new Error("Unexpected history or mutation request"); + }); + const read = () => + rendererRemoteSession(f.target, { + kind: "list", + hostId: "remote-ssh-discovered:a", + cursor: null, + archived: false, + }); + expect(await read()).toEqual({ + data: [ + { id: "native", modelProvider: "custom" }, + { + id: "external", + modelProvider: "codexhost", + harnessIdentity: { id: "claude-code", name: "Claude Code" }, + }, + ], + nextCursor: "next", + }); + expect(manager.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + "thread/list", + "codexhost/thread/ownership/list", + "codexhost/harness/plugins/list", + ]); + manager.sendRequest.mockImplementation(async (method: string) => { + if (method === "thread/list") + return { data: [{ id: "external", modelProvider: "codexhost" }], nextCursor: null }; + throw new Error("Older remote Host does not support ownership metadata"); + }); + expect(await read()).toEqual({ + data: [{ id: "external", modelProvider: "codexhost" }], + nextCursor: null, + }); + }); it("shows configured offline hosts and restores only already-enabled connections once", async () => { const f = fixture(); const writes: unknown[] = []; @@ -195,6 +246,76 @@ describe("Desktop existing remote session boundary", () => { "thread/resume", "turn/start", ]); + expect(send).toHaveBeenNthCalledWith(2, "thread/resume", { + threadId: "same", + excludeTurns: true, + }); + }); + it("keeps other Threads reachable after resuming a large native history", async () => { + const f = fixture(); + const send = f.route("remote-ssh-discovered:a").manager.sendRequest; + let connected = true; + const started: string[] = []; + send.mockImplementation(async (method: string, ...args: unknown[]) => { + const params = args[0] as { threadId: string; excludeTurns?: boolean }; + if (!connected) throw new Error("Native connection closed"); + if (method === "thread/resume" && params.excludeTurns !== true) { + connected = false; + throw new Error("Unbounded native resume exceeded transport limit"); + } + if (method === "turn/start") started.push(params.threadId); + return method === "thread/read" ? { thread: { id: params.threadId } } : {}; + }); + const { epoch } = await f.read(); + for (const threadId of ["large", "other"]) + await rendererRemoteSession(f.target, { + kind: "prompt", + hostId: "remote-ssh-discovered:a", + threadId, + epoch, + text: "hello", + }); + expect(started).toEqual(["large", "other"]); + await expect(f.read()).resolves.toHaveProperty("epoch", epoch); + }); + it("reads paginated external history without requesting all Turns", async () => { + const f = fixture(); + const send = f.route("remote-ssh-discovered:a").manager.sendRequest; + send.mockImplementation(async (method: string, ...args: unknown[]) => { + const params = args[0] as { includeTurns?: boolean }; + if (method === "thread/read" && params.includeTurns) + throw new Error("Paginated External Threads require thread/turns/list"); + return method === "thread/read" + ? { thread: { id: "same", historyMode: "paginated", turns: [] } } + : { data: [{ id: "newest" }], nextCursor: "older" }; + }); + const initial = await f.read(); + const page = await rendererRemoteSession(f.target, { + kind: "turns", + hostId: "remote-ssh-discovered:a", + threadId: "same", + epoch: initial.epoch, + cursor: null, + }); + expect(page).toEqual({ data: [{ id: "newest" }], nextCursor: "older" }); + expect(send).toHaveBeenLastCalledWith("thread/turns/list", { + threadId: "same", + cursor: null, + limit: 5, + sortDirection: "desc", + itemsView: "full", + }); + send.mockClear(); + await rendererRemoteSession(f.target, { + kind: "read", + hostId: "remote-ssh-discovered:a", + threadId: "same", + metadataOnly: true, + }); + expect(send).toHaveBeenCalledExactlyOnceWith("thread/read", { + threadId: "same", + includeTurns: false, + }); }); it("serializes data in the Controller expression and excludes local operations", async () => { const f = fixture(); diff --git a/packages/shared-contracts/src/remote-sessions.ts b/packages/shared-contracts/src/remote-sessions.ts index b2408555e..f17f87161 100644 --- a/packages/shared-contracts/src/remote-sessions.ts +++ b/packages/shared-contracts/src/remote-sessions.ts @@ -23,7 +23,13 @@ export const remoteSessionOperationSchema = z.discriminatedUnion("kind", [ cursor: z.string().nullable(), archived: z.boolean(), }), - z.strictObject({ kind: z.literal("read"), ...thread }), + z.strictObject({ kind: z.literal("read"), ...thread, metadataOnly: z.boolean().optional() }), + z.strictObject({ + kind: z.literal("turns"), + ...thread, + epoch: identity, + cursor: z.string().max(4096).nullable(), + }), z.strictObject({ kind: z.literal("prompt"), ...thread, diff --git a/packages/web-server/src/ch-thread-history.ts b/packages/web-server/src/ch-thread-history.ts index 9742d0437..f88209fd5 100644 --- a/packages/web-server/src/ch-thread-history.ts +++ b/packages/web-server/src/ch-thread-history.ts @@ -32,7 +32,7 @@ export class ChThreadHistory extends ChThreadView { private readonly pagination: { cursor: string | null }; constructor( - private readonly host: ChHostClient, + private readonly host: Pick, thread: ChThread, harnessId: string, onProjection: ProjectionListener, diff --git a/packages/web-server/src/ch-thread-view.ts b/packages/web-server/src/ch-thread-view.ts index 72bb24a09..13004e8ce 100644 --- a/packages/web-server/src/ch-thread-view.ts +++ b/packages/web-server/src/ch-thread-view.ts @@ -41,6 +41,7 @@ export interface ChThread { id: string; cwd: string; modelProvider: string; + harnessIdentity?: { id: string; name: string } | undefined; workspaceKind?: "projectless"; projectId?: string | null; name?: string | null | undefined; diff --git a/packages/web-server/src/remote-sessions.ts b/packages/web-server/src/remote-sessions.ts index 9f8eacd9e..2e26f9b54 100644 --- a/packages/web-server/src/remote-sessions.ts +++ b/packages/web-server/src/remote-sessions.ts @@ -9,6 +9,7 @@ import { type RemoteSessionOperation, } from "@codexhost/shared-contracts"; import { ChThreadView, type ChThread } from "./ch-thread-view.ts"; +import { ChThreadHistory } from "./ch-thread-history.ts"; import { defaultProjections } from "./session-log.ts"; import { requestOf } from "./store.ts"; import { @@ -50,6 +51,7 @@ const rowSchema = z id: z.string().min(1), cwd: z.string(), modelProvider: z.string(), + harnessIdentity: z.object({ id: z.string().min(1), name: z.string().min(1) }).optional(), createdAt: z.number(), updatedAt: z.number(), status: z.object({ type: z.string() }), @@ -169,8 +171,10 @@ export class RemoteSessions { ); try { return await this.call(parsed.data); - } catch { - const write = !["hosts", "list", "read"].includes(operation.kind); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === -32094) + throw new RpcError("remote/too-large", error.message); + const write = !["hosts", "list", "read", "turns"].includes(operation.kind); throw new RpcError( write ? "remote/outcome-unknown" : "remote/offline", write @@ -308,7 +312,9 @@ export class RemoteSessions { }, ...(entry.row.modelProvider !== "codexhost" ? { harnessIdentity: { id: "codex", name: "Codex" } } - : {}), + : entry.row.harnessIdentity + ? { harnessIdentity: entry.row.harnessIdentity } + : {}), threadReference: this.reference(entry), nativeConnection: { state: entry.online ? "connected" : "reconnecting" }, nativeDiscovery: false, @@ -349,7 +355,13 @@ export class RemoteSessions { current.reading ??= (async () => { try { const snapshot = snapshotSchema.parse( - await this.invoke({ kind: "read", ...identity }), + await this.invoke({ + kind: "read", + ...identity, + ...(current.row.historyMode === "paginated" || current.view instanceof ChThreadHistory + ? { metadataOnly: true } + : {}), + }), ) as Snapshot; if (this.closed || snapshot.result.thread.id !== identity.threadId) throw new Error("Remote identity mismatch"); @@ -359,11 +371,20 @@ export class RemoteSessions { } current.epoch = snapshot.epoch; current.online = true; - current.row = snapshot.result.thread; + current.row = { + ...snapshot.result.thread, + ...(snapshot.result.thread.modelProvider === "codexhost" && current.row.harnessIdentity + ? { + harnessIdentity: + snapshot.result.thread.harnessIdentity ?? current.row.harnessIdentity, + } + : {}), + }; const row = { ...current.row, id, cwd: "" }; - current.view ??= this.createView(row, current.viewOrigin); + current.view ??= this.createView(row, current.viewOrigin, current); try { - current.view.update(row); + if (current.view instanceof ChThreadHistory) await current.view.refresh(); + else current.view.update(row); } catch (error) { if ( !(error instanceof RpcError) || @@ -371,9 +392,12 @@ export class RemoteSessions { ) throw error; this.retireView(current); - current.view = this.createView(row, current.viewOrigin); - current.view.update(row); + current.view = this.createView(row, current.viewOrigin, current); + if (current.view instanceof ChThreadHistory) await current.view.refresh(); + else current.view.update(row); } + if (current.view instanceof ChThreadHistory) + current.row = { ...current.row, turns: current.view.thread.turns }; for (const [key, value] of Object.entries(this.summary(id, current).projections.values)) if ( [ @@ -404,7 +428,8 @@ export class RemoteSessions { ); return current.view; } catch (error) { - this.offline(current); + if (!(error instanceof RpcError) || error.code !== "remote/too-large") + this.offline(current); throw error; } })().finally(() => { @@ -419,18 +444,38 @@ export class RemoteSessions { entry.view.log.reconnectFollowers(); delete entry.view; } - private createView(row: ChThread, origin = 0): ChThreadView { - const view = new ChThreadView( - row, - row.modelProvider === "codexhost" ? "remote" : "codex", - (sessionId, key, value, seq) => { - view.log.pushProjection(key, value, seq); - for (const sink of this.controls) - sink.push({ type: "projection", sessionId, key, value, seq }); - }, - 0, - origin, - ); + private createView(row: ChThread, origin: number | undefined, entry: Entry): ChThreadView { + const onProjection = (sessionId: string, key: string, value: unknown, seq: number) => { + view.log.pushProjection(key, value, seq); + for (const sink of this.controls) + sink.push({ type: "projection", sessionId, key, value, seq }); + }; + const harness = row.modelProvider === "codexhost" ? "remote" : "codex"; + const view = + row.historyMode === "paginated" + ? new ChThreadHistory( + { + request: async (method: string, params: Record): Promise => { + if (params.threadId !== row.id) + throw new RpcError("remote/identity-invalid", "Remote history identity mismatch"); + if (method === "thread/read") + return { thread: { ...entry.row, id: row.id, cwd: "", turns: [] } } as T; + if (method !== "thread/turns/list" || !entry.epoch) + throw new RpcError("remote/unsupported", "Remote history operation unavailable"); + return (await this.invoke({ + kind: "turns", + ...entry.identity, + epoch: entry.epoch, + cursor: typeof params.cursor === "string" ? params.cursor : null, + })) as T; + }, + }, + row, + harness, + onProjection, + origin, + ) + : new ChThreadView(row, harness, onProjection, 0, origin ?? 0); return view; } private interactions(id: string, entry: Entry, requests: PendingRequest[]): void { @@ -582,12 +627,19 @@ export class RemoteSessions { const cached = readOnly ? this.entries.get(id)?.view : undefined; const view = cached ?? (await this.read(id)); if (method === "session/projections") return view.log.projectionBaseline(); - if (method === "session/page") + if (method === "session/page") { + if (view instanceof ChThreadHistory) + return view.olderPage( + Number(request.throughSeq), + typeof request.beforeSeq === "number" ? request.beforeSeq : undefined, + typeof request.maxMessages === "number" ? request.maxMessages : 20, + ); return view.log.page( Number(request.throughSeq), typeof request.beforeSeq === "number" ? request.beforeSeq : undefined, { maxMessages: typeof request.maxMessages === "number" ? request.maxMessages : 20 }, ); + } if (method === "session/toolOutput") return readToolOutput(view.log, webToolOutputReferenceSchema.parse(request)); const entry = this.entries.get(id); diff --git a/packages/web-server/test/browser/remote-sessions.test.ts b/packages/web-server/test/browser/remote-sessions.test.ts index 5af17b764..9c9ffa9eb 100644 --- a/packages/web-server/test/browser/remote-sessions.test.ts +++ b/packages/web-server/test/browser/remote-sessions.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { it } from "node:test"; import { createServer } from "node:net"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { chromium } from "playwright"; import { startServer } from "../support/server.ts"; +import { FakeChHost, startFakeChDebugger } from "../support/ch-host.ts"; it( "shows colliding remote sessions in the real Web shell and opens their own transcript", @@ -16,6 +17,7 @@ it( const nonce = "a".repeat(32); const calls: Array<{ kind: string; hostId?: string }> = []; const sent = new Map(); + const sentTurns = new Map>(); const archived = new Set(); const requestIds = new Map(); const timestamp = Math.floor(Date.now() / 1000); @@ -23,9 +25,13 @@ it( id: "same", cwd: "/remote/private", name: `Session ${hostId}`, - modelProvider: "openai", + modelProvider: hostId.startsWith("remote-ssh") ? "codexhost" : "openai", + ...(hostId.startsWith("remote-ssh") + ? { harnessIdentity: { id: "claude-code", name: "Claude Code" } } + : {}), + historyMode: hostId.startsWith("remote-ssh") ? "paginated" : "legacy", createdAt: timestamp, - updatedAt: timestamp + (sent.has(hostId) ? 1 : 0), + updatedAt: timestamp + (sentTurns.get(hostId)?.length ?? 0), status: { type: "idle" }, turns: [ { @@ -50,27 +56,23 @@ it( }, ], }, - ...(sent.has(hostId) - ? [ - { - id: "follow-up", - status: "completed", - items: [ - { - id: "follow-user", - type: "userMessage", - clientId: requestIds.get(hostId), - content: [{ type: "text", text: sent.get(hostId) }], - }, - { - id: "follow-answer", - type: "agentMessage", - text: `Follow-up accepted on ${hostId}`, - }, - ], - }, - ] - : []), + ...(sentTurns.get(hostId) ?? []).map((turn, index) => ({ + id: `follow-up-${index}`, + status: "completed", + items: [ + { + id: `follow-user-${index}`, + type: "userMessage", + clientId: turn.requestId, + content: [{ type: "text", text: turn.text }], + }, + { + id: `follow-answer-${index}`, + type: "agentMessage", + text: `Follow-up accepted on ${hostId}${index ? ` #${index + 1}` : ""}`, + }, + ], + })), ], }); const controller = createServer((socket) => { @@ -89,6 +91,9 @@ it( if (operation.kind === "prompt") { sent.set(operation.hostId, operation.text); requestIds.set(operation.hostId, operation.requestId); + const history = sentTurns.get(operation.hostId) ?? []; + history.push({ text: operation.text, requestId: operation.requestId }); + sentTurns.set(operation.hostId, history); } const result = operation.kind === "hosts" @@ -105,11 +110,17 @@ it( } : operation.kind === "read" ? { - result: { thread: row(operation.hostId) }, + result: { + thread: operation.metadataOnly + ? { ...row(operation.hostId), turns: [] } + : row(operation.hostId), + }, epoch: "fixture-epoch", requests: [], } - : {}; + : operation.kind === "turns" + ? { data: [...row(operation.hostId).turns].reverse(), nextCursor: null } + : {}; socket.end(JSON.stringify({ result }) + "\n"); }); }); @@ -156,12 +167,33 @@ it( ], }), ); + const localHost = new FakeChHost(); + const nativeRequest = localHost.request.bind(localHost); + localHost.request = async (method: string, params: Record): Promise => { + const result = await nativeRequest(method, params); + if (method !== "codexhost/harness/plugins/list") return result; + const manifest = JSON.parse( + readFileSync( + resolve(import.meta.dirname, "../../../adapters/claude-code/manifest.json"), + "utf8", + ), + ); + return { + plugins: [ + ...(result as { plugins: unknown[] }).plugins, + { id: "claude-code", name: "Claude Code", version: "1", iconStyle: manifest.iconStyle }, + ], + } as T; + }; + const debug = await startFakeChDebugger(localHost); + t.after(() => debug.close()); const url = await startServer( t, resolve(import.meta.dirname, "../.."), ["--import", "tsx", "src/main.ts"], - [], + ["--ch-cdp", debug.endpoint], { + authenticated: true, environment: { HOME: directory, USERPROFILE: directory, @@ -171,8 +203,49 @@ it( }, }, ); - const browser = await chromium.launch(); + const browser = await chromium.launch({ + ...(process.env.CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH + ? { executablePath: process.env.CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH } + : {}), + }); t.after(() => browser.close()); + const phone = await browser.newPage({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + locale: "en-US", + }); + await phone.goto(url); + for (const hostId of ["remote-ssh-discovered:a", "remote-control:b"]) { + await phone.getByRole("button", { name: "Open sidebar", exact: true }).first().click(); + const group = phone + .locator('[data-row-key^="workspace:remote-workspace:"]') + .filter({ has: phone.locator(`[data-host-id="${hostId}"]`) }); + await group.waitFor(); + if ((await group.getAttribute("aria-expanded")) === "false") await group.click(); + const item = phone.getByText(`Session ${hostId}`, { exact: true }).first(); + await item.waitFor(); + if (hostId.startsWith("remote-ssh")) + await item + .locator("xpath=ancestor::*[@data-row-key][1]") + .locator('[data-harness-id="claude-code"] svg') + .waitFor(); + await item.click(); + await phone.getByText(`Answer from ${hostId}`, { exact: true }).waitFor(); + } + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await phone.getByRole("button", { name: "Open sidebar", exact: true }).first().click(); + await phone.waitForFunction(() => { + const drawer = document.querySelector("[data-phone-drawer]"); + return drawer && drawer.getBoundingClientRect().left >= -1; + }); + await phone.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, "remote-sessions-phone.png"), + animations: "disabled", + }); + } + await phone.close(); const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: "en-US" }); const localMediaRequests: string[] = []; await page.route("**/api/file?**", async (route) => { @@ -224,6 +297,15 @@ it( 0, "remote image paths must render as text, not local image elements", ); + const row = item.locator("xpath=ancestor::*[@data-row-key][1]"); + if (hostId.startsWith("remote-ssh")) { + const icon = row.locator('[data-harness-id="claude-code"]'); + await icon.waitFor(); + assert.equal(await icon.locator("svg").getAttribute("viewBox"), "0 0 24 24"); + assert.equal(await row.locator("[data-native-codex-icon]").count(), 0); + } else { + assert.equal(await row.locator("[data-native-codex-icon]").count(), 1); + } } assert.deepEqual(localMediaRequests, [], "remote paths must not request local image bytes"); assert.ok( @@ -258,6 +340,24 @@ it( assert.equal(sent.has("remote-ssh-discovered:a"), false); assert.equal(calls.filter((call) => call.kind === "prompt").length, 1); assert.equal(await page.getByText("remote follow-up", { exact: true }).count(), 1); + for (const [hostId, text] of [ + ["remote-ssh-discovered:a", "second conversation"], + ["remote-control:b", "back to first conversation"], + ] as const) { + const nextCount = (sentTurns.get(hostId)?.length ?? 0) + 1; + await page.getByText(`Session ${hostId}`, { exact: true }).first().click(); + await page.getByText(`Answer from ${hostId}`, { exact: true }).waitFor(); + await page.locator('[contenteditable="true"][role="textbox"]').fill(text); + await page.getByRole("button", { name: /Send message|发送消息/ }).click(); + await page + .getByText(`Follow-up accepted on ${hostId}${nextCount > 1 ? ` #${nextCount}` : ""}`, { + exact: true, + }) + .waitFor(); + } + assert.equal(calls.filter((call) => call.kind === "prompt").length, 3); + assert.equal(sent.get("remote-ssh-discovered:a"), "second conversation"); + assert.equal(sent.get("remote-control:b"), "back to first conversation"); const remoteId = "web-remote:" + Buffer.from(JSON.stringify(["remote-control:b", "same"])).toString("base64url"); @@ -271,7 +371,10 @@ it( await remoteRow.waitFor(); assert.equal(archived.size, 0); await remoteRow.click(); - await page.getByText("Follow-up accepted on remote-control:b", { exact: true }).waitFor(); + await page + .getByText("Follow-up accepted on remote-control:b", { exact: true }) + .last() + .waitFor(); assert.deepEqual( calls.filter((call) => call.kind === "archive").map((call) => call.hostId), ["remote-control:b", "remote-control:b"], diff --git a/packages/web-server/test/remote-history.test.ts b/packages/web-server/test/remote-history.test.ts new file mode 100644 index 000000000..631cf8b94 --- /dev/null +++ b/packages/web-server/test/remote-history.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { RemoteSessions, remoteSessionId } from "../src/remote-sessions.ts"; +import { ChThreadHistory } from "../src/ch-thread-history.ts"; +import { EventHub, RpcRegistry, StreamRegistry } from "../src/transport.ts"; +import type { RemoteSessionOperation } from "@codexhost/shared-contracts"; + +it("opens and pages a long remote external Thread without full-history reads", async (t) => { + const hostId = "remote-ssh-discovered:a", + threadId = "long-46"; + const row = { + id: threadId, + cwd: "/remote", + modelProvider: "codexhost", + createdAt: 1, + updatedAt: 2, + status: { type: "idle" }, + historyMode: "paginated", + turns: [], + }; + const calls: RemoteSessionOperation[] = []; + let losePage = false; + const turn = (id: string) => ({ + id, + status: "completed", + items: [{ id: id + "-answer", type: "agentMessage", text: id }], + }); + const remote = new RemoteSessions(new EventHub("history"), async (operation) => { + calls.push(operation); + if (operation.kind === "hosts") return [{ hostId, online: true }]; + if (operation.kind === "list") return { data: [row], nextCursor: null }; + if (operation.kind === "read") { + assert.equal(operation.metadataOnly, true); + return { result: { thread: row }, epoch: "connection-a", requests: [] }; + } + if (operation.kind === "turns") { + assert.equal(operation.threadId, threadId); + assert.equal(operation.epoch, "connection-a"); + if (losePage) throw new Error("Lost read reply"); + return operation.cursor === null + ? { data: [turn("newest"), turn("middle")], nextCursor: "older" } + : { data: [turn("oldest")], nextCursor: null }; + } + throw new Error("Unexpected mutation"); + }); + t.after(() => remote.close()); + const rpc = new RpcRegistry(); + rpc.register("session/page", () => { + throw new Error("Remote history must not route locally"); + }); + remote.install(rpc, new StreamRegistry()); + await remote.refresh(); + const id = remoteSessionId(hostId, threadId); + const view = await remote.read(id); + assert.ok(view instanceof ChThreadHistory); + assert.deepEqual( + view.thread.turns.map((turn) => turn.id), + ["middle", "newest"], + ); + losePage = true; + await assert.rejects( + view.loadOlder(), + (error) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "remote/offline", + ); + losePage = false; + const result = await rpc.dispatch("session/page", { + args: { + request: { + sessionId: id, + throughSeq: view.log.lastSeq, + beforeSeq: view.log.firstSeq, + maxMessages: 20, + }, + }, + }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.deepEqual( + view.thread.turns.map((turn) => turn.id), + ["oldest", "middle", "newest"], + ); + assert.equal( + calls.filter((operation) => operation.kind === "turns" && operation.cursor === "older").length, + 2, + ); +}); + +it("a rejected oversized history page does not mark the remote Host offline", async (t) => { + const hostId = "remote-ssh-discovered:fixture"; + let oversized = false; + const row = { + id: "thread", + cwd: "/fixture", + modelProvider: "openai", + historyMode: "paginated", + status: { type: "idle" }, + createdAt: 1, + updatedAt: 1, + turns: [], + }; + const remote = new RemoteSessions(new EventHub("size-fixture"), async (operation) => { + if (operation.kind === "hosts") return [{ hostId, online: true }]; + if (operation.kind === "list") return { data: [row], nextCursor: null }; + if (operation.kind === "read") + return { result: { thread: row }, epoch: "stable", requests: [] }; + if (operation.kind === "turns") { + if (oversized) + throw Object.assign(new Error("Remote history response exceeds the 16 MiB limit"), { + code: -32094, + }); + return { data: [{ id: "turn", status: "completed", items: [] }], nextCursor: null }; + } + throw new Error("Unexpected fixture operation"); + }); + t.after(() => remote.close()); + const rpc = new RpcRegistry(); + rpc.register("session/projections", () => null); + remote.install(rpc, new StreamRegistry()); + await remote.refresh(); + const id = remoteSessionId(hostId, "thread"); + await remote.read(id); + oversized = true; + row.updatedAt += 1; + await assert.rejects( + remote.read(id), + (error) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "remote/too-large", + ); + const result = await rpc.dispatch("session/projections", { + args: { request: { sessionId: id } }, + }); + assert.equal(result.ok, true); + if (!result.ok) throw Error("Projection fixture failed"); + const values = (result.value as { values: Record }).values; + assert.equal(values.nativeConnection?.state, "connected"); +}); diff --git a/packages/web-server/test/remote-sessions.test.ts b/packages/web-server/test/remote-sessions.test.ts index 8a7f21f28..d56dbc92b 100644 --- a/packages/web-server/test/remote-sessions.test.ts +++ b/packages/web-server/test/remote-sessions.test.ts @@ -11,6 +11,70 @@ import { join } from "node:path"; import { DataDir } from "../src/store.ts"; import { Workspaces } from "../src/workspaces.ts"; +it("keeps remote Harness identity in sidebar summaries and after opening metadata-only history", async (t) => { + const identity = { id: "claude-code", name: "Claude Code" }; + const rows = ["external", "native"].map((id) => ({ + id, + cwd: "/remote", + name: id, + modelProvider: id === "external" ? "codexhost" : "custom", + createdAt: 1, + updatedAt: 2, + status: { type: "idle" }, + turns: [], + })); + const events = new EventHub("icons"), + rpc = new RpcRegistry(); + rpc.register("session/list", () => ({ items: [] })); + rpc.register("session/projections", () => { + throw Error("Must not route locally"); + }); + const remote = new RemoteSessions(events, async (op) => { + if (op.kind === "hosts") return [{ hostId: "remote-control:a", online: true }]; + if (op.kind === "list") + return { + data: rows.map((row) => + row.id === "external" ? { ...row, harnessIdentity: identity } : row, + ), + nextCursor: null, + }; + if (op.kind === "read") + return { + result: { thread: rows.find((row) => row.id === op.threadId) }, + epoch: "epoch", + requests: [], + }; + throw Error("No model sends or mutations are expected"); + }); + t.after(() => remote.close()); + remote.install(rpc, new StreamRegistry()); + const call = (method: string, request: object = {}) => + rpc.dispatch(method, { args: { request } }); + await remote.refresh(); + const result = await call("session/list"); + assert.ok(result.ok); + const items = ( + result.value as { + items: Array<{ sessionId: string; projections: { values: Record } }>; + } + ).items; + const id = remoteSessionId("remote-control:a", "external"); + assert.deepEqual( + items.find((row) => row.sessionId === id)?.projections.values.harnessIdentity, + identity, + ); + assert.deepEqual(items.find((row) => row.sessionId !== id)?.projections.values.harnessIdentity, { + id: "codex", + name: "Codex", + }); + const opened = await call("session/projections", { sessionId: id }); + assert.ok(opened.ok); + assert.deepEqual( + (opened.value as { values: Record }).values.harnessIdentity, + identity, + ); +}); + it("archives and undoes through workspace RPCs without losing local archives or other hosts", async (t) => { const directory = mkdtempSync(join(tmpdir(), "remote-archive-")); t.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/tests/release/desktop-controller-bundle.test.mjs b/tests/release/desktop-controller-bundle.test.mjs index f1797d895..cab636c92 100644 --- a/tests/release/desktop-controller-bundle.test.mjs +++ b/tests/release/desktop-controller-bundle.test.mjs @@ -1,4 +1,5 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; @@ -59,6 +60,14 @@ describe("Desktop Controller release Bundle", () => { expect(source).not.toContain('process.stdout.write("ready\\n")'); expect(source).not.toContain("tools/renderer-binding"); expect(source).not.toContain("@anthropic-ai/"); + const startup = spawnSync(process.execPath, [outputPath, "--codexhost-bundle-check"], { + encoding: "utf8", + timeout: 5_000, + windowsHide: true, + }); + expect(startup.status).toBe(1); + expect(startup.stderr).toContain("unknown Desktop Controller option"); + expect(startup.stderr).not.toContain("Dynamic require"); } finally { await rm(directory, { recursive: true, force: true }); }