fix(apps): fail closed on a malformed app ui/initialize

The capability observer recorded `params.appCapabilities` from ANY frame whose
method was `ui/initialize`, before the bridge validated it. A view could
therefore send a second, malformed initialize — one the bridge rejects, keeping
the capabilities it had already accepted — and flip `elicitation` on in this
gate, after which the host would forward an elicitation the bridge never
negotiated.

Now only a well-formed initialize REQUEST is recorded (a JSON-RPC id, plus the
handshake's required `protocolVersion` and `appInfo`), and only the first one:
the bridge keeps what it accepted and ignores re-initialization, so this gate
agrees rather than offering a second, laxer path to the same flag. A malformed
first frame records nothing.

This is a gate, not a second copy of the bridge's schema — the bridge stays the
authority on the rest of the frame.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 20:45:28 -04:00
commit cc0770babd

View file

@ -19,12 +19,20 @@ function makeTransport(): {
return { transport, inner };
}
function initializeFrame(appCapabilities: unknown): JSONRPCMessage {
function initializeFrame(
appCapabilities: unknown,
overrides: Record<string, unknown> = {},
): JSONRPCMessage {
return {
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: { appCapabilities },
params: {
protocolVersion: "2026-01-26",
appInfo: { name: "test-app", version: "1.0.0" },
appCapabilities,
...overrides,
},
} as unknown as JSONRPCMessage;
}
@ -94,6 +102,56 @@ describe("appCapabilities (#1854)", () => {
expect(inner).toHaveBeenCalledTimes(4);
});
it("ignores a malformed initialize the bridge would reject", () => {
// Fail-closed: accepting one would let a view flip `elicitation` on with a
// frame that never negotiated anything, and the host would then forward a
// request the bridge does not consider negotiated.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
// No protocolVersion.
transport.onmessage?.({
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: { appInfo: { name: "a" }, appCapabilities: { elicitation: {} } },
} as unknown as JSONRPCMessage);
// No appInfo.
transport.onmessage?.({
jsonrpc: "2.0",
id: 2,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
// A notification, not the handshake request.
transport.onmessage?.({
jsonrpc: "2.0",
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: { name: "a" },
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("keeps the capabilities from the first handshake, not a later frame", () => {
// The bridge keeps what it accepted and ignores re-initialization; this
// gate has to agree, or a second frame could change what it reports.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("keeps bridges independent", () => {
const a = makeBridge({});
const b = makeBridge({});