From 694f8498aeb93ce267fc1308aa891e6b2b114e8e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 04:13:29 +0000 Subject: [PATCH 1/2] fix(app): support Codex opaque parent origins --- .changeset/codex-opaque-app-origin.md | 5 + packages/agent-bundle/src/app/index.ts | 4 +- .../agent-bundle/tests/app-client.test.ts | 106 ++++++++++++++---- packages/agent-bundle/tests/serve-app.test.ts | 10 +- website/docs/en/guide/authoring/mcp.mdx | 36 +++--- website/docs/en/reference/security.mdx | 22 ++-- website/docs/zh/guide/authoring/mcp.mdx | 27 +++-- website/docs/zh/reference/security.mdx | 18 +-- 8 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 .changeset/codex-opaque-app-origin.md diff --git a/.changeset/codex-opaque-app-origin.md b/.changeset/codex-opaque-app-origin.md new file mode 100644 index 000000000..6e54a9196 --- /dev/null +++ b/.changeset/codex-opaque-app-origin.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Allow `createAppClient()` to authenticate Codex MCP Apps with an opaque parent origin while preserving exact source and configured HTTP(S) origin pinning. (#731) diff --git a/packages/agent-bundle/src/app/index.ts b/packages/agent-bundle/src/app/index.ts index 435cc725b..05afde5ca 100644 --- a/packages/agent-bundle/src/app/index.ts +++ b/packages/agent-bundle/src/app/index.ts @@ -363,7 +363,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient const post = (message: JsonObject, targetOrigin: string): void => { try { - parent.postMessage(message, targetOrigin); + parent.postMessage(message, targetOrigin === 'null' ? '*' : targetOrigin); } catch { throw new AppClientError('invalid-message', 'The App host rejected a JSON-RPC message.'); } @@ -451,7 +451,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient let responseOrigin: string | undefined; if (request.initialize && configuredOrigin === undefined) { try { - responseOrigin = trustedOrigin(event.origin); + responseOrigin = event.origin === 'null' ? 'null' : trustedOrigin(event.origin); } catch { request.reject(new AppClientError('invalid-message', 'The App host returned an unpinnable origin.')); return; diff --git a/packages/agent-bundle/tests/app-client.test.ts b/packages/agent-bundle/tests/app-client.test.ts index 80147eaab..7820db93c 100644 --- a/packages/agent-bundle/tests/app-client.test.ts +++ b/packages/agent-bundle/tests/app-client.test.ts @@ -157,7 +157,7 @@ it('accepts AbortController signals through the structural app contract', () => expect(options.signal?.aborted).toBe(false); }); -it('bootstraps an opaque sandbox only through a matching parent initialize response and pins its origin', async () => { +it('connects and calls through Codex opaque origin only for the matching parent', async () => { expect(typeProofs).toEqual([true, true, true, true, true, true]); const target = harness(); const foreignParent = {}; @@ -181,25 +181,63 @@ it('bootstraps an opaque sandbox only through a matching parent initialize respo targetOrigin: '*', }]); - target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }, 'https://attacker.example', foreignParent); - target.emit({ id: 99, jsonrpc: '2.0', result: initializeResult }); - expect(client.connected).toBe(false); + let initialized = false; + void connecting.then(() => { initialized = true; }, () => { initialized = true; }); + target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }, 'null', foreignParent); + target.emit({ id: 99, jsonrpc: '2.0', result: initializeResult }, 'null'); + await flushListeners(); + expect(initialized).toBe(false); - target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }); + target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }, 'null'); await expect(connecting).resolves.toEqual(initializeResult); expect(client.connected).toBe(true); expect(target.posts.at(-1)).toEqual({ message: { jsonrpc: '2.0', method: 'ui/notifications/initialized' }, - targetOrigin: hostOrigin, + targetOrigin: '*', }); + const called = client.call('tool:hauler/hauler_status', { limit: 40 }); + expect(target.posts.at(-1)?.targetOrigin).toBe('*'); + const callId = responseId(target.posts.at(-1)!); + let settled = false; + void called.then(() => { settled = true; }, () => { settled = true; }); + target.emit({ + id: callId, + jsonrpc: '2.0', + result: { + content: [{ text: 'forged', type: 'text' }], + structuredContent: { active: 99, status: 'forged' }, + }, + }, hostOrigin); + await flushListeners(); + expect(settled).toBe(false); + target.emit({ + id: callId, + jsonrpc: '2.0', + result: { + content: [{ text: 'healthy', type: 'text' }], + structuredContent: { active: 3, status: 'healthy' }, + }, + }, 'null'); + await expect(called).resolves.toEqual({ active: 3, status: 'healthy' }); +}); + +it('dynamically pins the Workbench HTTP parent origin', async () => { + const target = harness(); + const client = createAppClient({ window: target.window }); + await connect(client, target); + const request = client.request('ping', {}); expect(target.posts.at(-1)?.targetOrigin).toBe(hostOrigin); const pingId = responseId(target.posts.at(-1)!); - target.emit({ id: pingId, jsonrpc: '2.0', result: {} }, 'https://attacker.example'); - expect(target.posts).toHaveLength(3); - target.emit({ id: pingId, jsonrpc: '2.0', result: {} }); - await expect(request).resolves.toEqual({}); + let settled = false; + void request.then(() => { settled = true; }, () => { settled = true; }); + target.emit({ id: pingId, jsonrpc: '2.0', result: { forged: true } }, 'null'); + target.emit({ id: pingId, jsonrpc: '2.0', result: { forged: true } }, 'https://attacker.example'); + await flushListeners(); + expect(settled).toBe(false); + target.emit({ id: pingId, jsonrpc: '2.0', result: { accepted: true } }); + await expect(request).resolves.toEqual({ accepted: true }); }); it('uses an exact trusted targetOrigin from the first message and rejects a mismatched response origin', async () => { @@ -211,8 +249,12 @@ it('uses an exact trusted targetOrigin from the first message and rejects a mism }); const connecting = client.connect({ timeoutMs: 20 }); expect(target.posts[0]?.targetOrigin).toBe(hostOrigin); + let settled = false; + void connecting.then(() => { settled = true; }, () => { settled = true; }); + target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }, 'null'); target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }, 'https://other.example'); - expect(client.connected).toBe(false); + await flushListeners(); + expect(settled).toBe(false); target.emit({ id: 1, jsonrpc: '2.0', result: initializeResult }); await expect(connecting).resolves.toEqual(initializeResult); @@ -220,6 +262,10 @@ it('uses an exact trusted targetOrigin from the first message and rejects a mism targetOrigin: '*', window: target.window, })).toThrow(/exact trusted origin/u); + expect(() => createAppClient({ + targetOrigin: 'null', + window: target.window, + })).toThrow(/exact trusted origin/u); expect(() => createAppClient({ targetOrigin: 'file:///tmp/host.html', window: target.window, @@ -491,7 +537,7 @@ it('rejects old pending work on rebind and establishes a fresh exact-origin conn const second = harness(); const secondOrigin = 'https://replacement.example'; const client = createAppClient({ window: first.window }); - await connect(client, first); + await connect(client, first, 'null'); const oldRequest = client.request('slow', {}); const oldRequestId = responseId(first.posts.at(-1)!); const oldFailure = expect(oldRequest).rejects.toMatchObject({ code: 'connection-rebound' }); @@ -509,10 +555,18 @@ it('rejects old pending work on rebind and establishes a fresh exact-origin conn method: 'notifications/cancelled', params: { reason: 'connection-rebound', requestId: oldRequestId }, }, - targetOrigin: hostOrigin, + targetOrigin: '*', }); - expect(first.posts.at(-1)?.targetOrigin).not.toBe('*'); expect(second.posts[0]?.targetOrigin).toBe(secondOrigin); + let reboundSettled = false; + void rebound.then(() => { reboundSettled = true; }, () => { reboundSettled = true; }); + second.emit({ + id: responseId(second.posts[0]!), + jsonrpc: '2.0', + result: initializeResult, + }, secondOrigin, first.parent); + await flushListeners(); + expect(reboundSettled).toBe(false); second.emit({ id: responseId(second.posts[0]!), jsonrpc: '2.0', @@ -521,6 +575,7 @@ it('rejects old pending work on rebind and establishes a fresh exact-origin conn await expect(rebound).resolves.toEqual(initializeResult); await oldFailure; expect(client.connected).toBe(true); + expect(second.posts.at(-1)?.targetOrigin).toBe(secondOrigin); }); it('validates a rebind origin before changing the live connection', async () => { @@ -587,21 +642,35 @@ it('keeps wildcard initialization cancellation local on dispose', async () => { it('acknowledges host teardown before disposing and makes disposal idempotent', async () => { const target = harness(); const client = createAppClient({ window: target.window }); - await connect(client, target); + await connect(client, target, 'null'); const pending = client.request('slow', {}); const pendingId = responseId(target.posts.at(-1)!); const pendingFailure = expect(pending).rejects.toMatchObject({ code: 'disposed' }); + target.emit({ + id: 'foreign-close', + jsonrpc: '2.0', + method: 'ui/resource-teardown', + params: {}, + }, 'null', {}); + target.emit({ + id: 'changed-origin-close', + jsonrpc: '2.0', + method: 'ui/resource-teardown', + params: {}, + }, hostOrigin); + expect(client.disposed).toBe(false); + target.emit({ id: 'close-1', jsonrpc: '2.0', method: 'ui/resource-teardown', params: {}, - }); + }, 'null'); expect(target.posts.slice(-2)).toEqual([ { message: { id: 'close-1', jsonrpc: '2.0', result: {} }, - targetOrigin: hostOrigin, + targetOrigin: '*', }, { message: { @@ -609,10 +678,9 @@ it('acknowledges host teardown before disposing and makes disposal idempotent', method: 'notifications/cancelled', params: { reason: 'disposed', requestId: pendingId }, }, - targetOrigin: hostOrigin, + targetOrigin: '*', }, ]); - expect(target.posts.at(-1)?.targetOrigin).not.toBe('*'); await pendingFailure; expect(client.disposed).toBe(true); expect(client.connected).toBe(false); diff --git a/packages/agent-bundle/tests/serve-app.test.ts b/packages/agent-bundle/tests/serve-app.test.ts index 43e87bc15..e50316e38 100644 --- a/packages/agent-bundle/tests/serve-app.test.ts +++ b/packages/agent-bundle/tests/serve-app.test.ts @@ -123,7 +123,7 @@ const refused = async (url: string): Promise => { } }; -it('serves the MCP App example standalone over its packed server and relays the MCP Apps protocol through the Workbench routes', async () => { +it('serves the real MCP App and accepts Codex opaque-origin client traffic through the Workbench routes', async () => { const opened: string[] = []; const served = await serveApp({ app: 'status/status', @@ -232,6 +232,7 @@ it('serves the MCP App example standalone over its packed server and relays the const consentPath = `/api/mcp/apps/${encodeURIComponent(preview.bindingId)}/consent`; const hostMethods: string[] = []; const openingInputs: unknown[] = []; + const targetOrigins: string[] = []; let approveCalls = true; const decideConsent = async (approved: boolean): Promise => { @@ -250,7 +251,8 @@ it('serves the MCP App example standalone over its packed server and relays the const listeners = new Set<(event: AppMessageEvent) => void>(); const parent: AppMessageTarget = { - postMessage(message) { + postMessage(message, targetOrigin) { + targetOrigins.push(targetOrigin); void (async () => { try { const relayed = await api('POST', messagesPath, { message }) as { @@ -271,7 +273,7 @@ it('serves the MCP App example standalone over its packed server and relays the hostMethods.push(data.method); } for (const listener of [...listeners]) { - listener({ data, origin, source: parent }); + listener({ data, origin: 'null', source: parent }); } } } catch { @@ -298,6 +300,7 @@ it('serves the MCP App example standalone over its packed server and relays the protocolVersion: MCP_APP_PROTOCOL_VERSION, }); expect(client.connected).toBe(true); + expect(targetOrigins[0]).toBe('*'); await expect.poll(() => openingInputs, { timeout: 5_000 * timeScale }).toEqual([{ service: 'compiler' }]); await expect.poll(() => hostMethods.includes('ui/notifications/tool-result'), { timeout: 5_000 * timeScale }).toBe(true); @@ -309,6 +312,7 @@ it('serves the MCP App example standalone over its packed server and relays the const denied = client.call('tool:status/show-status', { service: 'payments-api' }); await expect(denied).rejects.toBeInstanceOf(AppClientError); await expect(denied).rejects.toMatchObject({ code: 'consent-required' }); + expect(new Set(targetOrigins)).toEqual(new Set(['*'])); expect(closed).toBe(false); client.dispose(); } finally { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index dffebe2a7..4d570fe16 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -898,24 +898,28 @@ that is not a function; `call()` rejects with that `TypeError` for a malformed r per-request `timeoutMs`. None of this is an `AB` diagnostic: App-side failures are browser errors, not build output. -**Lifecycle and origin pinning.** The Workbench and `serve-app` render the App as an opaque, -no-referrer `srcdoc` sandbox, so the document cannot know its host's origin up front. `connect()` -therefore sends exactly one frame to `'*'` — the framework-owned `ui/initialize` — and accepts a -response only from the configured parent window (`event.source`), with that request's id and a -valid initialize result (protocol version `2026-01-26`, `hostInfo`, `hostCapabilities`, -`hostContext`). It pins the responding `event.origin`, which must itself be an exact -`http:`/`https:` origin — an opaque `null`, empty, or other-scheme origin fails the handshake with -`invalid-message` — then sends `ui/notifications/initialized` and every later request to that -exact origin, and ignores every message from any other source or origin. A host that can supply a -trusted origin passes `targetOrigin` — an exact `http:`/`https:` origin; `'*'` is rejected — and -no wildcard frame is sent at all. `connect()` is idempotent: a connected client resolves the -cached result, a connecting one returns the in-flight handshake. +**Lifecycle and origin pinning.** The Workbench and `serve-app` put the App behind their separate +loopback HTTP sandbox proxy, while Codex can render it in an opaque sandbox. In either case the +document cannot know its parent's origin up front. `connect()` therefore sends exactly one frame +to `'*'` — the framework-owned `ui/initialize` — and accepts a response only from the configured +parent window (`event.source`), with that request's id and a valid initialize result (protocol +version `2026-01-26`, `hostInfo`, `hostCapabilities`, `hostContext`). An exact `http:`/`https:` +response origin is pinned for both inbound and outbound messages. An opaque `"null"` response +origin from that same parent enters opaque mode: later inbound messages must still come from the +same parent with origin `"null"`, and outbound messages use `'*'` because an opaque target cannot +be addressed by origin. Empty and other-scheme origins fail the handshake with `invalid-message`; +messages from another window or a changed origin are ignored. A host that can supply a trusted +origin passes `targetOrigin` — an exact `http:`/`https:` origin; both `'*'` and `"null"` are +rejected — and no wildcard frame is sent at all. `connect()` is idempotent: a connected client +resolves the cached result, a connecting one returns the in-flight handshake. `rebind({ parent?, targetOrigin?, window? })` rejects the previous generation's pending requests with `connection-rebound` — a `connect()` still in flight included, which never becomes the live -connection — clears the pin, keeps the configured `targetOrigin` unless the call names one, and -performs the handshake again against the new parent. `dispose()`, also idempotent, removes the -listener, rejects pending requests with `disposed`, and drops every registration; a host's -`ui/resource-teardown` request is acknowledged and disposes the client. The transport is +connection — clears either origin mode, keeps the configured `targetOrigin` unless the call names +one, and performs the handshake again against the new parent. `dispose()`, also idempotent, +removes the listener, rejects pending requests with `disposed`, and drops every registration; a +host's `ui/resource-teardown` request is accepted only through the current source-and-origin pin, +acknowledged, and then disposes the client. Cancellation and teardown replies use that same +current parent and outbound mode before the connection state is cleared. The transport is DOM-shaped rather than bound to the global `window`: `window` and `parent` are injectable, which is how tests and non-DOM hosts drive the same client. diff --git a/website/docs/en/reference/security.mdx b/website/docs/en/reference/security.mdx index 5c9ea1b2c..49f39d042 100644 --- a/website/docs/en/reference/security.mdx +++ b/website/docs/en/reference/security.mdx @@ -61,16 +61,18 @@ authority as in the Workbench (`--allow` pre-approves named capabilities on the It is a local preview host, not a deployment target. Inside the App document, the `agent-bundle/app` client trusts no arbitrary `postMessage` sender. -The sandbox is an opaque, no-referrer `srcdoc`, so the document cannot know its host's origin -before the first frame: each `connect()` handshake attempt sends one framework-owned -`ui/initialize` request to `'*'`, accepts a response only from the configured parent window with that request's id and a -valid initialize result, pins the responding origin — it must be an exact `http:`/`https:` -origin; an opaque `null`, empty, or other-scheme origin fails the handshake — and from then on -sends to and accepts from that exact `source` and `origin` only. A host that can name its origin -passes `targetOrigin` (an exact `http:`/`https:` origin; `'*'` is rejected) and no wildcard frame -is ever sent. `rebind()` and `dispose()` revoke the pin and reject every request still pending, a -handshake still in flight included, so a stale generation cannot receive a later host's messages. -Author code has no wildcard send path. +The Workbench and `serve-app` use a separate loopback HTTP sandbox proxy, while hosts such as +Codex can provide an opaque parent, so the document cannot know its parent's origin before the +first frame. Each `connect()` handshake attempt sends one framework-owned `ui/initialize` request +to `'*'` and accepts a response only from the configured parent window with that request's id and +a valid initialize result. An exact `http:`/`https:` response origin is pinned for inbound and +outbound traffic. An opaque `"null"` response from that same parent instead pins opaque mode: +later inbound messages must keep the same source and `"null"` origin, while outbound messages use +`'*'` because opaque targets cannot be addressed by origin. Empty and other-scheme origins fail +the handshake. A host that can name its origin passes `targetOrigin` (an exact `http:`/`https:` +origin; both `'*'` and `"null"` are rejected) and no wildcard frame is sent. `rebind()` and +`dispose()` revoke either pin and reject every request still pending, a handshake still in flight +included, so a stale generation cannot receive a later host's messages. The host relay is the security boundary. The App client speaks only to its expected parent; the Workbench, `serve-app`, or an embedding MCP host forwards permitted requests to the one server the diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index e7794b8bd..bc89b9f3c 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -788,18 +788,21 @@ JSON-RPC result。两者都会在尚未 `connect()` 时先连接;每个请求 `targetOrigin`、缺少浏览器 window,或非法的 `appInfo` / `appCapabilities`——抛出 `TypeError`; 超时不是 1 到 2,147,483,647 毫秒之间的安全整数则抛出 `RangeError`。这些都不是 `AB` 诊断。 -**生命周期与 origin pin。** Workbench 与 `serve-app` 把 App 渲染为 opaque、no-referrer 的 `srcdoc` -sandbox,所以文档事先不知道宿主 origin。`connect()` 只向 `'*'` 发送一帧——框架拥有的 -`ui/initialize`——并且只接受 configured parent window(`event.source`)以同一 request id 返回的有效 -initialize result(协议版本 `2026-01-26`,以及 `hostInfo`、`hostCapabilities`、`hostContext`)。 -客户端拒绝 opaque `null` origin,固定响应的 `event.origin`,发送 -`ui/notifications/initialized`,之后只向该精确 origin 发送并只接受同时匹配 source 与 origin 的消息。 -能预先给出可信 origin 的宿主可传入精确的 `http:` / `https:` `targetOrigin`;`'*'` 会被拒绝,且不会有 -任何 wildcard frame。`connect()` 幂等。`rebind({ parent?, targetOrigin?, window? })` 以 -`connection-rebound` 拒绝旧 generation 的 pending request、撤销 pin,再对新 parent 握手。 -`dispose()` 同样幂等:移除 listener,以 `disposed` 拒绝 pending request,并丢弃所有注册;宿主发来的 -`ui/resource-teardown` request 会先得到确认,再 dispose 客户端。可注入的 `window` 与 `parent` 让测试 -及非 DOM 宿主能驱动同一个客户端。 +**生命周期与 origin pin。** Workbench 与 `serve-app` 把 App 放在独立的 loopback HTTP sandbox proxy +之后,而 Codex 可以把它渲染在 opaque sandbox 中;两种情况下文档都无法事先知道 parent origin。 +`connect()` 只向 `'*'` 发送一帧——框架拥有的 `ui/initialize`——并且只接受 configured parent window +(`event.source`)以同一 request id 返回的有效 initialize result(协议版本 `2026-01-26`,以及 +`hostInfo`、`hostCapabilities`、`hostContext`)。响应若带精确的 `http:` / `https:` origin,客户端就 +把它同时固定为入站与出站 origin。若同一个 parent 返回 opaque `"null"` origin,客户端则进入 opaque +模式:之后的入站消息仍须来自同一个 parent 且 origin 为 `"null"`;出站消息使用 `'*'`,因为浏览器无法按 +origin 寻址 opaque target。空 origin 与其他 scheme 会以 `invalid-message` 令握手失败;其他 window +或发生变化的 origin 所发消息会被忽略。能预先给出可信 origin 的宿主可传入精确的 `http:` / `https:` +`targetOrigin`;`'*'` 与 `"null"` 都会被拒绝,且不会有任何 wildcard frame。`connect()` 幂等。 +`rebind({ parent?, targetOrigin?, window? })` 以 `connection-rebound` 拒绝旧 generation 的 pending +request、清除任一种 origin 模式,再对新 parent 握手。`dispose()` 同样幂等:移除 listener,以 +`disposed` 拒绝 pending request,并丢弃所有注册;只有通过当前 source 与 origin pin 的 +`ui/resource-teardown` request 才会先得到确认,再 dispose 客户端。取消与 teardown 回复会在连接状态 +清除之前沿当前 parent 及出站模式发送。可注入的 `window` 与 `parent` 让测试及非 DOM 宿主能驱动同一个客户端。 **宿主侧边界。** 客户端只是 iframe 内的半座桥。frame relay、sandbox proxy、宿主页面、 `/api/mcp/...` 路由与 consent authority 都属于宿主;Workbench MCP 页面、`agent-bundle serve-app`、 diff --git a/website/docs/zh/reference/security.mdx b/website/docs/zh/reference/security.mdx index 5f18d84e1..259239643 100644 --- a/website/docs/zh/reference/security.mdx +++ b/website/docs/zh/reference/security.mdx @@ -49,14 +49,16 @@ Skill Markdown 中的原始 HTML、JSX/MDX 与 Mermaid 在 Workbench 渲染器 检查;App 文档运行在框架沙箱内的第二个 loopback origin 上,并且每一个 App 动作仍与 Workbench 一样经过同一 个同意授权(`--allow` 代操作者预先批准指定能力)。它是本地预览宿主,不是部署目标。 -App 文档内的 `agent-bundle/app` 客户端也不会信任任意 `postMessage` 发送者。sandbox 是 opaque、 -no-referrer 的 `srcdoc`,所以文档在第一帧前不知道宿主 origin:每次 `connect()` 握手尝试只向 `'*'` -发送一条框架拥有的 `ui/initialize` request,仅接受 configured parent window 以该 request id 返回的有效 -initialize result,并固定响应 origin——opaque 的 `null` origin 会被拒绝。此后发送与接收都只允许同时 -匹配该精确 `source` 与 `origin`。能给出宿主 origin 的调用方传入精确的 `http:` / `https:` -`targetOrigin`;`'*'` 会被拒绝,且不会发送任何 wildcard frame。`rebind()` 与 `dispose()` 都撤销 pin -并拒绝所有 pending request,旧 generation 因此不可能收到后续宿主的消息;author code 没有 wildcard -发送路径。 +App 文档内的 `agent-bundle/app` 客户端也不会信任任意 `postMessage` 发送者。Workbench 与 +`serve-app` 使用独立的 loopback HTTP sandbox proxy,而 Codex 等宿主可以提供 opaque parent,因此文档 +在第一帧前不知道 parent origin。每次 `connect()` 握手尝试只向 `'*'` 发送一条框架拥有的 +`ui/initialize` request,并且只接受 configured parent window 以该 request id 返回的有效 initialize +result。精确的 `http:` / `https:` 响应 origin 会同时固定入站与出站流量。若同一个 parent 返回 opaque +`"null"` origin,客户端则固定 opaque 模式:之后的入站消息必须保持同一个 source 和 `"null"` origin, +而出站消息使用 `'*'`,因为浏览器无法按 origin 寻址 opaque target。空 origin 与其他 scheme 会使握手 +失败。能给出宿主 origin 的调用方传入精确的 `http:` / `https:` `targetOrigin`;`'*'` 与 `"null"` 都会 +被拒绝,且不会发送任何 wildcard frame。`rebind()` 与 `dispose()` 都撤销任一种 pin 并拒绝所有 pending +request,旧 generation 因此不可能收到后续宿主的消息。 这条桥接以宿主 relay 为安全边界:App 客户端只与预期 parent 通信;Workbench、`serve-app` 或嵌入式 MCP 宿主负责把允许的请求转发到所绑定的那一个服务器,并继续执行各自的认证、能力与同意策略。App 不会绕过 From 506413326359bff154ce4476eb0f47b0dabf4efe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 04:14:16 +0000 Subject: [PATCH 2/2] docs(changeset): reference PR 733 --- .changeset/codex-opaque-app-origin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/codex-opaque-app-origin.md b/.changeset/codex-opaque-app-origin.md index 6e54a9196..e9bebacdf 100644 --- a/.changeset/codex-opaque-app-origin.md +++ b/.changeset/codex-opaque-app-origin.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Allow `createAppClient()` to authenticate Codex MCP Apps with an opaque parent origin while preserving exact source and configured HTTP(S) origin pinning. (#731) +Allow `createAppClient()` to authenticate Codex MCP Apps with an opaque parent origin while preserving exact source and configured HTTP(S) origin pinning. (#733)