diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts
index 0b0c708..1a6e1fb 100644
--- a/packages/runtime/src/index.test.ts
+++ b/packages/runtime/src/index.test.ts
@@ -82,6 +82,8 @@ function createMockBrowserTree() {
mockClick,
mockFill,
mockContent,
+ mockUrl,
+ mockEvalAnchors,
contextClose,
browserClose,
};
@@ -153,6 +155,46 @@ describe("BrowserRuntime", () => {
await rt.shutdown();
});
+ it("redacts URL userinfo from navigate and snapshot outputs", async () => {
+ const { browser, mockUrl, mockContent, mockEvalAnchors } =
+ createMockBrowserTree();
+ mockUrl.mockReturnValue("https://alice:s3cret@page.example/path");
+ mockContent.mockResolvedValue(
+ '
x',
+ );
+ mockEvalAnchors.mockImplementation(
+ async (
+ _selector: string,
+ pageFunction: (
+ anchors: { href: string; textContent: string }[],
+ max: number,
+ ) => unknown,
+ max: number,
+ ) =>
+ pageFunction(
+ [{ href: "https://alice:s3cret@page.example/x", textContent: "x" }],
+ max,
+ ),
+ );
+ mockChromiumLaunch.mockResolvedValueOnce(browser);
+
+ const rt = new BrowserRuntime({ headless: true });
+ const { sessionId } = await rt.createSession();
+ const nav = await rt.navigate({
+ action: "navigate",
+ sessionId,
+ url: "https://alice:s3cret@example.com/",
+ });
+ expect(nav.url).toBe("https://page.example/path");
+ expect(nav.url).not.toContain("s3cret");
+
+ const snap = await rt.snapshot({ action: "snapshot", sessionId });
+ expect(snap.url).toBe("https://page.example/path");
+ expect(snap.htmlSnippet).not.toContain("s3cret");
+ expect(snap.links?.[0]?.href).toBe("https://page.example/x");
+ 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..8b14eec 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -19,6 +19,12 @@ import {
WebchainRuntimeError,
} from "./runtime-error.js";
import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js";
+import {
+ stripUserinfoDeep,
+ stripUserinfoFromLinks,
+ stripUserinfoFromText,
+ stripUserinfoFromUrl,
+} from "./url-redact.js";
export interface BrowserRuntimeOptions {
headless?: boolean;
@@ -103,11 +109,7 @@ export class BrowserRuntime {
throw mapCommandFailure(error);
}
- return {
- sessionId: command.sessionId,
- url: session.page.url(),
- title: await session.page.title(),
- };
+ return actionResult(command.sessionId, session.page);
}
async snapshot(command: SnapshotCommand): Promise {
@@ -150,12 +152,15 @@ export class BrowserRuntime {
}
return {
sessionId: command.sessionId,
- url: page.url(),
+ url: stripUserinfoFromUrl(page.url()),
title: await page.title(),
- htmlSnippet: summarizeHtml(html),
- domSummary,
- accessibilityTree: accessibilityJson,
- links,
+ htmlSnippet: stripUserinfoFromText(summarizeHtml(html)),
+ domSummary:
+ domSummary === undefined
+ ? undefined
+ : stripUserinfoFromText(domSummary),
+ accessibilityTree: stripUserinfoDeep(accessibilityJson),
+ links: links === undefined ? undefined : stripUserinfoFromLinks(links),
landmarks,
};
} catch (error) {
@@ -171,11 +176,7 @@ export class BrowserRuntime {
throw mapCommandFailure(error);
}
- return {
- sessionId: command.sessionId,
- url: session.page.url(),
- title: await session.page.title(),
- };
+ return actionResult(command.sessionId, session.page);
}
async type(command: TypeCommand): Promise {
@@ -186,11 +187,7 @@ export class BrowserRuntime {
throw mapCommandFailure(error);
}
- return {
- sessionId: command.sessionId,
- url: session.page.url(),
- title: await session.page.title(),
- };
+ return actionResult(command.sessionId, session.page);
}
async closeSession(
@@ -247,6 +244,17 @@ export {
WebchainRuntimeError,
} from "./runtime-error.js";
+async function actionResult(
+ sessionId: string,
+ page: Page,
+): Promise {
+ return {
+ sessionId,
+ url: stripUserinfoFromUrl(page.url()),
+ title: await page.title(),
+ };
+}
+
export function summarizeHtml(html: string, limit = 1600) {
return html.replace(/\s+/g, " ").trim().slice(0, limit);
}
diff --git a/packages/runtime/src/runtime-error.test.ts b/packages/runtime/src/runtime-error.test.ts
index 3896bbe..1bcef48 100644
--- a/packages/runtime/src/runtime-error.test.ts
+++ b/packages/runtime/src/runtime-error.test.ts
@@ -39,6 +39,15 @@ describe("mapCommandFailure", () => {
expect(err.code).toBe("COMMAND_FAILED");
expect(err.message).toContain("timed out");
});
+
+ it("redacts URL userinfo from Playwright error messages", () => {
+ const err = mapCommandFailure(
+ new Error("net::ERR_FAILED at https://alice:s3cret@127.0.0.1/x"),
+ );
+ expect(err.code).toBe("COMMAND_FAILED");
+ expect(err.message).not.toContain("s3cret");
+ expect(err.message).toContain("https://127.0.0.1/x");
+ });
});
describe("isExecutableMissingMessage", () => {
diff --git a/packages/runtime/src/runtime-error.ts b/packages/runtime/src/runtime-error.ts
index 95371ef..b814aaf 100644
--- a/packages/runtime/src/runtime-error.ts
+++ b/packages/runtime/src/runtime-error.ts
@@ -1,4 +1,5 @@
import type { RuntimeErrorCode } from "@webchain/protocol";
+import { stripUserinfoFromText } from "./url-redact.js";
const INSTALL_HINT =
"Playwright browser binaries are missing. Run: pnpm --filter @webchain/runtime exec playwright install chromium";
@@ -57,10 +58,11 @@ export function mapCommandFailure(error: unknown): WebchainRuntimeError {
}
const message = error instanceof Error ? error.message : String(error);
+ const redacted = stripUserinfoFromText(message);
return new WebchainRuntimeError(
"COMMAND_FAILED",
- message || "Command failed.",
+ redacted || "Command failed.",
{ cause: error },
);
}
diff --git a/packages/runtime/src/url-redact.test.ts b/packages/runtime/src/url-redact.test.ts
new file mode 100644
index 0000000..7ea6047
--- /dev/null
+++ b/packages/runtime/src/url-redact.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import {
+ stripUserinfoDeep,
+ stripUserinfoFromLinks,
+ stripUserinfoFromText,
+ stripUserinfoFromUrl,
+} from "./url-redact.js";
+
+describe("stripUserinfoFromUrl", () => {
+ it("removes user and password from http(s) URLs", () => {
+ expect(
+ stripUserinfoFromUrl("https://alice:s3cret@example.com/path?q=1"),
+ ).toBe("https://example.com/path?q=1");
+ });
+
+ it("removes username-only userinfo", () => {
+ expect(stripUserinfoFromUrl("https://alice@example.com/")).toBe(
+ "https://example.com/",
+ );
+ });
+
+ it("leaves URLs without userinfo unchanged", () => {
+ expect(stripUserinfoFromUrl("https://example.com/x")).toBe(
+ "https://example.com/x",
+ );
+ });
+
+ it("omits data: payloads so embedded credentials cannot hide in page.url()", () => {
+ const dataUrl =
+ "data:text/html," +
+ encodeURIComponent(
+ 'l',
+ );
+ const redacted = stripUserinfoFromUrl(dataUrl);
+ expect(redacted).toBe("data:text/html,");
+ expect(redacted).not.toContain("pw-in-href");
+ });
+
+ it("falls back to text stripping for non-URL strings", () => {
+ expect(
+ stripUserinfoFromUrl("see https://bob:pw@example.net/a and more"),
+ ).toBe("see https://example.net/a and more");
+ });
+});
+
+describe("stripUserinfoFromText", () => {
+ it("redacts credentials in HTML hrefs", () => {
+ const html = 'link';
+ expect(stripUserinfoFromText(html)).toBe(
+ 'link',
+ );
+ });
+
+ it("does not treat mailto addresses as userinfo URLs", () => {
+ expect(stripUserinfoFromText("contact mailto:user@example.com")).toBe(
+ "contact mailto:user@example.com",
+ );
+ });
+});
+
+describe("stripUserinfoFromLinks", () => {
+ it("redacts href and preserves link text", () => {
+ expect(
+ stripUserinfoFromLinks([
+ { href: "https://u:p@example.com/x", text: "x" },
+ ]),
+ ).toEqual([{ href: "https://example.com/x", text: "x" }]);
+ });
+});
+
+describe("stripUserinfoDeep", () => {
+ it("redacts nested strings", () => {
+ expect(
+ stripUserinfoDeep({
+ url: "https://u:p@example.com/",
+ items: ["https://a:b@example.net/"],
+ }),
+ ).toEqual({
+ url: "https://example.com/",
+ items: ["https://example.net/"],
+ });
+ });
+});
diff --git a/packages/runtime/src/url-redact.ts b/packages/runtime/src/url-redact.ts
new file mode 100644
index 0000000..19456db
--- /dev/null
+++ b/packages/runtime/src/url-redact.ts
@@ -0,0 +1,70 @@
+/**
+ * Strip URL userinfo so basic-auth credentials are not echoed to companion/MCP.
+ * Playwright `page.url()` and `HTMLAnchorElement.href` preserve `user:pass@`.
+ */
+
+/** Matches `scheme://userinfo@` including `user@host` and `user:pass@host`. */
+const USERINFO_IN_ABSOLUTE_URL =
+ /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/\s"'<>@]+@)/g;
+
+/**
+ * Remove embedded credentials from a single URL.
+ *
+ * @example
+ * stripUserinfoFromUrl("https://alice:pw@example.com/x")
+ * // => "https://example.com/x"
+ */
+export function stripUserinfoFromUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "data:") {
+ // data: URLs are the document; echoing the payload repeats href userinfo
+ // in percent-encoded form (not matched by the userinfo regex).
+ const comma = url.indexOf(",");
+ const withoutPayload = comma === -1 ? url : url.slice(0, comma + 1);
+ return stripUserinfoFromText(withoutPayload);
+ }
+ if (parsed.username !== "" || parsed.password !== "") {
+ parsed.username = "";
+ parsed.password = "";
+ }
+ return stripUserinfoFromText(parsed.href);
+ } catch {
+ return stripUserinfoFromText(url);
+ }
+}
+
+/**
+ * Remove `user:pass@` (or `user@`) from any absolute URLs inside free-form text.
+ */
+export function stripUserinfoFromText(text: string): string {
+ return text.replace(USERINFO_IN_ABSOLUTE_URL, "$1");
+}
+
+/** Redact `href` on extracted snapshot links. */
+export function stripUserinfoFromLinks(
+ links: { href: string; text: string }[],
+): { href: string; text: string }[] {
+ return links.map((link) => ({
+ ...link,
+ href: stripUserinfoFromUrl(link.href),
+ }));
+}
+
+/** Walk JSON-like snapshot trees and redact URLs in string leaves. */
+export function stripUserinfoDeep(value: unknown): unknown {
+ if (typeof value === "string") {
+ return stripUserinfoFromText(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map(stripUserinfoDeep);
+ }
+ if (value !== null && typeof value === "object") {
+ const out: Record = {};
+ for (const [key, child] of Object.entries(value)) {
+ out[key] = stripUserinfoDeep(child);
+ }
+ return out;
+ }
+ return value;
+}