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:
Agustin Rivera
2026-04-10 11:31:41 -07:00
committed by GitHub
parent a52d38275e
commit daeb74920d
3 changed files with 608 additions and 62 deletions

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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,