Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/browsie/docs/stagehand-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Browsie supports these typed actions:
It does not run model-written JavaScript in the Eve process. Snapshot IDs expire after page state
changes, and the next action must use a new snapshot.

Navigation accepts only HTTP and HTTPS URLs without embedded credentials. A destination cannot
change the browser provider. Local fixtures require the operator to set `STAGEHAND_BROWSER=local`;
a localhost URL never switches a hosted session to the application server's local browser.

## Hosted session settings

The Browserbase adapter requests `proxies: true`, `keepAlive: true`, and
Expand Down
2 changes: 2 additions & 0 deletions projects/browsie/docs/vault-and-otp.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ For automated 1Password access, create a least-privilege service account, grant

The model can list only labels, host mappings, field availability, and opaque item/field references. `vault_login` resolves values server-side immediately before filling. Stagehand v4.0.2 does not expose the older `act(..., { variables })` API; Browsie therefore uses a browser-session-bound `experimentalBatch` adapter with Stagehand logging disabled and redacted results. A success selector is mandatory because filling and clicking alone do not prove authentication.

Vault login requires HTTPS on an allowed hostname. The runtime rechecks the destination before each credential fill and submit, including an OTP step after navigation. Hosted login also requires `BROWSIE_LOG_SESSION=false` before the browser session starts. Changing this setting requires a new session; it cannot disable recording retroactively.

TOTP codes are generated server-side using RFC 6238 and are never stored. Challenges without a mapped TOTP secret must park the Eve task in `waiting_for_user`; the interactive Browserbase Live View remains the primary safe handoff.
7 changes: 6 additions & 1 deletion projects/browsie/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@
"app/handoff",
"LICENSE",
"dist/shared"
]
],
"pnpm": {
"overrides": {
"eve>undici": "8.10.2"
}
}
}
11 changes: 7 additions & 4 deletions projects/browsie/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 40 additions & 16 deletions projects/browsie/server/browser-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ export class BrowsieBrowserSession {
} = {},
) {}

