From 4c844114ab360cd0cd5b0c1343966244a200ccc8 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 16 Oct 2026 14:58:26 +0000 Subject: [PATCH 1/2] fix(host-runtime): answer External Thread settings and list requests from non-Desktop clients Clients without the codexhost renderer, such as the ChatGPT iOS app over SSH, call thread/settings/update before turn/start and list attachments and queued submissions for every open Thread. The Host rejected these for External Threads with -32076, which aborted every send from those clients. Answer them with the empty official response shape instead. Settings updates are acknowledged but not applied; the Harness keeps its own permission mode. Refs #543 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../official-traffic-ownership.md | 1 + packages/host-runtime/src/app-server-host.ts | 6 ++- .../src/external-thread-official-defaults.ts | 19 +++++++ ...er-host.external-official-defaults.test.ts | 54 +++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 packages/host-runtime/src/external-thread-official-defaults.ts create mode 100644 packages/host-runtime/test/app-server-host.external-official-defaults.test.ts diff --git a/docs/architecture/official-traffic-ownership.md b/docs/architecture/official-traffic-ownership.md index 10590da79..a910974f8 100644 --- a/docs/architecture/official-traffic-ownership.md +++ b/docs/architecture/official-traffic-ownership.md @@ -18,6 +18,7 @@ Host 夹在 Codex Desktop 与官方 app-server 之间。官方协议的方法和 - `thread/list`:Host 能完整解码时合并外部 Thread;解码失败且不带 Host 游标时整条转发官方,此页只显示官方结果。 - `thread/section/move`:Host 维护跨所有者的分区顺序;参数无法解码时,`threadId` 或 `beforeThreadId` 指向外部 Thread 则报错,其他原样转发。 - `thread/archive`、`thread/unarchive`、`thread/metadata/update` 及 `turn/*`、`thread/read` 等:先按 `threadId` 判定归属,官方 Thread 原样转发,外部 Thread 才解码参数。 +- 其他带外部 `threadId` 的 `thread/*` 请求返回 `-32076`。例外是 `thread/attachment/list`、`thread/queue/list`、`thread/settings/update`:不带 codexhost 渲染扩展的官方客户端(如 SSH 连接的 ChatGPT iOS app)打开 Thread 或发送前都会调用它们,因此按官方结构返回空结果。`thread/settings/update` 只确认、不应用,权限仍由 Harness 自己的权限模式决定。 ## 已知例外 diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index cd8bc38a6..4db3b8d7f 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -259,6 +259,7 @@ import { } from "./thread-list-aggregator.js"; import { listSectionThreads, moveThreadSection } from "./external-thread-sections.js"; import { externalThreadListEntries } from "./external-thread-list.js"; +import { externalThreadEmptyOfficialResult } from "./external-thread-official-defaults.js"; import { ModelPriceCatalog } from "./model-prices.js"; import { ObservedSessionUsage } from "./observed-session-usage.js"; import type { UsageStatistics, UsageStatisticsSource } from "./usage-statistics.js"; @@ -2090,8 +2091,11 @@ export class AppServerHost { const location = await this.#locateExternalThread(request.params.threadId); if (await this.#writeResolutionError(request, location)) return; if (location.kind === "external") { + const emptyResult = externalThreadEmptyOfficialResult(request.method); await this.#writer.json( - rpcError(request, -32076, `External Thread does not support ${request.method}`), + emptyResult + ? rpcEnvelope(request, { result: emptyResult }) + : rpcError(request, -32076, `External Thread does not support ${request.method}`), ); return; } diff --git a/packages/host-runtime/src/external-thread-official-defaults.ts b/packages/host-runtime/src/external-thread-official-defaults.ts new file mode 100644 index 000000000..7ec6e0e38 --- /dev/null +++ b/packages/host-runtime/src/external-thread-official-defaults.ts @@ -0,0 +1,19 @@ +import type { JsonObject } from "@codexhost/protocol-core"; + +/** + * Official per-Thread requests that clients without the codexhost renderer (such as the ChatGPT + * iOS app over SSH) send around every open or send. External Threads have no official + * attachments or queued submissions, and their permissions belong to the Harness, so these answer + * with the empty official response shape rather than an error that aborts the client's flow. + * `thread/settings/update` is acknowledged without being applied; the Harness keeps its own + * permission mode. + */ +const EMPTY_RESULTS: ReadonlyMap JsonObject> = new Map([ + ["thread/attachment/list", () => ({ data: [], nextCursor: null })], + ["thread/queue/list", () => ({ data: [], nextCursor: null })], + ["thread/settings/update", () => ({})], +]); + +export function externalThreadEmptyOfficialResult(method: string): JsonObject | undefined { + return EMPTY_RESULTS.get(method)?.(); +} diff --git a/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts b/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts new file mode 100644 index 000000000..d99a84c11 --- /dev/null +++ b/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + JsonLineCollector, + createFixture, + requestId, + startPiThread, + stopFixture, + writeRequest, +} from "./app-server-host-fixture.js"; + +describe("External Thread official per-Thread defaults", () => { + it("answers official list and settings requests with empty results instead of errors", async () => { + const fixture = createFixture(); + const official = new JsonLineCollector(fixture.official.stdin); + const threadId = await startPiThread(fixture); + + const requests = [ + { id: 40, method: "thread/attachment/list", params: { threadId, limit: 100 } }, + { id: 41, method: "thread/queue/list", params: { threadId, limit: 100 } }, + { + id: 42, + method: "thread/settings/update", + params: { + threadId, + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + { id: 43, method: "thread/future/manage", params: { threadId } }, + ]; + for (const request of requests) writeRequest(fixture.desktopInput, request); + + await expect( + fixture.collector.waitFor((message) => requestId(message, 40)), + ).resolves.toMatchObject({ result: { data: [], nextCursor: null } }); + await expect( + fixture.collector.waitFor((message) => requestId(message, 41)), + ).resolves.toMatchObject({ result: { data: [], nextCursor: null } }); + const settings = await fixture.collector.waitFor((message) => requestId(message, 42)); + expect(settings.result).toEqual({}); + expect(settings.error).toBeUndefined(); + await expect( + fixture.collector.waitFor((message) => requestId(message, 43)), + ).resolves.toMatchObject({ + error: { code: -32076, message: "External Thread does not support thread/future/manage" }, + }); + + const handled = new Set(requests.map((request) => request.method)); + expect(official.messages.some((message) => handled.has(String(message.method)))).toBe(false); + await stopFixture(fixture); + }); +}); From 8cc4e5d21be50f105b3f66f7cff05f1e83b760ad Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 17 Oct 2026 22:46:29 +0800 Subject: [PATCH 2/2] fix: limit external settings acknowledgements to neutral defaults Accept only the neutral Codex defaults sent before mobile submissions, leaving native Harness permissions unchanged. Reject restrictive policies and other settings rather than acknowledging unapplied changes. Verify default acknowledgements allow a subsequent Turn, preserve native Codex passthrough, and reject permission, model, cwd and unknown setting changes. --- .../official-traffic-ownership.md | 2 +- packages/host-runtime/src/app-server-host.ts | 2 +- .../src/external-thread-official-defaults.ts | 33 +++++++++- ...er-host.external-official-defaults.test.ts | 65 ++++++++++++++++++- ...p-server-host.official-passthrough.test.ts | 20 ++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/docs/architecture/official-traffic-ownership.md b/docs/architecture/official-traffic-ownership.md index a910974f8..7fd11754e 100644 --- a/docs/architecture/official-traffic-ownership.md +++ b/docs/architecture/official-traffic-ownership.md @@ -18,7 +18,7 @@ Host 夹在 Codex Desktop 与官方 app-server 之间。官方协议的方法和 - `thread/list`:Host 能完整解码时合并外部 Thread;解码失败且不带 Host 游标时整条转发官方,此页只显示官方结果。 - `thread/section/move`:Host 维护跨所有者的分区顺序;参数无法解码时,`threadId` 或 `beforeThreadId` 指向外部 Thread 则报错,其他原样转发。 - `thread/archive`、`thread/unarchive`、`thread/metadata/update` 及 `turn/*`、`thread/read` 等:先按 `threadId` 判定归属,官方 Thread 原样转发,外部 Thread 才解码参数。 -- 其他带外部 `threadId` 的 `thread/*` 请求返回 `-32076`。例外是 `thread/attachment/list`、`thread/queue/list`、`thread/settings/update`:不带 codexhost 渲染扩展的官方客户端(如 SSH 连接的 ChatGPT iOS app)打开 Thread 或发送前都会调用它们,因此按官方结构返回空结果。`thread/settings/update` 只确认、不应用,权限仍由 Harness 自己的权限模式决定。 +- 其他带外部 `threadId` 的 `thread/*` 请求返回 `-32076`。`thread/attachment/list`、`thread/queue/list` 按官方结构返回空列表,兼容不带 codexhost 渲染扩展的客户端。`thread/settings/update` 只接受客户端不附加 Codex 限制的默认同步参数:`approvalPolicy: "never"`、`approvalsReviewer: "user"`、`sandboxPolicy: { type: "dangerFullAccess" }`;这三个字段可以省略或为 `null`。回应不改变 Harness 的原生权限模式。其他设置字段、限制性审批或沙箱策略仍返回 `-32076`,不把未应用的设置变更报告为成功。原生 Codex Thread 的这些请求继续原样转发。 ## 已知例外 diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index 4db3b8d7f..5547269c5 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -2091,7 +2091,7 @@ export class AppServerHost { const location = await this.#locateExternalThread(request.params.threadId); if (await this.#writeResolutionError(request, location)) return; if (location.kind === "external") { - const emptyResult = externalThreadEmptyOfficialResult(request.method); + const emptyResult = externalThreadEmptyOfficialResult(request.method, request.params); await this.#writer.json( emptyResult ? rpcEnvelope(request, { result: emptyResult }) diff --git a/packages/host-runtime/src/external-thread-official-defaults.ts b/packages/host-runtime/src/external-thread-official-defaults.ts index 7ec6e0e38..89a8fb76b 100644 --- a/packages/host-runtime/src/external-thread-official-defaults.ts +++ b/packages/host-runtime/src/external-thread-official-defaults.ts @@ -5,8 +5,8 @@ import type { JsonObject } from "@codexhost/protocol-core"; * iOS app over SSH) send around every open or send. External Threads have no official * attachments or queued submissions, and their permissions belong to the Harness, so these answer * with the empty official response shape rather than an error that aborts the client's flow. - * `thread/settings/update` is acknowledged without being applied; the Harness keeps its own - * permission mode. + * Settings acknowledgements accept only neutral Codex client defaults. Native Harness + * permissions stay authoritative; actual settings changes remain unsupported. */ const EMPTY_RESULTS: ReadonlyMap JsonObject> = new Map([ ["thread/attachment/list", () => ({ data: [], nextCursor: null })], @@ -14,6 +14,33 @@ const EMPTY_RESULTS: ReadonlyMap JsonObject> = new Map([ ["thread/settings/update", () => ({})], ]); -export function externalThreadEmptyOfficialResult(method: string): JsonObject | undefined { +function isNeutralClientSettings(params: JsonObject): boolean { + return Object.entries(params).every(([key, value]) => { + switch (key) { + case "threadId": + return true; + case "approvalPolicy": + return value === null || value === "never"; + case "approvalsReviewer": + return value === null || value === "user"; + case "sandboxPolicy": + return ( + value === null || + (typeof value === "object" && + !Array.isArray(value) && + value.type === "dangerFullAccess" && + Object.keys(value).length === 1) + ); + default: + return false; + } + }); +} + +export function externalThreadEmptyOfficialResult( + method: string, + params: JsonObject, +): JsonObject | undefined { + if (method === "thread/settings/update" && !isNeutralClientSettings(params)) return undefined; return EMPTY_RESULTS.get(method)?.(); } diff --git a/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts b/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts index d99a84c11..cec7e2b24 100644 --- a/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts +++ b/packages/host-runtime/test/app-server-host.external-official-defaults.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; +import type { JsonObject } from "@codexhost/protocol-core"; +import { describe, expect, it, vi } from "vitest"; import { JsonLineCollector, + completePiTurn, createFixture, requestId, startPiThread, @@ -14,6 +16,9 @@ describe("External Thread official per-Thread defaults", () => { const fixture = createFixture(); const official = new JsonLineCollector(fixture.official.stdin); const threadId = await startPiThread(fixture); + const session = fixture.adapter.sessions[0]; + if (!session) throw new Error("External Session was not opened"); + const execute = vi.spyOn(session, "execute"); const requests = [ { id: 40, method: "thread/attachment/list", params: { threadId, limit: 100 } }, @@ -49,6 +54,64 @@ describe("External Thread official per-Thread defaults", () => { const handled = new Set(requests.map((request) => request.method)); expect(official.messages.some((message) => handled.has(String(message.method)))).toBe(false); + expect(execute).not.toHaveBeenCalled(); + await completePiTurn(fixture, threadId, 44); await stopFixture(fixture); }); + + it.each([ + { approvalPolicy: "on-request" }, + { approvalsReviewer: "auto_review" }, + { sandboxPolicy: { type: "readOnly" } }, + { sandboxPolicy: { type: "workspaceWrite", writableRoots: [] } }, + { sandboxPolicy: { type: "dangerFullAccess", extraRestriction: true } }, + { model: "other-model" }, + { cwd: "/different-workspace" }, + { effort: "low" }, + { serviceTier: null }, + { futureSetting: true }, + ])("rejects settings that are not neutral client defaults: %j", async (settings) => { + const fixture = createFixture(); + const official = new JsonLineCollector(fixture.official.stdin); + try { + const threadId = await startPiThread(fixture); + const session = fixture.adapter.sessions[0]; + if (!session) throw new Error("External Session was not opened"); + const execute = vi.spyOn(session, "execute"); + writeRequest(fixture.desktopInput, { + id: 40, + method: "thread/settings/update", + params: { threadId, ...settings }, + }); + await expect( + fixture.collector.waitFor((message) => requestId(message, 40)), + ).resolves.toMatchObject({ error: { code: -32076 } }); + expect(execute).not.toHaveBeenCalled(); + expect(official.messages.some((message) => message.method === "thread/settings/update")).toBe( + false, + ); + } finally { + await stopFixture(fixture); + } + }); + + it.each([{}, { approvalPolicy: null, approvalsReviewer: null, sandboxPolicy: null }])( + "accepts settings requests that leave all defaults unchanged: %j", + async (settings) => { + const fixture = createFixture(); + try { + const threadId = await startPiThread(fixture); + writeRequest(fixture.desktopInput, { + id: 40, + method: "thread/settings/update", + params: { threadId, ...settings }, + }); + await expect( + fixture.collector.waitFor((message) => requestId(message, 40)), + ).resolves.toMatchObject({ result: {} }); + } finally { + await stopFixture(fixture); + } + }, + ); }); diff --git a/packages/host-runtime/test/app-server-host.official-passthrough.test.ts b/packages/host-runtime/test/app-server-host.official-passthrough.test.ts index beaf01696..c44ec0459 100644 --- a/packages/host-runtime/test/app-server-host.official-passthrough.test.ts +++ b/packages/host-runtime/test/app-server-host.official-passthrough.test.ts @@ -66,6 +66,26 @@ describe("official Codex passthrough", () => { { method: "turn/start" }, { method: "turn/interrupt", params: "future-params-shape" }, { method: "thread/read" }, + { method: "thread/attachment/list", params: { threadId: "official-thread", limit: 100 } }, + { method: "thread/queue/list", params: { threadId: "official-thread", limit: 100 } }, + { + method: "thread/settings/update", + params: { + threadId: "official-thread", + approvalPolicy: "never", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + { + method: "thread/settings/update", + params: { + threadId: "official-thread", + approvalPolicy: "on-request", + sandboxPolicy: { type: "readOnly" }, + model: "future-model", + futureSetting: true, + }, + }, { method: "future/method", params: { anything: [1, "two"] } }, ])("forwards an unrecognized native shape unchanged: $method", async (request) => { const fixture = createFixture();