From b86db5c7a2d9fa87a7a837a18210e282ab24f16c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 22 Aug 2026 21:05:47 -0400 Subject: [PATCH] fix(apps): track the latest accepted app ui/initialize, not the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round froze the recorded capabilities at the first handshake, on the belief that the bridge ignores re-initialization. It does not: ext-apps 1.7.5's `_oninitialize` warns about the double-mount and then assigns `_appCapabilities` and `_appInfo` from the new frame — "the latest appInfo/ appCapabilities replace the previous values", in its own words. Freezing left this gate reporting capabilities the bridge no longer held, in both directions: a handshake advertising `elicitation` followed by one without it still read as advertised. Every frame that passes the accept gate now replaces the recorded value, and `appCapabilities` joins `protocolVersion` and `appInfo` in that gate since the bridge's own schema requires all three. A frame the bridge would reject still records nothing AND leaves the previous value alone — it is a route to changing the gate in neither direction. Signed-off-by: cliffhall --- .../AppRenderer/appCapabilities.test.ts | 32 +++++++++++++++--- .../elements/AppRenderer/appCapabilities.ts | 33 +++++++++++-------- 2 files changed, 47 insertions(+), 18 deletions(-) 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); } /**