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
5 changes: 5 additions & 0 deletions .changeset/codex-opaque-app-origin.md
Original file line number Diff line number Diff line change
@@ -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. (#733)
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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;
Expand Down
106 changes: 87 additions & 19 deletions packages/agent-bundle/tests/app-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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 () => {
Expand All @@ -211,15 +249,23 @@ 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);

expect(() => createAppClient({
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,
Expand Down Expand Up @@ -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' });
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -587,32 +642,45 @@ 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: {
jsonrpc: '2.0',
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);
Expand Down
10 changes: 7 additions & 3 deletions packages/agent-bundle/tests/serve-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const refused = async (url: string): Promise<boolean> => {
}
};

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',
Expand Down Expand Up @@ -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<readonly JsonRpc[]> => {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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);

Expand All @@ -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 {
Expand Down
36 changes: 20 additions & 16 deletions website/docs/en/guide/authoring/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 12 additions & 10 deletions website/docs/en/reference/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading