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/authenticate-app-parent-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Make `createAppClient()` authenticate unconfigured MCP App transports by exact parent source while preserving strict HTTP(S) `targetOrigin` pinning. (#779)
37 changes: 18 additions & 19 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1726,7 +1726,7 @@ client rather than by hand-written frames: `tests/serve-app.test.ts` connects
relay uses, and the Workbench real-App E2E
(`packages/workbench/tests/mcp-app-real.e2e.test.ts`) compiles a fixture view
on `createAppClient` and reads its `call()` result through the relay. The
client's own contract — envelopes, handshake, pinning, dispatch, cancellation,
client's own contract — envelopes, handshake, transport authentication, dispatch, cancellation,
rebind, disposal — is proven in `tests/app-client.test.ts` over injected
ports. The client never decides which server a call reaches or which
capability needs consent.
Expand All @@ -1742,8 +1742,8 @@ capability needs consent.
| `request(method, params?, options?)` | The typed JSON-RPC escape hatch for `resources/read` and supported `ui/*` methods; resolves the raw result. An empty method rejects with a `TypeError`. |
| `onToolInput(routeId, listener)` / `onToolResult(routeId, listener)` / `onToolError(routeId, listener)` | The opening call's `ui/notifications/tool-input` arguments, the decoded `structuredContent` of a successful `ui/notifications/tool-result`, and that notification's failures as an `AppClientError` — `isError: true` is `rpc` with the whole result on `data`; a malformed envelope or one without an object `structuredContent` is `invalid-message`; a failed result never reaches `onToolResult`. The notifications carry no tool name, so dispatch keys on the tool the handshake named: `hostContext.toolInfo.tool.name` from the initialize result, matched against the final segment of each registered route id. Listeners for other tools stay silent; when the initialize result names no tool, `tool-input` and `tool-result` reach no listener. Listeners run on a microtask, exceptions dropped. Each returns its unsubscribe function. |
| `onToolCancelled(listener)` | `ui/notifications/tool-cancelled` as `{ reason? }`, unfiltered; returns its unsubscribe function. |
| `rebind({ parent?, targetOrigin?, window? })` | Bumps the connection generation and rejects the previous generation's pending requests with `connection-rebound` — a `connect()` still in flight included; its late response can never become the live connection — clears the pinned origin and the opening tool name, moves the message listener when `window` changes, adopts the new parent, keeps the configured `targetOrigin` unless the call names the key, and runs `connect()` again. |
| `dispose()` | Idempotent. Removes the message listener, rejects pending requests with `disposed`, drops every registration and the pin. A host `ui/resource-teardown` request is answered with `{}` and disposes the client; any other host request is answered `-32601`. |
| `rebind({ parent?, targetOrigin?, window? })` | Bumps the connection generation and rejects the previous generation's pending requests with `connection-rebound` — a `connect()` still in flight included; its late response can never become the live connection — clears the opening tool name, moves the message listener when `window` changes, adopts the new parent, keeps the configured `targetOrigin` unless the call names the key, and runs `connect()` again. |
| `dispose()` | Idempotent. Removes the message listener, rejects pending requests with `disposed`, and drops every registration. A host `ui/resource-teardown` request is answered with `{}` and disposes the client; any other host request is answered `-32601`. |
| `connected` / `disposed` | Read-only state. |

`CreateAppClientOptions` are `appInfo` (`{ name, version }`, default
Expand Down Expand Up @@ -1799,25 +1799,24 @@ that cancellation cannot bypass consent or reach a request the App did not
start. Hosts outside the framework apply their own policy; the client's
behavior is the same either way.

### Dynamic sandbox handshake
### Parent transport authentication

The Workbench and `serve-app` render the App as `<iframe sandbox="allow-scripts"
referrerpolicy="no-referrer" srcdoc=…>`, so the document has an opaque
origin and no referrer to learn its host origin from. The client therefore
sends exactly one frame to `'*'` — its own `ui/initialize` — and accepts a
response only when `event.source` is the configured parent, the id is that
bootstrap request's, and the result validates; it then pins `event.origin`
raw for inbound messages. Exact `http:` or `https:` origins are also used as
the outbound target; opaque `'null'` and host-private origins such as
`codex-sandbox://…` use `'*'` outbound. An empty or literal `'*'` origin fails
the handshake as `invalid-message`. Every later inbound message must match
both the parent and exact raw pinned origin. The initialize result also names the opening tool
(`hostContext.toolInfo.tool.name`), which is what the opening-notification
listeners dispatch on. A malformed message that still names a pending id
rejects that request as `invalid-message`. A host that can name its origin
passes `targetOrigin` — an exact `http:` or `https:` origin; `'*'`, `'null'`,
other schemes, and non-origin strings are a `TypeError` — and no wildcard frame
is sent. The transport is DOM-shaped
origin and no referrer to learn its host origin from. Without `targetOrigin`,
the client sends every frame to `'*'` and authenticates every incoming frame
by exact `event.source === parent` identity plus strict JSON-RPC validation.
It does not inspect or pin `event.origin`. The initialize result also names
the opening tool (`hostContext.toolInfo.tool.name`), which is what the
opening-notification listeners dispatch on. A malformed message that still
names a pending id rejects that request as `invalid-message`.

A host that can name a trusted origin passes `targetOrigin` — an exact
`http:` or `https:` origin; `'*'`, `'null'`, other schemes, and non-origin
strings are a `TypeError`. The client then uses that exact origin for every
outgoing frame and requires every incoming `event.origin` to match it. The
Workbench and `serve-app` sandbox proxy keep their exact HTTP source-and-origin
checks on the host side before relaying. The transport is DOM-shaped
(`AppWindow`, `AppMessageTarget`) rather than bound to the global `window`, so
`tests/app-client.test.ts` and non-DOM hosts drive the same core through
injected ports.
Expand Down
59 changes: 9 additions & 50 deletions packages/agent-bundle/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,6 @@ interface PendingRequest {
readonly abort?: () => void;
}

interface AppOriginPin {
readonly incoming: string;
readonly outgoing: string;
}

type AnyListener = (value: unknown) => Promise<void> | void;

const allowedMessageKeys = Object.freeze(['error', 'id', 'jsonrpc', 'method', 'params', 'result']);
Expand Down Expand Up @@ -242,17 +237,6 @@ const trustedOrigin = (value: string | undefined): string | undefined => {
return value;
};

const originPin = (value: string): AppOriginPin => {
if (value === '*' || !nonempty(value)) {
throw new TypeError('App client cannot pin an empty or wildcard origin.');
}
try {
return { incoming: value, outgoing: trustedOrigin(value)! };
} catch {
return { incoming: value, outgoing: '*' };
}
};

const currentWindow = (): AppWindow => {
const candidate = (globalThis as { readonly window?: AppWindow }).window;
if (candidate === undefined) {
Expand Down Expand Up @@ -354,8 +338,6 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
let boundWindow = options.window ?? currentWindow();
let parent = options.parent ?? boundWindow.parent;
let configuredOrigin = trustedOrigin(options.targetOrigin);
let pinnedOrigin = configuredOrigin;
let postOrigin = configuredOrigin;
let nextId = 0;
let connectionGeneration = 0;
let connection: Promise<AppInitializeResult> | undefined;
Expand Down Expand Up @@ -400,13 +382,13 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
};

const notifyCancelled = (id: null | number | string, reason: string): void => {
if (connectedResult === undefined || postOrigin === undefined) return;
if (connectedResult === undefined) return;
try {
post({
jsonrpc: '2.0',
method: 'notifications/cancelled',
params: { reason, requestId: id },
}, postOrigin);
}, configuredOrigin ?? '*');
} catch {
// The original timeout or abort remains the observable request failure.
}
Expand All @@ -424,8 +406,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
};

const sendResponse = (id: null | number | string, result: JsonObject): void => {
if (postOrigin === undefined) return;
post({ id, jsonrpc: '2.0', result }, postOrigin);
post({ id, jsonrpc: '2.0', result }, configuredOrigin ?? '*');
};

const publishOpening = (listeners: Map<string, Set<AnyListener>>, value: unknown): void => {
Expand All @@ -445,8 +426,6 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
connection = undefined;
connectedResult = undefined;
openingToolName = undefined;
pinnedOrigin = undefined;
postOrigin = undefined;
inputListeners.clear();
resultListeners.clear();
errorListeners.clear();
Expand All @@ -455,7 +434,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient

const receive = (event: AppMessageEvent): void => {
if (isDisposed || event.source !== parent) return;
if (pinnedOrigin !== undefined && event.origin !== pinnedOrigin) return;
if (configuredOrigin !== undefined && event.origin !== configuredOrigin) return;
const message = rpcMessage(event.data);
if (message === undefined) {
const id = candidateId(event.data);
Expand All @@ -468,15 +447,6 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
if (message.method === undefined) {
const request = message.id === undefined ? undefined : clearPending(message.id);
if (request === undefined) return;
let responseOrigin: AppOriginPin | undefined;
if (request.initialize && configuredOrigin === undefined) {
try {
responseOrigin = originPin(event.origin);
} catch {
request.reject(new AppClientError('invalid-message', 'The App host returned an unpinnable origin.'));
return;
}
}
if (message.error !== undefined) {
request.reject(errorFromRpc(message.error));
return;
Expand All @@ -489,15 +459,11 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
request.reject(new AppClientError('invalid-message', `The App host did not negotiate protocol ${APP_PROTOCOL_VERSION}.`));
return;
}
if (responseOrigin !== undefined) {
pinnedOrigin = responseOrigin.incoming;
postOrigin = responseOrigin.outgoing;
}
request.resolve(message.result);
return;
}

if (postOrigin === undefined || connectedResult === undefined) return;
if (connectedResult === undefined) return;
if (message.id !== undefined) {
if (message.method === 'ui/resource-teardown') {
try {
Expand All @@ -511,7 +477,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
error: { code: -32601, message: `${message.method} is not supported by this App client.` },
id: message.id,
jsonrpc: '2.0',
}, postOrigin);
}, configuredOrigin ?? '*');
return;
}
if (message.method === 'ui/notifications/tool-input') {
Expand Down Expand Up @@ -571,10 +537,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
return Promise.reject(new AppClientError('invalid-message', 'App client request params must be finite strict JSON.'));
}
const id = ++nextId;
const targetOrigin = initialize ? configuredOrigin ?? '*' : postOrigin;
if (targetOrigin === undefined) {
return Promise.reject(new AppClientError('capability-unavailable', 'The App client is not connected.'));
}
const targetOrigin = configuredOrigin ?? '*';
return new Promise<JsonValue>((resolvePromise, rejectPromise) => {
const timeout = setTimeout(() => {
if (clearPending(id) === undefined) return;
Expand Down Expand Up @@ -625,10 +588,10 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
throw new AppClientError('connection-rebound', 'The App client connection was rebound.');
}
const initialized = initializeResult(result);
if (initialized === undefined || pinnedOrigin === undefined || postOrigin === undefined) {
if (initialized === undefined) {
throw new AppClientError('invalid-message', 'The App host returned an invalid initialize result.');
}
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' }, postOrigin);
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' }, configuredOrigin ?? '*');
connectedResult = initialized;
const toolInfo = isPlainDataRecord(initialized.hostContext.toolInfo)
? initialized.hostContext.toolInfo
Expand Down Expand Up @@ -735,8 +698,6 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
connection = undefined;
connectedResult = undefined;
openingToolName = undefined;
pinnedOrigin = undefined;
postOrigin = undefined;
const replacementWindow = rebindOptions.window ?? boundWindow;
if (replacementWindow !== boundWindow) {
boundWindow.removeEventListener('message', receive);
Expand All @@ -745,8 +706,6 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
}
parent = rebindOptions.parent ?? boundWindow.parent;
configuredOrigin = nextOrigin;
pinnedOrigin = configuredOrigin;
postOrigin = configuredOrigin;
return await connect();
},
dispose,
Expand Down
Loading
Loading