async start(forceLocal = false): Promise<void> {
async start(): Promise<void> {
if (this.closeRequested)
throw new DOMException("The browser session was closed.", "AbortError");
if (this.stagehand && this.browser && !this.browser.closed) return;
const startPromise = this.startPromise ?? this.startOnce(forceLocal);
const startPromise = this.startPromise ?? this.startOnce();
this.startPromise = startPromise;
try {
await startPromise;
Expand All @@ -83,10 +83,10 @@ export class BrowsieBrowserSession {
}
}

private async startOnce(forceLocal: boolean): Promise<void> {
private async startOnce(): Promise<void> {
this.state.browser.status = "starting";
const started = performance.now();
const requested = browserProvider(forceLocal);
const requested = browserProvider();
let contextStatus: "draft" | "saved" | undefined;

try {
Expand Down Expand Up @@ -257,13 +257,19 @@ export class BrowsieBrowserSession {
}

async run(actions: RunAction[]): Promise<{ completed: number; url: string }> {
for (const item of actions) {
if (item.action !== "goto") continue;
const destination = new URL(item.url);
if (
!["https:", "http:"].includes(destination.protocol) ||
destination.username ||
destination.password
)
throw new Error("Browser navigation requires an HTTP(S) URL without embedded credentials.");
}
const release = await this.acquireOperation();
try {
const localFixture = actions.some(
(item) =>
item.action === "goto" && /^http:\/\/(?:127\.0\.0\.1|localhost)(?::|\/)/.test(item.url),
);
await this.start(localFixture);
await this.start();
await this.gateCaptcha();
const started = performance.now();
const resolvedActions = actions.map((item) =>
Expand Down Expand Up @@ -348,21 +354,39 @@ export class BrowsieBrowserSession {
try {
await this.start();
await this.gateCaptcha();
if (this.browserbase && !this.sessionLogsDisabled)
throw new Error("Hosted credential operations require session logging to be disabled.");
const started = performance.now();
const result = await this.requireStagehand().experimentalBatch(
async ({ page }, values) => {
const before = new URL(await page.url());
if (!values.allowedHosts.includes(before.hostname.toLowerCase()))
throw new Error("host mismatch");
const checkDestination = async () => {
const destination = new URL(await page.url());
if (
destination.protocol !== "https:" ||
destination.username ||
destination.password ||
!values.allowedHosts.includes(destination.hostname.toLowerCase())
)
throw new Error("Unsafe credential destination.");
};
await checkDestination();
await page.locator(values.usernameTarget).fill(values.username);
await checkDestination();
await page.locator(values.passwordTarget).fill(values.password);
if (values.totpTarget && values.totp && !values.totpAfterSubmit)
if (values.totpTarget && values.totp && !values.totpAfterSubmit) {
await checkDestination();
await page.locator(values.totpTarget).fill(values.totp);
}
await checkDestination();
await page.locator(values.submitTarget).click();
await page.waitForTimeout(1_000);
if (values.totpTarget && values.totp && values.totpAfterSubmit) {
await checkDestination();
await page.locator(values.totpTarget).fill(values.totp);
if (values.otpSubmitTarget) await page.locator(values.otpSubmitTarget).click();
if (values.otpSubmitTarget) {
await checkDestination();
await page.locator(values.otpSubmitTarget).click();
}
await page.waitForTimeout(1_000);
}
const url = await page.url(),
Expand Down Expand Up @@ -840,8 +864,8 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

export function browserProvider(forceLocal = false): "local" | "browserbase" {
if (forceLocal || process.env.STAGEHAND_BROWSER === "local") return "local";
export function browserProvider(): "local" | "browserbase" {
if (process.env.STAGEHAND_BROWSER === "local") return "local";
if (process.env.STAGEHAND_BROWSER === "browserbase") return "browserbase";
return process.env.BROWSERBASE_API_KEY ? "browserbase" : "local";
}
Expand Down
150 changes: 150 additions & 0 deletions projects/browsie/tests/browser-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { BrowsieBrowserSession } from "../server/browser-session";
import type { ConversationState } from "../server/types";

const login = {
allowedHosts: ["shop.example"],
username: "synthetic-user",
password: "synthetic-password",
totp: "synthetic-otp",
usernameTarget: "#username",
passwordTarget: "#password",
totpTarget: "#otp",
submitTarget: "#submit",
otpSubmitTarget: "#otp-submit",
successTarget: "#success",
};

function fixture() {
const state: ConversationState = {
id: "browser-security-test",
traces: [],
browser: { provider: "not-started", status: "idle" },
};
const session = new BrowsieBrowserSession(state);
let url = "https://shop.example/login";
const fill = vi.fn<(target: string, value: string) => Promise<void>>().mockResolvedValue();
const click = vi.fn<(target: string) => Promise<void>>().mockResolvedValue();
const page = {
url: async () => url,
title: async () => "Test shop",
goto: vi.fn(async (next: string) => { url = next; }),
waitForLoadState: vi.fn(async () => {}),
waitForTimeout: vi.fn(async () => {}),
locator: (target: string) => ({
fill: (value: string) => fill(target, value),
click: () => click(target),
count: async () => 1,
}),
};
const batch = vi.fn(async <T, A>(
callback: (context: { page: typeof page }, args: A) => Promise<T>,
args: A,
) => callback({ page }, args));
const start = vi.spyOn(session, "start").mockResolvedValue();
vi.spyOn(session as unknown as { gateCaptcha(): Promise<void> }, "gateCaptcha")
.mockResolvedValue();
Object.assign(session, {
stagehand: { experimentalBatch: batch },
browserbase: {},
sessionLogsDisabled: true,
});
return { session, state, start, batch, page, fill, click, setUrl: (next: string) => { url = next; } };
}

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

describe("vault login credential boundary", () => {
it.each([
"http://shop.example/login",
"https://other.example/login",
"https://user:password@shop.example/login",
])("does not fill credentials at an unsafe destination: %s", async (url) => {
const { session, setUrl, fill } = fixture();
setUrl(url);
await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed");
expect(fill).not.toHaveBeenCalled();
});

it("refuses hosted login when session logging is enabled", async () => {
const { session, batch, fill } = fixture();
Object.assign(session, { sessionLogsDisabled: false });
await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed");
expect(batch).not.toHaveBeenCalled();
expect(fill).not.toHaveBeenCalled();
});

it("rechecks the destination before filling the password", async () => {
const { session, setUrl, fill } = fixture();
fill.mockImplementationOnce(async () => { setUrl("https://other.example/login"); });
await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed");
expect(fill.mock.calls).toEqual([[login.usernameTarget, login.username]]);
});

it.each([false, true])("rechecks the destination before OTP, afterSubmit=%s", async (afterSubmit) => {
const { session, setUrl, fill, click } = fixture();
if (afterSubmit) {
click.mockImplementationOnce(async () => { setUrl("https://other.example/otp"); });
} else {
fill.mockImplementation(async (target) => {
if (target === login.passwordTarget) setUrl("http://shop.example/otp");
});
}
await expect(session.secureLogin({ ...login, totpAfterSubmit: afterSubmit }))
.rejects.toThrow("Secure login failed");
expect(fill.mock.calls.some(([, value]) => value === login.totp)).toBe(false);
});

it("allows HTTPS login without exposing secrets in its result or trace", async () => {
const { session, state, fill } = fixture();
const result = await session.secureLogin(login);
expect(result.status).toBe("complete");
expect(fill).toHaveBeenCalledTimes(3);
for (const value of [login.username, login.password, login.totp]) {
expect(JSON.stringify({ state, result })).not.toContain(value);
}
});

it("redacts failures and releases the operation lock for the next attempt", async () => {
const { session, state, fill } = fixture();
fill.mockRejectedValueOnce(new Error(login.password));
await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed");
expect(JSON.stringify(state)).not.toContain(login.password);
await expect(session.secureLogin(login)).resolves.toMatchObject({ status: "complete" });
});
});

describe("browser navigation boundary", () => {
it.each([
"file:///tmp/bell-synthetic-fixture",
"javascript:alert(1)",
"data:text/html,example",
"https://user:password@shop.example/",
])("rejects unsafe navigation before starting a browser: %s", async (url) => {
const { session, start, batch } = fixture();
await expect(session.run([{ action: "goto", url }])).rejects.toThrow();
expect(start).not.toHaveBeenCalled();
expect(batch).not.toHaveBeenCalled();
});

it("validates the whole batch before executing an earlier action", async () => {
const { session, batch } = fixture();
await expect(session.run([
{ action: "goto", url: "https://shop.example/" },
{ action: "goto", url: "file:///tmp/bell-synthetic-fixture" },
])).rejects.toThrow();
expect(batch).not.toHaveBeenCalled();
});

it("does not let a loopback URL override the configured browser provider", async () => {
vi.stubEnv("STAGEHAND_BROWSER", "browserbase");
const { session, start, page } = fixture();
await session.run([{ action: "goto", url: "http://127.0.0.1:4318/" }]);
expect(start).toHaveBeenCalledWith();
expect(page.goto).toHaveBeenCalledWith("http://127.0.0.1:4318/");
});
});
Loading