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
1 change: 1 addition & 0 deletions docs/architecture/official-traffic-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 按官方结构返回空列表,兼容不带 codexhost 渲染扩展的客户端。`thread/settings/update` 只接受客户端不附加 Codex 限制的默认同步参数:`approvalPolicy: "never"`、`approvalsReviewer: "user"`、`sandboxPolicy: { type: "dangerFullAccess" }`;这三个字段可以省略或为 `null`。回应不改变 Harness 的原生权限模式。其他设置字段、限制性审批或沙箱策略仍返回 `-32076`,不把未应用的设置变更报告为成功。原生 Codex Thread 的这些请求继续原样转发。

## 已知例外

Expand Down
6 changes: 5 additions & 1 deletion packages/host-runtime/src/app-server-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, request.params);
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;
}
Expand Down
46 changes: 46 additions & 0 deletions packages/host-runtime/src/external-thread-official-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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.
* Settings acknowledgements accept only neutral Codex client defaults. Native Harness
* permissions stay authoritative; actual settings changes remain unsupported.
*/
const EMPTY_RESULTS: ReadonlyMap<string, () => JsonObject> = new Map([
["thread/attachment/list", () => ({ data: [], nextCursor: null })],
["thread/queue/list", () => ({ data: [], nextCursor: null })],
["thread/settings/update", () => ({})],
]);

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)?.();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { JsonObject } from "@codexhost/protocol-core";
import { describe, expect, it, vi } from "vitest";

import {
JsonLineCollector,
completePiTurn,
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 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 } },
{ 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);
expect(execute).not.toHaveBeenCalled();
await completePiTurn(fixture, threadId, 44);
await stopFixture(fixture);
});

it.each<JsonObject>([
{ 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<JsonObject>([{}, { 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);
}
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading