diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 28ff470c0b..ede79d610b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d6db9a9541b0727b61dbe501f7234564ffef139e -README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d +README.md: 63294100cd0dc62f9822a3ca9678c1034880169f +README.zh.md: 251b4b0356da5f1fb518d133a92f484da957b951 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d6db9a9541..63294100cd 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod ## Contract layer (`/api`) -Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. +Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind. The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4175c8fdb9..251b4b0356 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -6,7 +6,7 @@ ## 契约层(`/api`) -协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。 +协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f0535dc939..8160f9d69a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -2,8 +2,8 @@ * Server side of the fetch carrier: maps an ApiProxy onto a pure * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method + * path==method) -> payload dispatched per method. HTTP status expresses only the carrier - * (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always - * 200 + ServerResponse. + * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash); + * business errors are always 200 + ServerResponse. */ import { randomUUID } from 'node:crypto' @@ -188,6 +188,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { return new Response('not found', { status: 404 }) } + // Cross-site write fence: browsers send "simple" POSTs (text/plain, + // form encodings) without a CORS preflight, so a malicious page could + // otherwise execute side-effectful RPCs blind — the response stays + // unreadable cross-origin, but session.prompt would still run. Only the + // JSON media type is accepted; anything else is forced into a preflight + // this server never answers. 415 = carrier layer, like the 400 below. + const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + let body: unknown try { body = await req.json() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2dbc962a00..0885b7b896 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -138,7 +138,7 @@ describe('unary round trip', () => { it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) }) + const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) expect(response.status).toBe(200) const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } expect(parsed.result.ok).toBe(false) @@ -149,13 +149,13 @@ describe('unary round trip', () => { it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) }) + const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) expect(noId.status).toBe(200) const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } expect(noIdParsed.result.ok).toBe(false) expect(noIdParsed.rpcId).toBe('invalid-request') // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) + const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } expect(withIdParsed.result.ok).toBe(false) expect(withIdParsed.rpcId).toBe('salvage-me') @@ -164,16 +164,34 @@ describe('unary round trip', () => { it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => { const handler = toFetchHandler(scriptedApi()) // Unknown method → 404. - const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' }) + const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(notFound.status).toBe(404) // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' }) + const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) expect(badBody.status).toBe(400) // Impl crash → 500, and through the client that is a throw, not an err result. const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } }) await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/) }) + it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { + const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] })) + const handler = toFetchHandler(scriptedApi({ sessions: { list } })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }) + // A "simple" browser POST (text/plain — sent with no CORS preflight) is + // refused at the carrier before the impl runs. + const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) + expect(plain.status).toBe(415) + // A string body with no explicit header defaults to text/plain — same fence. + const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body }) + expect(unlabelled.status).toBe(415) + expect(list).not.toHaveBeenCalled() + // Media-type parameters pass: the fence checks the type, not the exact string. + const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) + expect(charset.status).toBe(200) + expect(list).toHaveBeenCalledTimes(1) + }) + it('rejects when the transport never resolves within timeoutMs', async () => { // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast. const never = new InProcessApiClient({ @@ -421,7 +439,7 @@ describe('respond path', () => { it('returns bad-response for a malformed client-response without reaching the impl', async () => { const respond = vi.fn() const handler = toFetchHandler(scriptedApi({ respond })) - const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) }) + const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) }) expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' }) expect(respond).not.toHaveBeenCalled() }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 0e6dc8dc4a..318dbc970e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -223,7 +223,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) // The fake's /hang settles only when the invoke-level signal aborts: a // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal })) + const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) controller.abort() const response = await pending const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } @@ -248,7 +248,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const controller = new AbortController() const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { - method: 'POST', body, signal: controller.signal, + method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() const parsed = await (await pending).json() as { result: { error?: { code: string } } } @@ -260,18 +260,18 @@ describe('handler carrier-layer statuses', () => { const handler = toFetchHandler(fakeApi()) it('404s unknown paths and non-POST non-stream methods', async () => { - expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) }) it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) expect(response.status).toBe(400) }) it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) expect(response.status).toBe(200) const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } expect(body.rpcId).toBe('invalid-request') @@ -280,7 +280,7 @@ describe('handler carrier-layer statuses', () => { it('rejects a method/path mismatch echoing the envelope rpcId', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } expect(parsed.rpcId).toBe('r-9') expect(parsed.result.error?.message).toContain('does not match path') @@ -288,7 +288,7 @@ describe('handler carrier-layer statuses', () => { it('rejects an invalid payload with the zod issues attached', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) @@ -297,23 +297,23 @@ describe('handler carrier-layer statuses', () => { it('500s when the impl itself throws', async () => { const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' })) const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) expect(response.status).toBe(500) expect(await response.text()).toContain('impl crashed') }) it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => { const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } }) - const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json() + const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json() expect(goodReceipt).toEqual({ accepted: true }) const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} }) - const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json() + const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json() expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' }) }) it('accepts (url, init) form fetch invocation', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} }) - const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body }) + const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) expect(response.status).toBe(200) }) })