diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts index c7f8e374..8217a4ff 100644 --- a/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts @@ -141,15 +141,39 @@ describe("appCapabilities (#1854)", () => { 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. + it("takes the latest accepted handshake, in both directions", () => { + // Verified against ext-apps 1.7.5: a second `ui/initialize` is accepted — + // the bridge warns about the double-mount and the latest appInfo and + // capabilities REPLACE the previous ones. Freezing this at the first frame + // would leave the gate reporting capabilities the bridge no longer holds. const bridge = makeBridge({}); const { transport } = makeTransport(); observeAppCapabilities(bridge, transport); + transport.onmessage?.(initializeFrame({ availableDisplayModes: [] })); - transport.onmessage?.(initializeFrame({ elicitation: {} })); expect(appAdvertisesElicitation(bridge)).toBe(false); + transport.onmessage?.(initializeFrame({ elicitation: {} })); + expect(appAdvertisesElicitation(bridge)).toBe(true); + // …and a re-handshake that drops the capability turns it back off. + transport.onmessage?.(initializeFrame({})); + expect(appAdvertisesElicitation(bridge)).toBe(false); + }); + + it("leaves a recorded capability alone when a later frame is malformed", () => { + // A rejected frame is not a route to changing the gate — in either + // direction: it cannot set `elicitation`, and it cannot clear one the + // bridge still holds. + const bridge = makeBridge({}); + const { transport } = makeTransport(); + observeAppCapabilities(bridge, transport); + transport.onmessage?.(initializeFrame({ elicitation: {} })); + transport.onmessage?.({ + jsonrpc: "2.0", + id: 9, + method: "ui/initialize", + params: { appCapabilities: {} }, + } as unknown as JSONRPCMessage); + expect(appAdvertisesElicitation(bridge)).toBe(true); }); it("keeps bridges independent", () => { diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts index a32f7db8..f1f3f1ef 100644 --- a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts @@ -40,8 +40,8 @@ export interface MessageObservable { /** * Whether `message` is a well-formed `ui/initialize` REQUEST — a JSON-RPC - * request id, plus the two params the handshake requires (`protocolVersion`, - * `appInfo`). + * request id, plus the three params the handshake requires + * (`protocolVersion`, `appInfo`, `appCapabilities`). * * This is a gate, not a second copy of the bridge's schema: the bridge stays * the authority on everything else in the frame. What it has to stop is a @@ -52,7 +52,7 @@ export interface MessageObservable { */ function isInitializeRequest( message: unknown, -): message is { params: { appCapabilities?: unknown } } { +): message is { params: { appCapabilities: Record } } { if (typeof message !== "object" || message === null) return false; const frame = message as { id?: unknown; method?: unknown; params?: unknown }; if (frame.method !== "ui/initialize") return false; @@ -62,33 +62,38 @@ function isInitializeRequest( const params = frame.params as { protocolVersion?: unknown; appInfo?: unknown; + appCapabilities?: unknown; }; + // All three are required by the bridge's own initialize schema, so a frame + // missing any of them is one the bridge rejects. return ( typeof params.protocolVersion === "string" && typeof params.appInfo === "object" && - params.appInfo !== null + params.appInfo !== null && + typeof params.appCapabilities === "object" && + params.appCapabilities !== null ); } /** * Record `params.appCapabilities` from the view's handshake. * - * Only the FIRST well-formed `ui/initialize` is recorded, mirroring the bridge: - * it keeps the capabilities it accepted and ignores re-initialization, so a - * later frame — malformed or not — must not be able to change what this gate - * reports either. Fail-closed in both directions: a malformed first frame - * records nothing. + * Every *accepted* `ui/initialize` replaces the recorded value, because that is + * what the bridge does — on a second handshake (a view double-mounting under + * React StrictMode, or reconnecting) it warns and takes the latest appInfo and + * capabilities. Freezing this at the first frame would leave the gate reporting + * capabilities the bridge no longer holds, in both directions. + * + * A frame the bridge would *reject* records nothing and leaves the previous + * value alone, which is what keeps the gate fail-closed: a malformed initialize + * must not be a second, laxer route to setting `elicitation`. */ function recordAdvertisedCapabilities( bridge: AppBridge, message: unknown, ): void { - if (rawAppCapabilities.has(bridge)) return; if (!isInitializeRequest(message)) return; - const advertised = message.params.appCapabilities; - if (typeof advertised === "object" && advertised !== null) { - rawAppCapabilities.set(bridge, advertised as Record); - } + rawAppCapabilities.set(bridge, message.params.appCapabilities); } /**