diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 48d3c81..b957aac 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ActionResultSchema, + assertAllowedNavigateUrl, CloseSessionResultSchema, CompanionApiErrorBodySchema, CompanionCommandSuccessSchema, @@ -24,6 +25,12 @@ import { } from "./index.js"; describe("RuntimeCommandSchema", () => { + it("re-exports navigate url policy used after click-driven navigation", () => { + expect(() => assertAllowedNavigateUrl("http://127.0.0.1/secret")).toThrow( + "not allowed", + ); + }); + it("parses a valid navigate command", () => { const command = NavigateCommandSchema.parse({ action: "navigate", diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index a1f2328..3e56510 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -253,6 +253,8 @@ export const McpToolErrorEnvelopeSchema = z export type McpToolErrorEnvelope = z.infer; +export { assertAllowedNavigateUrl } from "./navigate-url.js"; + export function createTraceContext(): TraceContext { return TraceContextSchema.parse({ traceId: crypto.randomUUID(), diff --git a/packages/protocol/src/navigate-url.test.ts b/packages/protocol/src/navigate-url.test.ts new file mode 100644 index 0000000..2ed2e30 --- /dev/null +++ b/packages/protocol/src/navigate-url.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { assertAllowedNavigateUrl } from "./navigate-url.js"; + +describe("assertAllowedNavigateUrl", () => { + it("allows public http and https targets", () => { + expect(() => + assertAllowedNavigateUrl("https://example.com/path"), + ).not.toThrow(); + expect(() => assertAllowedNavigateUrl("http://example.org")).not.toThrow(); + }); + + it("does not treat public hostnames as IPv6 unique-local prefixes", () => { + expect(() => assertAllowedNavigateUrl("https://fd.com")).not.toThrow(); + expect(() => + assertAllowedNavigateUrl("https://fcbarcelona.com"), + ).not.toThrow(); + }); + + it("rejects file and non-http schemes", () => { + expect(() => assertAllowedNavigateUrl("file:///etc/passwd")).toThrow( + "http or https", + ); + expect(() => assertAllowedNavigateUrl("javascript:alert(1)")).toThrow( + "http or https", + ); + }); + + it("rejects localhost, trailing-dot localhost, and private networks", () => { + expect(() => assertAllowedNavigateUrl("http://localhost/admin")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://localhost./admin")).toThrow( + "not allowed", + ); + expect(() => + assertAllowedNavigateUrl("http://metadata.google.internal/"), + ).toThrow("not allowed"); + expect(() => + assertAllowedNavigateUrl("http://foo.localhost/admin"), + ).toThrow("not allowed"); + expect(() => assertAllowedNavigateUrl("http://127.0.0.1:8080")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://169.254.169.254/")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://192.168.1.1/")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://10.0.0.5/")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://172.16.0.1/")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://100.64.0.1/")).toThrow( + "not allowed", + ); + expect(() => + assertAllowedNavigateUrl("https://172.32.0.1/not-private"), + ).not.toThrow(); + }); + + it("rejects loopback IPv6 including bracketed and v4-mapped forms", () => { + expect(() => assertAllowedNavigateUrl("http://[::1]/")).toThrow( + "not allowed", + ); + expect(() => + assertAllowedNavigateUrl("http://[::ffff:127.0.0.1]/secret"), + ).toThrow("not allowed"); + expect(() => assertAllowedNavigateUrl("http://[fe80::1]/")).toThrow( + "not allowed", + ); + expect(() => assertAllowedNavigateUrl("http://[fc00::1]/")).toThrow( + "not allowed", + ); + }); + + it("rejects unparseable urls", () => { + expect(() => assertAllowedNavigateUrl("not a url")).toThrow("invalid"); + }); +}); diff --git a/packages/protocol/src/navigate-url.ts b/packages/protocol/src/navigate-url.ts new file mode 100644 index 0000000..41244ba --- /dev/null +++ b/packages/protocol/src/navigate-url.ts @@ -0,0 +1,127 @@ +const BLOCKED_EXACT_HOSTS = new Set(["localhost", "metadata.google.internal"]); + +/** + * Reject navigate/document URLs that can read local files or internal networks. + * + * @example + * assertAllowedNavigateUrl("https://example.com/path"); + * // throws for "http://127.0.0.1/secret" + */ +export function assertAllowedNavigateUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error("invalid navigate url"); + } + + const scheme = parsed.protocol.replace(":", "").toLowerCase(); + if (scheme !== "http" && scheme !== "https") { + throw new Error("navigate url must use http or https"); + } + + const hostname = normalizeHostname(parsed.hostname); + if (isBlockedHostname(hostname)) { + throw new Error("navigate url host is not allowed"); + } +} + +function normalizeHostname(hostname: string): string { + let host = hostname.toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } + if (host.endsWith(".")) { + host = host.slice(0, -1); + } + return host; +} + +function isBlockedHostname(hostname: string): boolean { + if (BLOCKED_EXACT_HOSTS.has(hostname) || hostname.endsWith(".localhost")) { + return true; + } + if (hostname.includes(":")) { + return isPrivateOrReservedIpv6(hostname); + } + return isPrivateOrReservedIpv4(hostname); +} + +function parseIpv4Octets(host: string): number[] | null { + const parts = host.split("."); + if (parts.length !== 4) { + return null; + } + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null; + } + const value = Number(part); + if (value < 0 || value > 255) { + return null; + } + octets.push(value); + } + return octets; +} + +function isPrivateOrReservedIpv4(host: string): boolean { + const octets = parseIpv4Octets(host); + if (octets === null) { + return false; + } + const [a, b] = octets; + if (a === undefined) { + return false; + } + if (a === 10 || a === 127 || a === 0) { + return true; + } + if (a === 169 && b === 254) { + return true; + } + if (a === 172 && b !== undefined && b >= 16 && b <= 31) { + return true; + } + if (a === 192 && b === 168) { + return true; + } + if (a === 100 && b !== undefined && b >= 64 && b <= 127) { + return true; + } + return false; +} + +function ipv4MappedFromIpv6(host: string): string | null { + const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(host); + if (dotted?.[1] !== undefined) { + return dotted[1]; + } + const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host); + if (hex?.[1] === undefined || hex[2] === undefined) { + return null; + } + const high = Number.parseInt(hex[1], 16); + const low = Number.parseInt(hex[2], 16); + return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; +} + +function isPrivateOrReservedIpv6(host: string): boolean { + if (host === "::1" || host === "0:0:0:0:0:0:0:1") { + return true; + } + const mappedIpv4 = ipv4MappedFromIpv6(host); + if (mappedIpv4 !== null) { + return isPrivateOrReservedIpv4(mappedIpv4); + } + const firstHextet = host.split(":")[0] ?? ""; + // Unique local fc00::/7 and link-local fe80::/10. + if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) { + return true; + } + if (/^fe[89ab]/i.test(firstHextet)) { + return true; + } + return false; +} diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts index 0b0c708..d29f489 100644 --- a/packages/runtime/src/index.test.ts +++ b/packages/runtime/src/index.test.ts @@ -65,9 +65,11 @@ function createMockBrowserTree() { })), }; const contextClose = vi.fn().mockResolvedValue(undefined); + const mockRoute = vi.fn().mockResolvedValue(undefined); const context = { newPage: vi.fn(async () => page), close: contextClose, + route: mockRoute, }; const browserClose = vi.fn().mockResolvedValue(undefined); const browser = { @@ -82,6 +84,8 @@ function createMockBrowserTree() { mockClick, mockFill, mockContent, + mockUrl, + mockRoute, contextClose, browserClose, }; @@ -153,6 +157,73 @@ describe("BrowserRuntime", () => { await rt.shutdown(); }); + it("installs a session request guard on createSession", async () => { + const { browser, mockRoute } = createMockBrowserTree(); + mockChromiumLaunch.mockResolvedValueOnce(browser); + + const rt = new BrowserRuntime({ headless: true }); + await rt.createSession(); + expect(mockRoute).toHaveBeenCalledWith("**/*", expect.any(Function)); + await rt.shutdown(); + }); + + it("rejects click navigation onto a loopback url and restores the prior page", async () => { + const tree = createMockBrowserTree(); + let currentUrl = "https://page.example/path"; + tree.mockUrl.mockImplementation(() => currentUrl); + tree.mockClick.mockImplementation(async () => { + currentUrl = "http://127.0.0.1:65500/secret"; + }); + tree.mockGoto.mockImplementation(async (url: string) => { + currentUrl = url; + }); + mockChromiumLaunch.mockResolvedValueOnce(tree.browser); + + const rt = new BrowserRuntime({ headless: true }); + try { + const { sessionId } = await rt.createSession(); + await expect( + rt.click({ + action: "click", + sessionId, + selector: "#to-internal", + }), + ).rejects.toMatchObject({ + code: "COMMAND_FAILED", + message: expect.stringContaining("127.0.0.1:65500"), + }); + expect(tree.mockGoto).toHaveBeenCalledWith("https://page.example/path", { + waitUntil: "domcontentloaded", + }); + expect(currentUrl).toBe("https://page.example/path"); + } finally { + await rt.shutdown(); + } + }); + + it("does not snapshot a loopback page and restores about:blank", async () => { + const tree = createMockBrowserTree(); + tree.mockUrl.mockReturnValue("http://127.0.0.1/secret"); + mockChromiumLaunch.mockResolvedValueOnce(tree.browser); + + const rt = new BrowserRuntime({ headless: true }); + try { + const { sessionId } = await rt.createSession(); + await expect( + rt.snapshot({ action: "snapshot", sessionId }), + ).rejects.toMatchObject({ + code: "COMMAND_FAILED", + message: expect.stringContaining("127.0.0.1"), + }); + expect(tree.mockContent).not.toHaveBeenCalled(); + expect(tree.mockGoto).toHaveBeenCalledWith("about:blank", { + waitUntil: "domcontentloaded", + }); + } finally { + await rt.shutdown(); + } + }); + it("clicks and types", async () => { const { mockClick, mockFill, browser } = createMockBrowserTree(); mockChromiumLaunch.mockResolvedValueOnce(browser); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..6692d2b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -13,6 +13,10 @@ import { } from "@webchain/protocol"; import type { Browser, BrowserContext, Page } from "playwright"; import { chromium, webkit } from "playwright"; +import { + enforceAllowedPageUrl, + installSessionRequestGuard, +} from "./navigation-guard.js"; import { mapCommandFailure, mapPlaywrightLaunchError, @@ -74,6 +78,7 @@ export class BrowserRuntime { const context = await browser.newContext(); try { + await installSessionRequestGuard(context); const page = await context.newPage(); const sessionId = randomUUID(); const pageId = randomUUID(); @@ -114,6 +119,7 @@ export class BrowserRuntime { const session = this.getSession(command.sessionId); try { const page = session.page; + await enforceAllowedPageUrl(page, "about:blank"); const html = await page.content(); /** Covers sync throws (some Playwright APIs throw before returning a Promise). */ const safe = async (run: () => Promise): Promise => { @@ -165,8 +171,10 @@ export class BrowserRuntime { async click(command: ClickCommand): Promise { const session = this.getSession(command.sessionId); + const previousUrl = session.page.url(); try { await session.page.locator(command.selector).first().click(); + await enforceAllowedPageUrl(session.page, previousUrl); } catch (error) { throw mapCommandFailure(error); } @@ -180,8 +188,10 @@ export class BrowserRuntime { async type(command: TypeCommand): Promise { const session = this.getSession(command.sessionId); + const previousUrl = session.page.url(); try { await session.page.locator(command.selector).first().fill(command.text); + await enforceAllowedPageUrl(session.page, previousUrl); } catch (error) { throw mapCommandFailure(error); } diff --git a/packages/runtime/src/navigation-guard.test.ts b/packages/runtime/src/navigation-guard.test.ts new file mode 100644 index 0000000..b5c6b77 --- /dev/null +++ b/packages/runtime/src/navigation-guard.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import { + assertAllowedPageUrl, + blockedPageUrlError, + enforceAllowedPageUrl, + installSessionRequestGuard, + isIdleBrowserUrl, + restoreAllowedPageUrl, + shouldAllowSessionRequest, +} from "./navigation-guard.js"; + +describe("isIdleBrowserUrl", () => { + it("accepts about:blank only", () => { + expect(isIdleBrowserUrl("about:blank")).toBe(true); + expect(isIdleBrowserUrl("about:blank?foo")).toBe(true); + expect(isIdleBrowserUrl("https://example.com")).toBe(false); + }); +}); + +describe("assertAllowedPageUrl", () => { + it("allows about:blank, data, and public http(s)", () => { + expect(() => assertAllowedPageUrl("about:blank")).not.toThrow(); + expect(() => assertAllowedPageUrl("data:text/html,hi")).not.toThrow(); + expect(() => assertAllowedPageUrl("https://example.com")).not.toThrow(); + }); + + it("rejects loopback after a click-driven navigation", () => { + expect(() => assertAllowedPageUrl("http://127.0.0.1/secret")).toThrow( + "not allowed", + ); + }); +}); + +describe("shouldAllowSessionRequest", () => { + it("allows public http, data, blob, and about", () => { + expect(shouldAllowSessionRequest("https://cdn.example/app.js")).toBe(true); + expect(shouldAllowSessionRequest("data:text/plain,hi")).toBe(true); + expect(shouldAllowSessionRequest("blob:https://example.com/uuid")).toBe( + true, + ); + expect(shouldAllowSessionRequest("about:blank")).toBe(true); + }); + + it("blocks file and internal http targets", () => { + expect(shouldAllowSessionRequest("file:///etc/passwd")).toBe(false); + expect(shouldAllowSessionRequest("http://127.0.0.1/secret")).toBe(false); + expect(shouldAllowSessionRequest("not a url")).toBe(false); + }); +}); + +describe("installSessionRequestGuard", () => { + it("continues allowed requests and aborts internal ones", async () => { + const continueFn = vi.fn().mockResolvedValue(undefined); + const abort = vi.fn().mockResolvedValue(undefined); + let handler: + | ((route: { + request: () => { url: () => string }; + continue: () => Promise; + abort: (reason: string) => Promise; + }) => unknown) + | undefined; + const context = { + route: vi.fn(async (_pattern: string, routeHandler: typeof handler) => { + handler = routeHandler; + }), + }; + + await installSessionRequestGuard(context as never); + expect(handler).toBeDefined(); + if (handler === undefined) { + throw new Error("expected route handler"); + } + + await handler({ + request: () => ({ url: () => "https://example.com/index.html" }), + continue: continueFn, + abort, + }); + expect(continueFn).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + + await handler({ + request: () => ({ url: () => "http://127.0.0.1/secret" }), + continue: continueFn, + abort, + }); + expect(abort).toHaveBeenCalledWith("blockedbyclient"); + expect(continueFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("blockedPageUrlError", () => { + it("includes the host and uses COMMAND_FAILED", () => { + const err = blockedPageUrlError("http://127.0.0.1:9/secret?token=1"); + expect(err.code).toBe("COMMAND_FAILED"); + expect(err.message).toContain("127.0.0.1:9"); + expect(err.message).not.toContain("token=1"); + }); + + it("uses a fallback host label for unparseable urls", () => { + const err = blockedPageUrlError("::"); + expect(err.message).toContain("invalid-url"); + }); +}); + +describe("restoreAllowedPageUrl", () => { + it("goes back to a public previous url", async () => { + const goto = vi.fn().mockResolvedValue(undefined); + const page = { + url: () => "http://127.0.0.1/secret", + goto, + }; + await restoreAllowedPageUrl(page as never, "https://page.example/path"); + expect(goto).toHaveBeenCalledWith("https://page.example/path", { + waitUntil: "domcontentloaded", + }); + }); + + it("falls back to about:blank when previous url is also blocked", async () => { + const goto = vi.fn().mockResolvedValue(undefined); + const page = { + url: () => "http://127.0.0.1/secret", + goto, + }; + await restoreAllowedPageUrl(page as never, "http://192.168.0.1/"); + expect(goto).toHaveBeenCalledWith("about:blank", { + waitUntil: "domcontentloaded", + }); + }); + + it("skips goto when already on the restore target", async () => { + const goto = vi.fn(); + await restoreAllowedPageUrl( + { url: () => "https://page.example/path", goto } as never, + "https://page.example/path", + ); + expect(goto).not.toHaveBeenCalled(); + }); + + it("falls back to about:blank when restore goto fails", async () => { + const goto = vi + .fn() + .mockRejectedValueOnce(new Error("nav fail")) + .mockResolvedValueOnce(undefined); + await restoreAllowedPageUrl( + { url: () => "http://127.0.0.1/secret", goto } as never, + "https://page.example/path", + ); + expect(goto).toHaveBeenNthCalledWith(2, "about:blank", { + waitUntil: "domcontentloaded", + }); + }); +}); + +describe("enforceAllowedPageUrl", () => { + it("is a no-op on a public url", async () => { + const goto = vi.fn(); + await enforceAllowedPageUrl( + { url: () => "https://example.com", goto } as never, + "about:blank", + ); + expect(goto).not.toHaveBeenCalled(); + }); + + it("restores and throws on a loopback url", async () => { + const goto = vi.fn().mockResolvedValue(undefined); + await expect( + enforceAllowedPageUrl( + { url: () => "http://127.0.0.1/secret", goto } as never, + "https://page.example/", + ), + ).rejects.toMatchObject({ + code: "COMMAND_FAILED", + message: expect.stringContaining("127.0.0.1"), + }); + expect(goto).toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/navigation-guard.ts b/packages/runtime/src/navigation-guard.ts new file mode 100644 index 0000000..ae76bf8 --- /dev/null +++ b/packages/runtime/src/navigation-guard.ts @@ -0,0 +1,107 @@ +import { assertAllowedNavigateUrl } from "@webchain/protocol"; +import type { BrowserContext, Page, Route } from "playwright"; +import { WebchainRuntimeError } from "./runtime-error.js"; + +/** Initial Playwright documents are not agent navigations and must keep working. */ +export function isIdleBrowserUrl(url: string): boolean { + return url === "about:blank" || url.startsWith("about:blank?"); +} + +/** + * Allow data/blob/about subresources; block file and internal http(s) targets. + * + * @example + * shouldAllowSessionRequest("https://example.com/app.js"); // true + * shouldAllowSessionRequest("http://127.0.0.1/secret"); // false + */ +export function shouldAllowSessionRequest(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + const scheme = parsed.protocol.replace(":", "").toLowerCase(); + if (scheme === "about" || scheme === "data" || scheme === "blob") { + return true; + } + try { + assertAllowedNavigateUrl(url); + return true; + } catch { + return false; + } +} + +/** Same policy as session requests: public http(s), about/data/blob; not file or internal. */ +export function assertAllowedPageUrl(url: string): void { + if (shouldAllowSessionRequest(url)) { + return; + } + assertAllowedNavigateUrl(url); +} + +/** Abort in-session requests whose URL would reach files or internal networks. */ +export async function installSessionRequestGuard( + context: BrowserContext, +): Promise { + await context.route("**/*", (route: Route) => { + if (shouldAllowSessionRequest(route.request().url())) { + return route.continue(); + } + return route.abort("blockedbyclient"); + }); +} + +export function blockedPageUrlError(url: string): WebchainRuntimeError { + let host = "invalid-url"; + try { + host = new URL(url).host; + } catch { + host = "invalid-url"; + } + return new WebchainRuntimeError( + "COMMAND_FAILED", + `Blocked navigation to disallowed url host: ${host}`, + ); +} + +function isSafeRestoreTarget(url: string): boolean { + try { + assertAllowedPageUrl(url); + return true; + } catch { + return false; + } +} + +export async function restoreAllowedPageUrl( + page: Page, + previousUrl: string, +): Promise { + const target = isSafeRestoreTarget(previousUrl) ? previousUrl : "about:blank"; + if (page.url() === target) { + return; + } + try { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } catch { + await page + .goto("about:blank", { waitUntil: "domcontentloaded" }) + .catch(() => undefined); + } +} + +/** Throw and leave the page on `restoreUrl` (or about:blank) if the current URL is blocked. */ +export async function enforceAllowedPageUrl( + page: Page, + restoreUrl: string, +): Promise { + const url = page.url(); + try { + assertAllowedPageUrl(url); + } catch { + await restoreAllowedPageUrl(page, restoreUrl); + throw blockedPageUrlError(url); + } +} diff --git a/services/companion/src/local-browser-loop.integration.test.ts b/services/companion/src/local-browser-loop.integration.test.ts index 75905b0..2ea35cf 100644 --- a/services/companion/src/local-browser-loop.integration.test.ts +++ b/services/companion/src/local-browser-loop.integration.test.ts @@ -1,3 +1,5 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { BrowserRuntime } from "@webchain/runtime"; import type { FastifyInstance } from "fastify"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -96,4 +98,85 @@ describe("local browser loop (integration)", () => { expect(errBody.code).toBe("SESSION_NOT_FOUND"); expect(errBody.trace.traceId.length).toBeGreaterThan(0); }); + + it("blocks click navigation onto loopback and does not snapshot victim html", async () => { + const marker = "WEBCHAIN_INTERNAL_MARKER_9f3c"; + const victim = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(`${marker}`); + }); + await new Promise((resolve) => { + victim.listen(0, "127.0.0.1", () => resolve()); + }); + const address = victim.address(); + if (address === null || typeof address === "string") { + victim.close(); + throw new Error("expected TCP address for victim server"); + } + const { port } = address satisfies AddressInfo; + + try { + const sessionRes = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "x-webchain-token": token }, + }); + expect(sessionRes.statusCode).toBe(200); + const sessionBody = JSON.parse(sessionRes.body) as { sessionId: string }; + const { sessionId } = sessionBody; + + const attackerHtml = `go`; + const nav = await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { + action: "navigate", + sessionId, + url: `data:text/html;charset=utf-8,${encodeURIComponent(attackerHtml)}`, + }, + }); + expect(nav.statusCode).toBe(200); + + const click = await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { + action: "click", + sessionId, + selector: "#to-internal", + }, + }); + expect(click.statusCode).toBe(502); + const clickBody = JSON.parse(click.body) as { code?: string }; + expect(clickBody.code).toBe("COMMAND_FAILED"); + + const snap = await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { action: "snapshot", sessionId }, + }); + expect(snap.statusCode).toBe(200); + expect(snap.body).not.toContain(marker); + + await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { action: "closeSession", sessionId }, + }); + } finally { + await new Promise((resolve, reject) => { + victim.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); });