mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
fix(browser): guard existing-session navigation (#64370)
* fix(browser): guard existing-session navigation Co-authored-by: zsx <git@zsxsoft.com> * fix(browser): tighten interaction navigation guard * fix(browser): tighten existing-session nav guard * fix(browser): fail closed on unstable existing-session probes * fix(browser): add follow-up probe for late URL transitions in existing-session nav guard * fix(browser): keep probing through full navigation window * fix(browser): reset stability flag on probe error in existing-session nav guard * chore(changelog): add Chrome MCP interaction SSRF guard entry --------- Co-authored-by: zsx <git@zsxsoft.com> Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/dependencies: pin axios to 1.15.0 and add a plugin install dependency denylist that blocks known malicious packages before install. (#63891) Thanks @mmaps.
|
||||
- Browser/security: apply three-phase interaction navigation guard to pressKey and type(submit) so delayed JS redirects from keypress cannot bypass SSRF policy. (#63889) Thanks @mmaps.
|
||||
|
||||
- Browser/security: guard existing-session Chrome MCP interaction routes with SSRF post-checks so delayed navigation from click, type, press, and evaluate cannot bypass the configured policy. (#64370) Thanks @eleqtrizit.
|
||||
## 2026.4.9
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
import type { BrowserRequest } from "./types.js";
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
profileCtx: {
|
||||
profile: {
|
||||
driver: "existing-session" as const,
|
||||
name: "chrome-live",
|
||||
},
|
||||
ensureTabAvailable: vi.fn(async () => ({
|
||||
targetId: "7",
|
||||
url: "https://example.com",
|
||||
})),
|
||||
},
|
||||
tab: {
|
||||
targetId: "7",
|
||||
url: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
const chromeMcpMocks = vi.hoisted(() => ({
|
||||
clickChromeMcpElement: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: vi.fn(async () => {}),
|
||||
evaluateChromeMcpScript: vi.fn(async () => "https://example.com"),
|
||||
fillChromeMcpElement: vi.fn(async () => {}),
|
||||
fillChromeMcpForm: vi.fn(async () => {}),
|
||||
hoverChromeMcpElement: vi.fn(async () => {}),
|
||||
pressChromeMcpKey: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const navigationGuardMocks = vi.hoisted(() => ({
|
||||
assertBrowserNavigationAllowed: vi.fn(async () => {}),
|
||||
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
|
||||
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
|
||||
}));
|
||||
|
||||
vi.mock("../chrome-mcp.js", () => ({
|
||||
clickChromeMcpElement: chromeMcpMocks.clickChromeMcpElement,
|
||||
closeChromeMcpTab: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: chromeMcpMocks.dragChromeMcpElement,
|
||||
evaluateChromeMcpScript: chromeMcpMocks.evaluateChromeMcpScript,
|
||||
fillChromeMcpElement: chromeMcpMocks.fillChromeMcpElement,
|
||||
fillChromeMcpForm: chromeMcpMocks.fillChromeMcpForm,
|
||||
hoverChromeMcpElement: chromeMcpMocks.hoverChromeMcpElement,
|
||||
pressChromeMcpKey: chromeMcpMocks.pressChromeMcpKey,
|
||||
resizeChromeMcpPage: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation-guard.js", () => navigationGuardMocks);
|
||||
|
||||
vi.mock("./agent.shared.js", () => ({
|
||||
getPwAiModule: vi.fn(async () => null),
|
||||
handleRouteError: vi.fn(),
|
||||
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
|
||||
requirePwAi: vi.fn(async () => {
|
||||
throw new Error("Playwright should not be used for existing-session tests");
|
||||
}),
|
||||
resolveProfileContext: vi.fn(() => routeState.profileCtx),
|
||||
resolveTargetIdFromBody: vi.fn((body: Record<string, unknown>) =>
|
||||
typeof body.targetId === "string" ? body.targetId : undefined,
|
||||
),
|
||||
withPlaywrightRouteContext: vi.fn(),
|
||||
withRouteTabContext: vi.fn(async ({ run }: { run: (args: unknown) => Promise<void> }) => {
|
||||
await run({
|
||||
profileCtx: routeState.profileCtx,
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
tab: routeState.tab,
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEFAULT_SSRF_POLICY = { allowPrivateNetwork: false } as const;
|
||||
|
||||
const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
|
||||
|
||||
function getActPostHandler(
|
||||
ssrfPolicy: { allowPrivateNetwork: false } | null = DEFAULT_SSRF_POLICY,
|
||||
) {
|
||||
const { app, postHandlers } = createBrowserRouteApp();
|
||||
registerBrowserAgentActRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
evaluateEnabled: true,
|
||||
ssrfPolicy: ssrfPolicy ?? undefined,
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
const handler = postHandlers.get("/act");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("existing-session interaction navigation guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
for (const fn of Object.values(chromeMcpMocks)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
for (const fn of Object.values(navigationGuardMocks)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
chromeMcpMocks.evaluateChromeMcpScript.mockResolvedValue("https://example.com");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function runAction(
|
||||
body: Record<string, unknown>,
|
||||
ssrfPolicy: { allowPrivateNetwork: false } | null = DEFAULT_SSRF_POLICY,
|
||||
) {
|
||||
const handler = getActPostHandler(ssrfPolicy);
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending = handler?.({ params: {}, query: {}, body }, response.res);
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
return response;
|
||||
}
|
||||
|
||||
it("checks navigation after click and key-driven submit paths", async () => {
|
||||
const clickResponse = await runAction({ kind: "click", ref: "btn-1" });
|
||||
const typeResponse = await runAction({
|
||||
kind: "type",
|
||||
ref: "field-1",
|
||||
text: "hello",
|
||||
submit: true,
|
||||
});
|
||||
|
||||
expect(clickResponse.statusCode).toBe(200);
|
||||
expect(typeResponse.statusCode).toBe(200);
|
||||
expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledOnce();
|
||||
expect(chromeMcpMocks.pressChromeMcpKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "Enter" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(6);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rechecks the page url after delayed navigation-triggering interactions", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce(42 as never)
|
||||
.mockResolvedValueOnce("https://example.com" as never)
|
||||
.mockResolvedValueOnce("http://169.254.169.254/latest/meta-data/" as never)
|
||||
.mockResolvedValueOnce("http://169.254.169.254/latest/meta-data/" as never);
|
||||
|
||||
const response = await runAction({ kind: "evaluate", fn: "() => document.title" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledTimes(4);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ url: "http://169.254.169.254/latest/meta-data/" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ url: "http://169.254.169.254/latest/meta-data/" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when location probes never return a usable url", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never)
|
||||
.mockResolvedValueOnce(undefined as never)
|
||||
.mockResolvedValueOnce(null as never)
|
||||
.mockResolvedValueOnce(" " as never);
|
||||
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending =
|
||||
handler?.(
|
||||
{ params: {}, query: {}, body: { kind: "evaluate", fn: "() => 1" } },
|
||||
response.res,
|
||||
) ?? Promise.resolve();
|
||||
void pending.catch(() => {});
|
||||
const completion = (async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
})();
|
||||
|
||||
await expect(completion).rejects.toThrow("Unable to verify stable post-interaction navigation");
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when a later post-action probe becomes unreadable", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never) // action evaluate
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 1
|
||||
.mockResolvedValueOnce(undefined as never) // location probe 2 - unreadable
|
||||
.mockResolvedValueOnce(undefined as never) // location probe 3 - unreadable
|
||||
.mockResolvedValueOnce(undefined as never); // follow-up probe - still unreadable
|
||||
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending =
|
||||
handler?.(
|
||||
{ params: {}, query: {}, body: { kind: "evaluate", fn: "() => 1" } },
|
||||
response.res,
|
||||
) ?? Promise.resolve();
|
||||
void pending.catch(() => {});
|
||||
const completion = (async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
})();
|
||||
|
||||
await expect(completion).rejects.toThrow("Unable to verify stable post-interaction navigation");
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledOnce();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("confirms stability via follow-up probe when URL changes on the last loop iteration", async () => {
|
||||
// Probe 1 (action evaluate result): returns the action value
|
||||
// Location probe 1 (0ms): fails (context churn)
|
||||
// Location probe 2 (250ms): reads safe URL A
|
||||
// Location probe 3 (500ms): reads safe URL B (late navigation)
|
||||
// Follow-up probe (500ms later): reads URL B again → stable, success
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never) // action evaluate result
|
||||
.mockRejectedValueOnce(new Error("context churn") as never) // location probe 1 fails
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 2: URL A
|
||||
.mockResolvedValueOnce("https://safe-redirect.com" as never) // location probe 3: URL B (changed)
|
||||
.mockResolvedValueOnce("https://safe-redirect.com" as never); // follow-up: URL B again → stable
|
||||
|
||||
const response = await runAction({ kind: "evaluate", fn: "() => 1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// 1 action call + 5 location probes (3 in loop + 1 failed + 1 follow-up)
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledTimes(5);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(3);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ url: "https://safe-redirect.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ url: "https://safe-redirect.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps probing through the full window before declaring navigation stable", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never) // action evaluate result
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 1
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 2
|
||||
.mockResolvedValueOnce("https://safe-redirect.com" as never) // location probe 3
|
||||
.mockResolvedValueOnce("https://safe-redirect.com" as never); // follow-up confirms late redirect
|
||||
|
||||
const response = await runAction({ kind: "evaluate", fn: "() => 1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledTimes(5);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(4);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ url: "https://safe-redirect.com" }),
|
||||
);
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ url: "https://safe-redirect.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when follow-up probe sees yet another URL change", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never) // action evaluate result
|
||||
.mockResolvedValueOnce("https://a.com" as never) // location probe 1
|
||||
.mockResolvedValueOnce("https://b.com" as never) // location probe 2: changed
|
||||
.mockResolvedValueOnce("https://c.com" as never) // location probe 3: changed again
|
||||
.mockResolvedValueOnce("https://d.com" as never); // follow-up: still changing
|
||||
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending =
|
||||
handler?.(
|
||||
{ params: {}, query: {}, body: { kind: "evaluate", fn: "() => 1" } },
|
||||
response.res,
|
||||
) ?? Promise.resolve();
|
||||
void pending.catch(() => {});
|
||||
const completion = (async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
})();
|
||||
|
||||
await expect(completion).rejects.toThrow("Unable to verify stable post-interaction navigation");
|
||||
});
|
||||
|
||||
it("fails closed when a probe error follows two stable reads", async () => {
|
||||
// Probes 1 + 2 match (sawStableAllowedUrl would be true), probe 3 throws.
|
||||
// Guard must NOT return success — the throw invalidates prior stability.
|
||||
chromeMcpMocks.evaluateChromeMcpScript
|
||||
.mockResolvedValueOnce("result" as never) // action evaluate result
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 1
|
||||
.mockResolvedValueOnce("https://example.com" as never) // location probe 2 → stable pair
|
||||
.mockRejectedValueOnce(new Error("context destroyed") as never) // location probe 3 → error
|
||||
.mockRejectedValueOnce(new Error("context destroyed") as never); // follow-up → still errored
|
||||
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending =
|
||||
handler?.(
|
||||
{ params: {}, query: {}, body: { kind: "evaluate", fn: "() => 1" } },
|
||||
response.res,
|
||||
) ?? Promise.resolve();
|
||||
void pending.catch(() => {});
|
||||
const completion = (async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
})();
|
||||
|
||||
await expect(completion).rejects.toThrow("Unable to verify stable post-interaction navigation");
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips the guard when no SSRF policy is configured", async () => {
|
||||
const response = await runAction({ kind: "press", key: "Enter" }, null);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(chromeMcpMocks.pressChromeMcpKey).toHaveBeenCalledOnce();
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still probes navigation when the interaction command throws", async () => {
|
||||
chromeMcpMocks.clickChromeMcpElement.mockImplementationOnce(() => {
|
||||
throw new Error("stale element");
|
||||
});
|
||||
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
const pending =
|
||||
handler?.({ params: {}, query: {}, body: { kind: "click", ref: "btn-1" } }, response.res) ??
|
||||
Promise.resolve();
|
||||
void pending.catch(() => {});
|
||||
const completion = (async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
})();
|
||||
|
||||
await expect(completion).rejects.toThrow("stale element");
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalled();
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
resizeChromeMcpPage,
|
||||
} from "../chrome-mcp.js";
|
||||
import type { BrowserActRequest } from "../client-actions.types.js";
|
||||
import {
|
||||
assertBrowserNavigationResultAllowed,
|
||||
type BrowserNavigationPolicyOptions,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "../navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { matchBrowserUrlPattern } from "../url-pattern.js";
|
||||
@@ -38,6 +43,118 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;
|
||||
|
||||
async function readExistingSessionLocationHref(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
}): Promise<string> {
|
||||
const currentUrl = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
fn: "() => window.location.href",
|
||||
});
|
||||
if (typeof currentUrl !== "string") {
|
||||
throw new Error("Location probe returned a non-string result");
|
||||
}
|
||||
const normalizedUrl = currentUrl.trim();
|
||||
if (!normalizedUrl) {
|
||||
throw new Error("Location probe returned an empty URL");
|
||||
}
|
||||
return normalizedUrl;
|
||||
}
|
||||
|
||||
async function assertExistingSessionPostInteractionNavigationAllowed(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
|
||||
}): Promise<void> {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
|
||||
if (!ssrfPolicyOpts.ssrfPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastObservedUrl: string | undefined;
|
||||
let sawStableAllowedUrl = false;
|
||||
for (const delayMs of EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS) {
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
let currentUrl: string;
|
||||
try {
|
||||
currentUrl = await readExistingSessionLocationHref(params);
|
||||
} catch {
|
||||
sawStableAllowedUrl = false;
|
||||
continue;
|
||||
}
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: currentUrl,
|
||||
...ssrfPolicyOpts,
|
||||
});
|
||||
if (currentUrl === lastObservedUrl) {
|
||||
sawStableAllowedUrl = true;
|
||||
} else {
|
||||
sawStableAllowedUrl = false;
|
||||
}
|
||||
lastObservedUrl = currentUrl;
|
||||
}
|
||||
|
||||
if (sawStableAllowedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the loop exhausted without confirming stability but we did observe
|
||||
// at least one allowed URL, run a single follow-up probe so a late URL
|
||||
// transition that has already settled is not treated as a false failure.
|
||||
if (lastObservedUrl) {
|
||||
const lastDelay =
|
||||
EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS[
|
||||
EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS.length - 1
|
||||
];
|
||||
await sleep(lastDelay);
|
||||
try {
|
||||
const followUpUrl = await readExistingSessionLocationHref(params);
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: followUpUrl,
|
||||
...ssrfPolicyOpts,
|
||||
});
|
||||
if (followUpUrl === lastObservedUrl) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Probe failed — fall through to throw
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to verify stable post-interaction navigation");
|
||||
}
|
||||
|
||||
async function runExistingSessionActionWithNavigationGuard<T>(params: {
|
||||
execute: () => Promise<T>;
|
||||
guard?: Parameters<typeof assertExistingSessionPostInteractionNavigationAllowed>[0];
|
||||
}): Promise<T> {
|
||||
let actionError: unknown;
|
||||
let result: T | undefined;
|
||||
try {
|
||||
result = await params.execute();
|
||||
} catch (error) {
|
||||
actionError = error;
|
||||
}
|
||||
|
||||
if (params.guard) {
|
||||
await assertExistingSessionPostInteractionNavigationAllowed(params.guard);
|
||||
}
|
||||
|
||||
if (actionError) {
|
||||
throw actionError;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function buildExistingSessionWaitPredicate(params: {
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
@@ -250,6 +367,12 @@ export function registerBrowserAgentActRoutes(
|
||||
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const profileName = profileCtx.profile.name;
|
||||
if (isExistingSession) {
|
||||
const existingSessionNavigationGuard = {
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
ssrfPolicy,
|
||||
};
|
||||
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
|
||||
if (unsupportedMessage) {
|
||||
return jsonActError(
|
||||
@@ -261,83 +384,116 @@ export function registerBrowserAgentActRoutes(
|
||||
}
|
||||
switch (action.kind) {
|
||||
case "click":
|
||||
await clickChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
doubleClick: action.doubleClick ?? false,
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
clickChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
doubleClick: action.doubleClick ?? false,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
case "type":
|
||||
await fillChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
value: action.text,
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: async () => {
|
||||
await fillChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
value: action.text,
|
||||
});
|
||||
if (action.submit) {
|
||||
await pressChromeMcpKey({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
key: "Enter",
|
||||
});
|
||||
}
|
||||
},
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
if (action.submit) {
|
||||
await pressChromeMcpKey({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
key: "Enter",
|
||||
});
|
||||
}
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "press":
|
||||
await pressChromeMcpKey({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
key: action.key,
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
pressChromeMcpKey({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
key: action.key,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "hover":
|
||||
await hoverChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
hoverChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "scrollIntoView":
|
||||
await evaluateChromeMcpScript({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
|
||||
args: [action.ref!],
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
evaluateChromeMcpScript({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
|
||||
args: [action.ref!],
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "drag":
|
||||
await dragChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fromUid: action.startRef!,
|
||||
toUid: action.endRef!,
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
dragChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fromUid: action.startRef!,
|
||||
toUid: action.endRef!,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "select":
|
||||
await fillChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
value: action.values[0] ?? "",
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
fillChromeMcpElement({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
uid: action.ref!,
|
||||
value: action.values[0] ?? "",
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "fill":
|
||||
await fillChromeMcpForm({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
elements: action.fields.map((field) => ({
|
||||
uid: field.ref,
|
||||
value: String(field.value ?? ""),
|
||||
})),
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
fillChromeMcpForm({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
elements: action.fields.map((field) => ({
|
||||
uid: field.ref,
|
||||
value: String(field.value ?? ""),
|
||||
})),
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "resize":
|
||||
@@ -365,12 +521,16 @@ export function registerBrowserAgentActRoutes(
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
case "evaluate": {
|
||||
const result = await evaluateChromeMcpScript({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fn: action.fn,
|
||||
args: action.ref ? [action.ref] : undefined,
|
||||
const result = await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
evaluateChromeMcpScript({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
fn: action.fn,
|
||||
args: action.ref ? [action.ref] : undefined,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user