From f9dbda38dc4520aaae5f9dead1562ffa0e29f1b7 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:04:14 -0700 Subject: [PATCH] fix(mcp): revoke resource access with app authority (#129550) --- docs/cli/mcp.md | 5 +- docs/web/dashboard-architecture.md | 14 ++-- docs/web/dashboards.md | 6 +- src/gateway/mcp-app-operations.ts | 20 ++++- src/gateway/mcp-app-standalone.test.ts | 16 +++- src/gateway/mcp-app-standalone.ts | 16 +++- src/gateway/server-methods/mcp-app.test.ts | 76 ++++++++++++++++++- ...way-mcp-app-grant-revalidation.e2e.test.ts | 30 ++++++++ ui/src/components/mcp-app-security.test.ts | 7 +- ui/src/components/mcp-app-security.ts | 3 +- ui/src/components/mcp-app-view.test.ts | 6 +- ui/src/components/mcp-app-view.ts | 1 + ui/src/e2e/mcp-app-conformance.e2e.test.ts | 64 +++++++++++++++- 13 files changed, 237 insertions(+), 27 deletions(-) diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index cb7fc22f1667..2c3d40a60ef3 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -900,7 +900,7 @@ Notes: ## MCP Apps -OpenClaw can render tools that implement the stable [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps). Apps are opt-in because their HTML comes from the configured MCP server and can request app-visible tools or resources from that same server. +OpenClaw can render tools that implement the stable [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps). Apps are opt-in because their HTML comes from the configured MCP server. A view with current App-interaction authority can request app-visible tools and resources from that same server. Enable the host bridge: @@ -948,8 +948,9 @@ Behavior and security boundaries: - Only `ui://` resources with the exact `text/html;profile=mcp-app` MIME type render. - UI resources are capped at 2 MiB, placed behind a double-iframe proxy on a dedicated outer origin, loaded into an opaque inner App origin, and constrained by CSP derived from the resource metadata. - App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server that also pass the effective OpenClaw tool policy for the run that created the view. +- Same-server resource listing and reads require that same current App-interaction authority. OpenClaw rechecks after upstream resource work, so a grant revoked in flight cannot return resource data to the App. - Origin-bound App permissions such as camera, microphone, and geolocation are not granted while inner App documents use opaque origins for cross-App isolation. -- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease and are not written to disk or copied into transcript preview metadata. The transcript stores only a bounded server/tool/resource descriptor tied to the original tool-call ID. After a Gateway restart, the Control UI can verify that descriptor against the authenticated session transcript and refetch the `ui://` resource; reconstructed views are read-only until a fresh run establishes current tool permissions. +- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease and are not written to disk or copied into transcript preview metadata. The transcript stores only a bounded server/tool/resource descriptor tied to the original tool-call ID. After a Gateway restart, the Control UI can verify that descriptor against the authenticated session transcript and refetch the `ui://` document for display; reconstructed views cannot call tools or use the resource bridge until a fresh run establishes current App-interaction authority. - In channel conversations, the latest successful App view in a turn adds one **Open App**-style action to the final assistant reply. Telegram DMs use a native Mini App button; Slack and Discord render the same portable action as a link. Other channels keep the original reply text and append an understandable HTTPS link. - Channel launch links are available only when Gateway Tailscale exposure has prepared a published HTTPS origin. `gateway.tailscale.mode: "serve"` is reachable only from the tailnet; password-authenticated `"funnel"` is reachable from the public internet. Externally managed Funnel routes targeting the ordinary Gateway listener must migrate to managed `"funnel"` mode before OpenClaw can publish an internet-reachable origin. See [Tailscale](/gateway/tailscale). - Launch tickets are opaque, minted only while materializing the final channel reply, and expire after at most two minutes or when the underlying view lease expires, whichever comes first. The URL does not contain Gateway bearer credentials, session keys, view metadata, App HTML, tool input, or tool results. diff --git a/docs/web/dashboard-architecture.md b/docs/web/dashboard-architecture.md index 3a066f3d509b..d48fcb800a5b 100644 --- a/docs/web/dashboard-architecture.md +++ b/docs/web/dashboard-architecture.md @@ -156,8 +156,9 @@ Shared infrastructure underneath (this is where the simplification lands): the natural case. - **One authorization model.** A widget's reach is a granted allowlist, whatever its kind: for `html` widgets, host tools; for `mcp-app` widgets, - the server's app-visible tools (via the existing `allowedAppToolNames` - mechanism, made durable per widget instead of per-minting-run). + the server's app-visible tools and same-server resources (via the existing + live App-interaction authority, made durable per widget instead of + per-minting-run). - **Host tools for `html` widgets** (exposed over the widget bridge, checked against the grant): - `openclaw.prompt.send` — tier 2; routed through the visible composer, @@ -248,10 +249,11 @@ on staleness). Chat inline MCP app views get the same **Pin to dashboard** affordance as agent widgets. Re-opened views are read-only today by design; pinned apps that should stay interactive get a durable grant over the server's app-visible tools (explicit allowlist shown to the operator on pin), decoupled -from the minting run. Ungranted pins stay read-only — still useful for display -dashboards. v1 pins to the originating session's board; cross-session pinning -needs a lease broker and waits. Coordinate with open PR #109807 (`ui/message` -composer routing, theme/size propagation). +from the minting run. Ungranted pins can render their fetched App HTML but +cannot call tools or access the same-server resource bridge. v1 pins to the +originating session's board; cross-session pinning needs a lease broker and +waits. Coordinate with open PR #109807 (`ui/message` composer routing, +theme/size propagation). ### WorkBoard integration diff --git a/docs/web/dashboards.md b/docs/web/dashboards.md index f1e3e29af0bb..6ecd5417786c 100644 --- a/docs/web/dashboards.md +++ b/docs/web/dashboards.md @@ -117,9 +117,9 @@ being interrupted. If your gateway has MCP servers configured, interactive MCP apps that appear in chat can be pinned like any widget. Pinned apps come back to life on the -board with fresh sessions; by default they are display-only, and granting the -widget its declared server tools makes it fully interactive — with the same -one-tap, revision-bound approval as everything else. +board with fresh sessions. By default they render without server tools or +same-server resource access. Granting the widget its declared server tools +enables both bridges while that revision-bound grant remains active. ## A2UI widgets diff --git a/src/gateway/mcp-app-operations.ts b/src/gateway/mcp-app-operations.ts index 6f107f0abf62..2ed8d8276830 100644 --- a/src/gateway/mcp-app-operations.ts +++ b/src/gateway/mcp-app-operations.ts @@ -178,6 +178,20 @@ export async function withMcpAppActiveView( } } +async function withMcpAppResourceAuthority( + active: McpAppActiveView, + operation: () => Promise, +): Promise { + return await withMcpAppActiveView(active, "read", async () => { + await requireMcpAppInteraction(active.view); + const result = await operation(); + // Resource results may contain protected data. Recheck after upstream work + // so a grant revoked in flight cannot disclose the completed response. + await requireMcpAppInteraction(active.view); + return result; + }); +} + export async function executeMcpAppOperation( active: McpAppActiveView, operation: McpAppOperation, @@ -227,7 +241,7 @@ export async function executeMcpAppOperation( return result; }); case "resources/list": - return await withMcpAppActiveView(active, "read", async () => { + return await withMcpAppResourceAuthority(active, async () => { if (!runtime.listResources) { throw new Error("MCP resources/list is unavailable"); } @@ -237,7 +251,7 @@ export async function executeMcpAppOperation( return Array.isArray(resources) ? { resources } : resources; }); case "resources/templates/list": - return await withMcpAppActiveView(active, "read", async () => { + return await withMcpAppResourceAuthority(active, async () => { if (!runtime.listResourceTemplates) { throw new Error("MCP resources/templates/list is unavailable"); } @@ -247,7 +261,7 @@ export async function executeMcpAppOperation( ); }); case "resources/read": - return await withMcpAppActiveView(active, "read", async () => { + return await withMcpAppResourceAuthority(active, async () => { if (!runtime.readResource) { throw new Error("MCP resources/read is unavailable"); } diff --git a/src/gateway/mcp-app-standalone.test.ts b/src/gateway/mcp-app-standalone.test.ts index 7301e1580ca9..15ca96cd6c7c 100644 --- a/src/gateway/mcp-app-standalone.test.ts +++ b/src/gateway/mcp-app-standalone.test.ts @@ -516,9 +516,16 @@ describe("MCP App standalone host", () => { expect(runtime.callTool).not.toHaveBeenCalled(); }); - it("keeps reconstructed views read-only while preserving resource reads", async () => { + it("denies resource reads from reconstructed read-only views", async () => { Object.assign(view, { readOnly: true }); const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret }); + const payload = await request({ + url: "/__openclaw__/mcp-app/view", + authorization: `MCP-App ${issued.ticket}`, + }); + expect(JSON.parse(String(payload.end.mock.calls[0]?.[0]))).toMatchObject({ + serverResources: false, + }); const invoke = (body: unknown) => request({ url: "/__openclaw__/mcp-app/view", @@ -533,10 +540,11 @@ describe("MCP App standalone host", () => { expect( (await invoke({ method: "resources/read", params: { uri: "ui://demo/state" } })).res .statusCode, - ).toBe(200); + ).toBe(403); + expect(runtime.readResource).not.toHaveBeenCalled(); }); - it("does not accept standalone tool operations without explicit run authority", async () => { + it("does not accept standalone server operations without explicit run authority", async () => { Object.assign(view, { allowedAppToolNames: undefined }); const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret }); const invoke = (body: unknown) => @@ -555,7 +563,7 @@ describe("MCP App standalone host", () => { expect( (await invoke({ method: "resources/read", params: { uri: "ui://demo/state" } })).res .statusCode, - ).toBe(200); + ).toBe(403); expect(runtime.callTool).not.toHaveBeenCalled(); }); diff --git a/src/gateway/mcp-app-standalone.ts b/src/gateway/mcp-app-standalone.ts index f2eec734b62d..92495528b2df 100644 --- a/src/gateway/mcp-app-standalone.ts +++ b/src/gateway/mcp-app-standalone.ts @@ -17,6 +17,7 @@ import { executeMcpAppOperation, type McpAppActiveView, parseMcpAppOperation, + requireMcpAppInteraction, withMcpAppActiveView, } from "./mcp-app-operations.js"; @@ -211,6 +212,15 @@ function supportsStandaloneToolOperations( return view.allowedAppToolNames !== undefined && view.readOnly !== true; } +async function supportsStandaloneResourceOperations(view: McpAppViewLease): Promise { + try { + await requireMcpAppInteraction(view); + return true; + } catch { + return false; + } +} + function sendJsonRepresentation( req: IncomingMessage, res: ServerResponse, @@ -685,8 +695,10 @@ export async function handleMcpAppStandaloneHttpRequest( } try { - return await withMcpAppActiveView(active, "read", () => { + return await withMcpAppActiveView(active, "read", async () => { const { runtime, view } = active; + const serverResources = + runtime.readResource !== undefined && (await supportsStandaloneResourceOperations(view)); sendJsonRepresentation(req, res, 200, { sandboxUrl: buildMcpAppSandboxPath(view.csp), sandboxPort, @@ -696,7 +708,7 @@ export async function handleMcpAppStandaloneHttpRequest( toolInput: view.toolInput, toolResult: view.toolResult, serverTools: supportsStandaloneToolOperations(view), - serverResources: runtime.readResource !== undefined, + serverResources, ...(view.requestTimeoutMs !== undefined ? { // Keep the browser's outer deadline behind the SDK request diff --git a/src/gateway/server-methods/mcp-app.test.ts b/src/gateway/server-methods/mcp-app.test.ts index 28db39edc742..0d80c6de1555 100644 --- a/src/gateway/server-methods/mcp-app.test.ts +++ b/src/gateway/server-methods/mcp-app.test.ts @@ -86,6 +86,11 @@ function runtime() { }, ], })), + listResources: vi.fn(async () => [{ uri: "ui://demo/state", name: "state" }]), + listResourceTemplates: vi.fn(async () => ({ resourceTemplates: [] })), + readResource: vi.fn(async (_serverName: string, uri: string) => ({ + contents: [{ uri, text: "resource" }], + })), }; } @@ -383,6 +388,8 @@ describe("MCP App gateway bridge", () => { }); it("rechecks current widget authority for every interactive capability", async () => { + const activeRuntime = runtime(); + mocks.peekSessionMcpRuntime.mockReturnValue(activeRuntime); view.authorizeAppInteraction = vi.fn(async () => false); const params = { sessionKey: "agent:main:main", viewId: "cv_app" }; @@ -400,9 +407,76 @@ describe("MCP App gateway bridge", () => { expect(listed.mock.calls[0]?.[0]).toBe(false); const called = await invoke("mcp.app.callTool", { ...params, toolName: "shared" }); expect(called.mock.calls[0]?.[0]).toBe(false); - expect(view.authorizeAppInteraction).toHaveBeenCalledTimes(4); + const resources = await invoke("mcp.app.listResources", params); + expect(resources.mock.calls[0]?.[0]).toBe(false); + const templates = await invoke("mcp.app.listResourceTemplates", params); + expect(templates.mock.calls[0]?.[0]).toBe(false); + const resource = await invoke("mcp.app.readResource", { + ...params, + uri: "ui://demo/state", + }); + expect(resource.mock.calls[0]?.[0]).toBe(false); + expect(view.authorizeAppInteraction).toHaveBeenCalledTimes(7); + expect(activeRuntime.listResources).not.toHaveBeenCalled(); + expect(activeRuntime.listResourceTemplates).not.toHaveBeenCalled(); + expect(activeRuntime.readResource).not.toHaveBeenCalled(); }); + it.each([ + { + capability: "resource list", + method: "mcp.app.listResources" as const, + params: {}, + runtimeMethod: "listResources" as const, + result: [{ uri: "ui://demo/state", name: "state" }], + }, + { + capability: "resource template list", + method: "mcp.app.listResourceTemplates" as const, + params: {}, + runtimeMethod: "listResourceTemplates" as const, + result: { resourceTemplates: [{ uriTemplate: "ui://demo/{id}", name: "demo" }] }, + }, + { + capability: "resource read", + method: "mcp.app.readResource" as const, + params: { uri: "ui://demo/state" }, + runtimeMethod: "readResource" as const, + result: { contents: [{ uri: "ui://demo/state", text: "protected" }] }, + }, + ])( + "withholds $capability results when widget authority is revoked in flight", + async (testCase) => { + const resourceStarted = createDeferred(); + const releaseResource = createDeferred(); + const activeRuntime = runtime(); + activeRuntime[testCase.runtimeMethod].mockImplementationOnce(async () => { + resourceStarted.resolve(); + return (await releaseResource.promise) as never; + }); + mocks.peekSessionMcpRuntime.mockReturnValue(activeRuntime); + let grantActive = true; + view.authorizeAppInteraction = vi.fn(async () => grantActive); + + const pending = invoke(testCase.method, { + sessionKey: "agent:main:main", + viewId: "cv_app", + ...testCase.params, + }); + await resourceStarted.promise; + expect(view.authorizeAppInteraction).toHaveBeenCalledOnce(); + grantActive = false; + releaseResource.resolve(testCase.result); + + const denied = await pending; + expect(denied.mock.calls[0]?.[0]).toBe(false); + expect(denied.mock.calls[0]?.[2]).toMatchObject({ + message: "MCP App widget grant is no longer active", + }); + expect(view.authorizeAppInteraction).toHaveBeenCalledTimes(2); + }, + ); + it("filters model-only tools from app discovery and execution", async () => { const params = { sessionKey: "agent:main:main", viewId: "cv_app" }; const listed = await invoke("mcp.app.listTools", params); diff --git a/test/e2e/qa-lab/runtime/gateway-mcp-app-grant-revalidation.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-mcp-app-grant-revalidation.e2e.test.ts index 72569258f1f6..537c78459832 100644 --- a/test/e2e/qa-lab/runtime/gateway-mcp-app-grant-revalidation.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-mcp-app-grant-revalidation.e2e.test.ts @@ -129,6 +129,23 @@ async function postStandalone(params: { }); } +async function readStandaloneResource(params: { + gateway: GatewayHandle; + ticket: string; +}): Promise { + return await fetch(new URL("/__openclaw__/mcp-app/view", params.gateway.baseUrl), { + method: "POST", + headers: { + Authorization: `MCP-App ${params.ticket}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "resources/read", + params: { uri: "ui://parity/app" }, + }), + }); +} + describe("Gateway MCP App board grant revalidation", () => { it( "rejects a standalone tool call revoked during a real catalog refresh", @@ -256,6 +273,11 @@ describe("Gateway MCP App board grant revalidation", () => { const ticket = standaloneUrl.hash.slice(1); expect(ticket).not.toBe(""); + const allowedResource = await readStandaloneResource({ gateway, ticket }); + const allowedResourceBody: unknown = await allowedResource.json(); + expect(allowedResource.status).toBe(200); + expect(JSON.stringify(allowedResourceBody)).toContain("Parity MCP App"); + await fs.writeFile(armPath, "armed\n"); const notificationCall = await postStandalone({ gateway, @@ -274,14 +296,22 @@ describe("Gateway MCP App board grant revalidation", () => { const denied = await pendingCall; const deniedBody: unknown = await denied.json(); + const deniedResource = await readStandaloneResource({ gateway, ticket }); + const deniedResourceBody: unknown = await deniedResource.json(); const executedMarkers = await readExecutedMarkers(eventPath); expect({ status: denied.status, error: isRecord(deniedBody) ? deniedBody.error : undefined, + resourceStatus: deniedResource.status, + resourceError: isRecord(deniedResourceBody) ? deniedResourceBody.error : undefined, + leakedResource: JSON.stringify(deniedResourceBody).includes("Parity MCP App"), postRevocationExecuted: executedMarkers.includes(POST_REVOCATION_MARKER), }).toEqual({ status: 403, error: "MCP App widget grant is no longer active", + resourceStatus: 403, + resourceError: "MCP App widget grant is no longer active", + leakedResource: false, postRevocationExecuted: false, }); } catch (error) { diff --git a/ui/src/components/mcp-app-security.test.ts b/ui/src/components/mcp-app-security.test.ts index 3b2da347de9e..9ab1d631f3c5 100644 --- a/ui/src/components/mcp-app-security.test.ts +++ b/ui/src/components/mcp-app-security.test.ts @@ -14,11 +14,14 @@ describe("MCP App sandbox security", () => { }); it("advertises update-model-context text support only when the handler path exists", () => { - expect(buildMcpAppHostCapabilities(undefined, true, true)).toMatchObject({ + expect(buildMcpAppHostCapabilities(undefined, true, true, true)).toMatchObject({ message: { text: {} }, + serverResources: {}, updateModelContext: { text: {} }, }); - expect(buildMcpAppHostCapabilities(undefined, true, false)).not.toHaveProperty( + const readOnly = buildMcpAppHostCapabilities(undefined, false, false, false); + expect(readOnly).not.toHaveProperty("serverResources"); + expect(buildMcpAppHostCapabilities(undefined, true, false, true)).not.toHaveProperty( "updateModelContext", ); }); diff --git a/ui/src/components/mcp-app-security.ts b/ui/src/components/mcp-app-security.ts index c1f4373e3a84..0ef83fc29d93 100644 --- a/ui/src/components/mcp-app-security.ts +++ b/ui/src/components/mcp-app-security.ts @@ -102,12 +102,13 @@ export function buildMcpAppHostCapabilities( csp?: McpAppHostSandboxCsp, supportsMessage = false, supportsUpdateModelContext = false, + supportsServerResources = false, ): McpAppHostCapabilities { return { openLinks: {}, - serverResources: {}, serverTools: {}, sandbox: { csp: csp ?? {} }, + ...(supportsServerResources ? { serverResources: {} } : {}), ...(supportsMessage ? { message: { text: {} } } : {}), ...(supportsUpdateModelContext ? { updateModelContext: { text: {} } } : {}), }; diff --git a/ui/src/components/mcp-app-view.test.ts b/ui/src/components/mcp-app-view.test.ts index 807493ede90e..c52dcdccc643 100644 --- a/ui/src/components/mcp-app-view.test.ts +++ b/ui/src/components/mcp-app-view.test.ts @@ -198,7 +198,10 @@ describe("mcp-app-view localization", () => { it("accepts only focused visible plain-text ui/message requests through the chat seam", async () => { const { bridge, frame, view } = await mountBridge(`view-message-${crypto.randomUUID()}`); - expect(bridge.capabilities).toMatchObject({ message: { text: {} } }); + expect(bridge.capabilities).toMatchObject({ + message: { text: {} }, + serverResources: {}, + }); expect(bridge.messageHandler).toBeTypeOf("function"); const received: string[] = []; @@ -297,6 +300,7 @@ describe("mcp-app-view localization", () => { expect(bridge.capabilities).not.toHaveProperty("message"); expect(bridge.messageHandler).toBeUndefined(); expect(bridge.capabilities).not.toHaveProperty("updateModelContext"); + expect(bridge.capabilities).not.toHaveProperty("serverResources"); expect(bridge.updateModelContextHandler).toBeUndefined(); }); diff --git a/ui/src/components/mcp-app-view.ts b/ui/src/components/mcp-app-view.ts index 68687be31633..12f7f157153c 100644 --- a/ui/src/components/mcp-app-view.ts +++ b/ui/src/components/mcp-app-view.ts @@ -369,6 +369,7 @@ export class McpAppView extends LitElement { payload.csp, payload.messageSupported === true, payload.updateModelContextSupported === true, + payload.messageSupported === true, ), { hostContext: hostContext(mount, this.height) }, ); diff --git a/ui/src/e2e/mcp-app-conformance.e2e.test.ts b/ui/src/e2e/mcp-app-conformance.e2e.test.ts index f68db49e88c4..50d3d7f33132 100644 --- a/ui/src/e2e/mcp-app-conformance.e2e.test.ts +++ b/ui/src/e2e/mcp-app-conformance.e2e.test.ts @@ -44,6 +44,8 @@ const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM const describeConformance = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; const authValue = "test"; const sessionKey = "agent:main:mcp-app-conformance"; +const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofDir = path.resolve(".artifacts/control-ui-e2e/mcp-app-resource-revocation"); let browser: Browser; let controlUiServer: ControlUiE2eServer; @@ -526,7 +528,15 @@ describeConformance("MCP App Control UI and standalone host conformance", () => }, 120_000); it("drives the authenticated Control UI and ticketed standalone bridges", async () => { - const controlContext = await browser.newContext({ permissions: ["local-network-access"] }); + if (captureUiProof) { + await fs.mkdir(proofDir, { recursive: true }); + } + const controlContext = await browser.newContext({ + permissions: ["local-network-access"], + ...(captureUiProof + ? { recordVideo: { dir: proofDir, size: { width: 1280, height: 800 } } } + : {}), + }); openContexts.add(controlContext); const controlPage = await controlContext.newPage(); const browserDiagnostics: string[] = []; @@ -597,6 +607,11 @@ describeConformance("MCP App Control UI and standalone host conformance", () => await waitForTextContaining(app.locator("#model-tool"), "denied:"); await app.locator("#read-resource").click(); await waitForTextContaining(app.locator("#resource"), "resource-ok"); + if (captureUiProof) { + await controlPage.screenshot({ + path: path.join(proofDir, "control-ui-resource-allowed.png"), + }); + } const confirmedPrompts: string[] = []; controlPage.on("dialog", async (dialog) => { confirmedPrompts.push(dialog.message()); @@ -629,7 +644,12 @@ describeConformance("MCP App Control UI and standalone host conformance", () => expect(detachedDiagnostic).toBeGreaterThan(teardownDiagnostic); await expect.poll(() => controlPage.frames().length).toBe(1); - const standaloneContext = await browser.newContext({ permissions: ["local-network-access"] }); + const standaloneContext = await browser.newContext({ + permissions: ["local-network-access"], + ...(captureUiProof + ? { recordVideo: { dir: proofDir, size: { width: 1280, height: 800 } } } + : {}), + }); openContexts.add(standaloneContext); const authorizationHeaders: string[] = []; const requestUrls: string[] = []; @@ -681,6 +701,46 @@ describeConformance("MCP App Control UI and standalone host conformance", () => await app.locator("#call-app").click(); await waitForTextContaining(app.locator("#app-tool"), "companion-called"); + const activeView = getMcpAppViewLease(viewId, runtime); + if (!activeView) { + throw new Error("MCP App conformance view expired before revocation proof"); + } + activeView.authorizeAppInteraction = async () => false; + + // The already-initialized App retains its capability snapshot, so the + // authoritative request-time check must still withhold the resource. + await app.locator("#read-resource").click(); + await waitForTextContaining(app.locator("#resource"), "denied:"); + await waitForTextContaining(app.locator("#resource"), "resource-ok", false); + if (captureUiProof) { + await standalonePage.screenshot({ + path: path.join(proofDir, "standalone-resource-revoked.png"), + }); + } + + await standalonePage.reload(); + app = await findAppFrame(standalonePage); + await waitForTextContaining(app.locator("#capabilities"), "serverResources", false); + await app.locator("#read-resource").click(); + await waitForTextContaining(app.locator("#resource"), "denied:"); + + const revokedControlPage = await controlContext.newPage(); + await mountControlUiHost(revokedControlPage); + const revokedControlApp = await findAppFrame(revokedControlPage); + await waitForTextContaining( + revokedControlApp.locator("#capabilities"), + "serverResources", + false, + ); + await revokedControlApp.locator("#read-resource").click(); + await waitForTextContaining(revokedControlApp.locator("#resource"), "denied:"); + if (captureUiProof) { + await revokedControlPage.screenshot({ + path: path.join(proofDir, "control-ui-resource-revoked.png"), + }); + } + await revokedControlPage.close(); + const tampered = `${absoluteStandaloneUrl.slice(0, -1)}${absoluteStandaloneUrl.endsWith("a") ? "b" : "a"}`; const tamperedPage = await standaloneContext.newPage(); await tamperedPage.goto(tampered);