diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts index 0b0c708..07c66ce 100644 --- a/packages/runtime/src/index.test.ts +++ b/packages/runtime/src/index.test.ts @@ -29,11 +29,14 @@ function createMockBrowserTree() { const mockAriaSnapshot = vi.fn(() => Promise.resolve('- heading "mock" [level=1]'), ); - const mockEvaluate = vi.fn((fn: unknown) => { + const mockEvaluate = vi.fn((fn: unknown, arg?: unknown) => { if (typeof fn !== "function") { return Promise.resolve(undefined); } const src = Function.prototype.toString.call(fn); + if (src.includes('type="password"')) { + return Promise.resolve(Array.isArray(arg) ? undefined : []); + } if (src.includes("innerText")) { return Promise.resolve("hello summary"); } @@ -82,6 +85,8 @@ function createMockBrowserTree() { mockClick, mockFill, mockContent, + mockEvaluate, + mockAriaSnapshot, contextClose, browserClose, }; @@ -139,7 +144,8 @@ describe("BrowserRuntime", () => { }); it("snapshots HTML with summarizeHtml and layered fields", async () => { - const { browser } = createMockBrowserTree(); + const { browser, mockEvaluate, mockContent, mockAriaSnapshot } = + createMockBrowserTree(); mockChromiumLaunch.mockResolvedValueOnce(browser); const rt = new BrowserRuntime({ headless: true }); @@ -150,6 +156,14 @@ describe("BrowserRuntime", () => { expect(snap.accessibilityTree).toBe('- heading "mock" [level=1]'); expect(snap.links).toEqual([]); expect(snap.landmarks).toEqual([]); + const blankCallOrder = mockEvaluate.mock.invocationCallOrder[0]; + const contentOrder = mockContent.mock.invocationCallOrder[0]; + const ariaOrder = mockAriaSnapshot.mock.invocationCallOrder[0]; + expect(typeof blankCallOrder).toBe("number"); + expect(typeof contentOrder).toBe("number"); + expect(typeof ariaOrder).toBe("number"); + expect(blankCallOrder as number).toBeLessThan(contentOrder as number); + expect(blankCallOrder as number).toBeLessThan(ariaOrder as number); await rt.shutdown(); }); @@ -211,7 +225,7 @@ describe("BrowserRuntime", () => { }); it("maps snapshot content errors to COMMAND_FAILED", async () => { - const { browser, mockContent } = createMockBrowserTree(); + const { browser, mockContent, mockEvaluate } = createMockBrowserTree(); mockContent.mockRejectedValueOnce(new Error("content-boom")); mockChromiumLaunch.mockResolvedValueOnce(browser); @@ -224,6 +238,10 @@ describe("BrowserRuntime", () => { code: "COMMAND_FAILED", message: "content-boom", }); + const restoreCalls = mockEvaluate.mock.calls.filter((call) => + Array.isArray(call[1]), + ); + expect(restoreCalls.length).toBeGreaterThan(0); } finally { await rt.shutdown(); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..250baf6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -18,7 +18,11 @@ import { mapPlaywrightLaunchError, WebchainRuntimeError, } from "./runtime-error.js"; -import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js"; +import { + extractLandmarks, + extractPageLinks, + withPasswordFieldsRedacted, +} from "./snapshot-helpers.js"; export interface BrowserRuntimeOptions { headless?: boolean; @@ -113,51 +117,9 @@ export class BrowserRuntime { async snapshot(command: SnapshotCommand): Promise { const session = this.getSession(command.sessionId); try { - const page = session.page; - const html = await page.content(); - /** Covers sync throws (some Playwright APIs throw before returning a Promise). */ - const safe = async (run: () => Promise): Promise => { - try { - return await run(); - } catch { - return undefined; - } - }; - const [accessibilityTree, domSummary, links, landmarks] = - await Promise.all([ - /** Playwright 1.58+ removed `page.accessibility`; use ARIA snapshot on `body`. */ - safe(() => page.locator("body").ariaSnapshot()), - safe(() => - page.evaluate(() => { - const t = document.body?.innerText ?? ""; - return t.replace(/\s+/g, " ").trim().slice(0, 2000); - }), - ), - safe(() => extractPageLinks(page, 80)), - safe(() => extractLandmarks(page)), - ]); - let accessibilityJson: unknown = accessibilityTree ?? undefined; - if (accessibilityJson !== undefined) { - try { - accessibilityJson = JSON.parse( - JSON.stringify(accessibilityJson, (_k, v) => - typeof v === "bigint" ? v.toString() : v, - ), - ); - } catch { - accessibilityJson = undefined; - } - } - return { - sessionId: command.sessionId, - url: page.url(), - title: await page.title(), - htmlSnippet: summarizeHtml(html), - domSummary, - accessibilityTree: accessibilityJson, - links, - landmarks, - }; + return await withPasswordFieldsRedacted(session.page, () => + capturePageSnapshot(session.page, command.sessionId), + ); } catch (error) { throw mapCommandFailure(error); } @@ -247,6 +209,55 @@ export { WebchainRuntimeError, } from "./runtime-error.js"; +async function capturePageSnapshot( + page: Page, + sessionId: string, +): Promise { + const html = await page.content(); + /** Covers sync throws (some Playwright APIs throw before returning a Promise). */ + const safe = async (run: () => Promise): Promise => { + try { + return await run(); + } catch { + return undefined; + } + }; + const [accessibilityTree, domSummary, links, landmarks] = await Promise.all([ + /** Playwright 1.58+ removed `page.accessibility`; use ARIA snapshot on `body`. */ + safe(() => page.locator("body").ariaSnapshot()), + safe(() => + page.evaluate(() => { + const t = document.body?.innerText ?? ""; + return t.replace(/\s+/g, " ").trim().slice(0, 2000); + }), + ), + safe(() => extractPageLinks(page, 80)), + safe(() => extractLandmarks(page)), + ]); + let accessibilityJson: unknown = accessibilityTree ?? undefined; + if (accessibilityJson !== undefined) { + try { + accessibilityJson = JSON.parse( + JSON.stringify(accessibilityJson, (_k, v) => + typeof v === "bigint" ? v.toString() : v, + ), + ); + } catch { + accessibilityJson = undefined; + } + } + return { + sessionId, + url: page.url(), + title: await page.title(), + htmlSnippet: summarizeHtml(html), + domSummary, + accessibilityTree: accessibilityJson, + links, + landmarks, + }; +} + export function summarizeHtml(html: string, limit = 1600) { return html.replace(/\s+/g, " ").trim().slice(0, limit); } diff --git a/packages/runtime/src/snapshot-helpers.test.ts b/packages/runtime/src/snapshot-helpers.test.ts index 565eba0..704c02a 100644 --- a/packages/runtime/src/snapshot-helpers.test.ts +++ b/packages/runtime/src/snapshot-helpers.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js"; +import { + backupAndBlankPasswordFieldsInPage, + extractLandmarks, + extractPageLinks, + PASSWORD_FIELD_SELECTOR, + restorePasswordFieldsInPage, + withPasswordFieldsRedacted, +} from "./snapshot-helpers.js"; describe("extractPageLinks", () => { it("maps anchors and trims text, skipping empty href", async () => { @@ -86,3 +93,87 @@ describe("extractLandmarks", () => { expect(lm).toEqual([]); }); }); + +function makePasswordInput(value: string, attrValue: string | null) { + let attr = attrValue; + return { + value, + getAttribute: (name: string) => (name === "value" ? attr : null), + setAttribute: (name: string, next: string) => { + if (name === "value") { + attr = next; + } + }, + removeAttribute: (name: string) => { + if (name === "value") { + attr = null; + } + }, + }; +} + +describe("password snapshot redaction", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("targets password and autocomplete password fields", () => { + expect(PASSWORD_FIELD_SELECTOR).toContain('input[type="password"]'); + expect(PASSWORD_FIELD_SELECTOR).toContain("current-password"); + expect(PASSWORD_FIELD_SELECTOR).toContain("new-password"); + expect(PASSWORD_FIELD_SELECTOR).toContain("one-time-password"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("blanks matching inputs and restores value plus attribute", () => { + const pw = makePasswordInput("secret", "secret"); + vi.stubGlobal("document", { + querySelectorAll: () => [pw], + }); + + const backups = backupAndBlankPasswordFieldsInPage(); + expect(pw.value).toBe(""); + expect(pw.getAttribute("value")).toBeNull(); + restorePasswordFieldsInPage(backups); + expect(pw.value).toBe("secret"); + expect(pw.getAttribute("value")).toBe("secret"); + }); + + it("blanks during capture and restores afterward", async () => { + const pw = makePasswordInput("typed-secret", null); + vi.stubGlobal("document", { + querySelectorAll: () => [pw], + }); + const page = { + evaluate: (fn: (...args: never[]) => unknown, arg?: unknown) => + Promise.resolve(arg === undefined ? fn() : fn(arg as never)), + }; + + await withPasswordFieldsRedacted(page as never, async () => { + expect(pw.value).toBe(""); + expect(pw.getAttribute("value")).toBeNull(); + }); + expect(pw.value).toBe("typed-secret"); + }); + + it("restores fields when capture throws", async () => { + const pw = makePasswordInput("keep-me", "keep-me"); + vi.stubGlobal("document", { + querySelectorAll: () => [pw], + }); + const page = { + evaluate: (fn: (...args: never[]) => unknown, arg?: unknown) => + Promise.resolve(arg === undefined ? fn() : fn(arg as never)), + }; + + await expect( + withPasswordFieldsRedacted(page as never, async () => { + throw new Error("capture-boom"); + }), + ).rejects.toThrow("capture-boom"); + expect(pw.value).toBe("keep-me"); + expect(pw.getAttribute("value")).toBe("keep-me"); + }); +}); diff --git a/packages/runtime/src/snapshot-helpers.ts b/packages/runtime/src/snapshot-helpers.ts index 160cd01..23712e3 100644 --- a/packages/runtime/src/snapshot-helpers.ts +++ b/packages/runtime/src/snapshot-helpers.ts @@ -1,5 +1,89 @@ import type { Page } from "playwright"; +export type PasswordFieldBackup = { + value: string; + attrValue: string | null; +}; + +/** + * CSS selector for fields whose values must not appear in snapshots. + * Duplicated inside in-page evaluate helpers so Playwright can serialize them. + */ +export const PASSWORD_FIELD_SELECTOR = + 'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]'; + +/** + * Blank password inputs in the page document. Must stay serializable for Playwright. + * + * @example + * const backups = backupAndBlankPasswordFieldsInPage(); + */ +export function backupAndBlankPasswordFieldsInPage(): PasswordFieldBackup[] { + const selector = + 'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]'; + const inputs = Array.from(document.querySelectorAll(selector)); + const backups: PasswordFieldBackup[] = []; + for (let i = 0; i < inputs.length; i++) { + const el = inputs[i] as HTMLInputElement; + backups.push({ + value: el.value, + attrValue: el.getAttribute("value"), + }); + el.value = ""; + el.removeAttribute("value"); + } + return backups; +} + +/** + * Restore values saved by backupAndBlankPasswordFieldsInPage. + * + * @example + * restorePasswordFieldsInPage(backups); + */ +export function restorePasswordFieldsInPage( + backups: PasswordFieldBackup[], +): void { + const selector = + 'input[type="password"], input[autocomplete="current-password"], input[autocomplete="new-password"], input[autocomplete="one-time-password"]'; + const inputs = Array.from(document.querySelectorAll(selector)); + for (let i = 0; i < backups.length; i++) { + const el = inputs[i] as HTMLInputElement | undefined; + const backup = backups[i]; + if (!el || !backup) { + continue; + } + el.value = backup.value; + if (backup.attrValue === null) { + el.removeAttribute("value"); + } else { + el.setAttribute("value", backup.attrValue); + } + } +} + +/** + * Run a snapshot capture with password fields blanked, then restore them. + * + * @example + * const snap = await withPasswordFieldsRedacted(page, () => page.content()); + */ +export async function withPasswordFieldsRedacted( + page: Page, + run: () => Promise, +): Promise { + const backups = await page.evaluate(backupAndBlankPasswordFieldsInPage); + try { + return await run(); + } finally { + try { + await page.evaluate(restorePasswordFieldsInPage, backups); + } catch { + // Capture already finished; the page may have closed. + } + } +} + /** Collect up to `limit` anchor href/text pairs for agent navigation hints. */ export async function extractPageLinks(page: Page, limit: number) { return page.$$eval( diff --git a/services/companion/src/local-browser-loop.integration.test.ts b/services/companion/src/local-browser-loop.integration.test.ts index 75905b0..728a922 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 { createServer } 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"; @@ -5,6 +7,26 @@ import { createCompanionApp } from "./server.js"; const token = "integration-test-token"; +async function serveHtml( + html: string, +): Promise<{ url: string; close: () => Promise }> { + const server = createServer((_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(html); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const addr = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${addr.port}/`, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + describe("local browser loop (integration)", () => { let app: FastifyInstance; let runtime: BrowserRuntime; @@ -96,4 +118,66 @@ describe("local browser loop (integration)", () => { expect(errBody.code).toBe("SESSION_NOT_FOUND"); expect(errBody.trace.traceId.length).toBeGreaterThan(0); }); + + it("redacts password field values from snapshot output", async () => { + const filledSecret = "SuperSecretPw!42"; + const staticSecret = "StaticAttrSecret99"; + const loginHtml = `Login + + +`; + const page = await serveHtml(loginHtml); + + try { + const sessionRes = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "x-webchain-token": token }, + }); + expect(sessionRes.statusCode).toBe(200); + const { sessionId } = JSON.parse(sessionRes.body) as { + sessionId: string; + }; + + const nav = await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { action: "navigate", sessionId, url: page.url }, + }); + expect(nav.statusCode).toBe(200); + + const typed = await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { + action: "type", + sessionId, + selector: "#pw", + text: filledSecret, + }, + }); + expect(typed.statusCode).toBe(200); + + 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(filledSecret); + expect(snap.body).not.toContain(staticSecret); + + await app.inject({ + method: "POST", + url: "/commands", + headers: { "x-webchain-token": token }, + payload: { action: "closeSession", sessionId }, + }); + } finally { + await page.close(); + } + }); });