browser: route existing-session user profile through browser nodes (#68891)

* browser: route user profile through browser nodes

* browser: align existing-session node docs

* browser: preserve host fallback on node discovery errors

* browser: preserve configured node pin errors

* browser: widen config mock in node pin test
This commit is contained in:
Mariano
2026-04-19 12:21:23 +02:00
committed by GitHub
parent d83215084f
commit 8cb73844c8
8 changed files with 150 additions and 27 deletions

View File

@@ -113,7 +113,12 @@ const gatewayMocks = vi.hoisted(() => ({
vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
loadConfig: vi.fn<
() => {
browser: Record<string, unknown>;
gateway?: { nodes?: { browser?: { node?: string } } };
}
>(() => ({ browser: {} })),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
@@ -340,7 +345,7 @@ describe("browser tool snapshot maxChars", () => {
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using profile=user (even in sandboxed sessions)", async () => {
it("keeps profile=user off the sandbox browser when no node is selected", async () => {
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
@@ -360,7 +365,7 @@ describe("browser tool snapshot maxChars", () => {
);
});
it("defaults to host for custom existing-session profiles too", async () => {
it("keeps custom existing-session profiles off the sandbox browser too", async () => {
setResolvedBrowserProfiles({
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
@@ -470,7 +475,7 @@ describe("browser tool snapshot maxChars", () => {
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("keeps user profile on host when node proxy is available", async () => {
it("routes profile=user through the node proxy when one is available", async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
@@ -478,6 +483,113 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "user" });
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 25000 },
expect.objectContaining({
nodeId: "node-1",
command: "browser.proxy",
params: expect.objectContaining({
profile: "user",
path: "/",
method: "GET",
timeoutMs: 20000,
}),
}),
);
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it("falls back to the host for profile=user when node discovery errors", async () => {
nodesUtilsMocks.listNodes.mockRejectedValueOnce(new Error("gateway unavailable"));
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "user" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ profile: "user" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("preserves configured node pins when profile=user node discovery errors", async () => {
nodesUtilsMocks.listNodes.mockRejectedValueOnce(new Error("gateway unavailable"));
configMocks.loadConfig.mockReturnValue({
browser: {},
gateway: { nodes: { browser: { node: "node-1" } } },
});
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await expect(tool.execute?.("call-1", { action: "status", profile: "user" })).rejects.toThrow(
/gateway unavailable/i,
);
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it('allows profile="user" with target="node"', async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "user", target: "node" });
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 25000 },
expect.objectContaining({
nodeId: "node-1",
command: "browser.proxy",
params: expect.objectContaining({
profile: "user",
path: "/",
method: "GET",
}),
}),
);
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it('allows profile="user" with an explicit node pin', async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "user", node: "node-1" });
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 25000 },
expect.objectContaining({
nodeId: "node-1",
command: "browser.proxy",
params: expect.objectContaining({
profile: "user",
path: "/",
method: "GET",
}),
}),
);
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it('keeps profile="user" on the host when target="host" is explicit', async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "user", target: "host" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ profile: "user" }),

View File

@@ -380,7 +380,7 @@ export function createBrowserTool(opts?: {
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
'For the logged-in user browser on the local host, use profile="user". A supported Chromium-based browser (v144+) must be running. Use only when existing logins/cookies matter and the user is present.',
'For the logged-in user browser, use profile="user". A supported Chromium-based browser (v144+) must be running on the selected host or browser node. Use only when existing logins/cookies matter and the user is present.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
@@ -395,31 +395,39 @@ export function createBrowserTool(opts?: {
const profile = readStringParam(params, "profile");
const requestedNode = readStringParam(params, "node");
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
const configuredNode = browserToolDeps.loadConfig().gateway?.nodes?.browser?.node?.trim();
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
}
// User-browser profiles (existing-session) are host-only.
// existing-session profiles can attach through the selected host or browser node,
// but they must never fall back into the sandbox browser.
const isUserBrowserProfile = shouldPreferHostForProfile(profile);
if (isUserBrowserProfile) {
if (requestedNode || target === "node") {
throw new Error(`profile="${profile}" only supports the local host browser.`);
}
if (target === "sandbox") {
throw new Error(
`profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`,
);
}
if (!target && !requestedNode) {
target = "host";
}
}
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
});
let nodeTarget: BrowserNodeTarget | null = null;
try {
nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
});
} catch (error) {
// Keep the logged-in user browser usable on the host when auto-discovery
// of browser nodes fails transiently. Explicit node requests still fail.
if (!(isUserBrowserProfile && !target && !requestedNode && !configuredNode)) {
throw error;
}
}
if (isUserBrowserProfile && !target && !requestedNode && !nodeTarget) {
target = "host";
}
const resolvedTarget = target === "node" ? undefined : target;
const baseUrl = nodeTarget