mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
feat(browser): add stable tab handles and automation skill
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
import {
|
||||
@@ -82,6 +84,16 @@ describe("browser plugin", () => {
|
||||
expect(browserSecurityAuditCollectors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bundles the browser automation skill with the plugin", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "openclaw.plugin.json"), "utf8"),
|
||||
) as { skills?: string[] };
|
||||
const skillPath = path.join(__dirname, "skills", "browser-automation", "SKILL.md");
|
||||
|
||||
expect(manifest.skills).toEqual(["./skills"]);
|
||||
expect(fs.readFileSync(skillPath, "utf8")).toContain("name: browser-automation");
|
||||
});
|
||||
|
||||
it("forwards per-session browser options into the tool factory", async () => {
|
||||
const { api, registerTool } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "browser",
|
||||
"enabledByDefault": true,
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
83
extensions/browser/skills/browser-automation/SKILL.md
Normal file
83
extensions/browser/skills/browser-automation/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: browser-automation
|
||||
description: Use when controlling web pages with the OpenClaw browser tool, especially multi-step flows, login checks, tab management, or recovery from stale refs/timeouts.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Browser Automation
|
||||
|
||||
Use this skill when you need the `browser` tool for anything beyond a single page check.
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Check browser state before acting:
|
||||
- `action="status"` for availability.
|
||||
- `action="profiles"` if login state or profile choice matters.
|
||||
- `action="tabs"` before opening a new tab if retries/timeouts may have left windows behind.
|
||||
2. Prefer stable tab handles:
|
||||
- Open important tabs with `label`, for example `label="meet"`.
|
||||
- Use `tabId` handles like `t1` or labels like `meet` as `targetId` in later calls.
|
||||
- Avoid relying on raw DevTools `targetId` unless the tool just returned it.
|
||||
3. Read before you click:
|
||||
- Use `action="snapshot"` on the intended `targetId`.
|
||||
- Use the same `targetId` for follow-up actions so refs stay on the same tab.
|
||||
- For durable Playwright refs, request `refs="aria"` when supported.
|
||||
4. Act narrowly:
|
||||
- Prefer `action="act"` with a ref from the latest snapshot.
|
||||
- After navigation, modal changes, or form submission, snapshot again before the next action.
|
||||
- Avoid blind waits. Wait for visible UI state when possible.
|
||||
5. Report real blockers:
|
||||
- If the page needs login, permission, captcha, 2FA, camera/microphone approval, or another manual step, stop and tell the user exactly what is needed.
|
||||
- Do not claim the browser is not logged in just because the current page shows a permission or onboarding dialog. Inspect the visible UI first.
|
||||
|
||||
## Tab Hygiene
|
||||
|
||||
Before creating a tab for a named task, list tabs and reuse an existing matching label or URL when it is still usable.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{ "action": "tabs" }
|
||||
```
|
||||
|
||||
If no suitable tab exists:
|
||||
|
||||
```json
|
||||
{ "action": "open", "url": "https://example.com", "label": "task" }
|
||||
```
|
||||
|
||||
Then target it by label:
|
||||
|
||||
```json
|
||||
{ "action": "snapshot", "targetId": "task", "refs": "aria" }
|
||||
```
|
||||
|
||||
If a retry creates duplicates, close the extras by `tabId`:
|
||||
|
||||
```json
|
||||
{ "action": "close", "targetId": "t3" }
|
||||
```
|
||||
|
||||
## Stale Ref Recovery
|
||||
|
||||
If an action fails with a missing or stale ref:
|
||||
|
||||
1. Snapshot the same `targetId` again.
|
||||
2. Find the current visible control.
|
||||
3. Retry once with the new ref.
|
||||
4. If the UI moved to a blocker state, report the blocker instead of looping.
|
||||
|
||||
## Existing User Browser
|
||||
|
||||
Use `profile="user"` only when existing cookies/login matter. This attaches to the user's running Chromium-based browser.
|
||||
|
||||
For `profile="user"` and other existing-session profiles, omit `timeoutMs` on `act:type`, `evaluate`, `hover`, `scrollIntoView`, `drag`, `select`, and `fill`; that driver rejects per-call timeout overrides for those actions.
|
||||
|
||||
## Google Meet Notes
|
||||
|
||||
When creating or joining a Meet:
|
||||
|
||||
- Treat camera/microphone permission screens as progress, not login failure.
|
||||
- If asked whether people can hear you, click the microphone option when voice is required.
|
||||
- If Google asks for sign-in, 2FA, account chooser confirmation, or permission that needs user approval, report the exact manual action.
|
||||
- Use one labeled tab per meeting flow, for example `label="meet"`, and reuse it during retries.
|
||||
@@ -93,6 +93,7 @@ export const BrowserToolSchema = Type.Object({
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
url: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
label: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
maxChars: Type.Optional(Type.Number()),
|
||||
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
|
||||
|
||||
@@ -308,6 +308,7 @@ describe("browser tool description", () => {
|
||||
expect(tool.description).toContain('profile="user"');
|
||||
expect(tool.description).toContain("omit timeoutMs on act:type");
|
||||
expect(tool.description).toContain("existing-session profiles");
|
||||
expect(tool.description).toContain("browser-automation skill");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -384,7 +384,8 @@ export function createBrowserTool(opts?: {
|
||||
'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.',
|
||||
'For profile="user" or other existing-session profiles, omit timeoutMs on act:type, evaluate, hover, scrollIntoView, drag, select, and fill; that driver rejects per-call timeout overrides for those actions.',
|
||||
'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).",
|
||||
"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 tab operations, targetId also accepts tabId handles (t1) and labels from action=tabs.",
|
||||
"For multi-step browser work, login checks, stale refs, duplicate tabs, or Google Meet flows, use the bundled browser-automation skill when it is available.",
|
||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
|
||||
@@ -523,16 +524,20 @@ export function createBrowserTool(opts?: {
|
||||
return await executeTabsAction({ baseUrl, profile, proxyRequest });
|
||||
case "open": {
|
||||
const targetUrl = readTargetUrlParam(params);
|
||||
const label = normalizeOptionalString(params.label);
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
profile,
|
||||
body: { url: targetUrl },
|
||||
body: { url: targetUrl, ...(label ? { label } : {}) },
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, { profile });
|
||||
const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, {
|
||||
profile,
|
||||
label,
|
||||
});
|
||||
browserToolDeps.trackSessionBrowserTab({
|
||||
sessionKey: opts?.agentSessionKey,
|
||||
targetId: opened.targetId,
|
||||
|
||||
@@ -37,6 +37,21 @@ describe("browser target id resolution", () => {
|
||||
expect(res).toEqual({ ok: true, targetId: "FULL" });
|
||||
});
|
||||
|
||||
it("resolves exact tab ids and labels", () => {
|
||||
expect(
|
||||
resolveTargetIdFromTabs("t2", [
|
||||
{ targetId: "AAA", tabId: "t1" },
|
||||
{ targetId: "BBB", tabId: "t2", label: "docs" },
|
||||
]),
|
||||
).toEqual({ ok: true, targetId: "BBB" });
|
||||
expect(
|
||||
resolveTargetIdFromTabs("docs", [
|
||||
{ targetId: "AAA", tabId: "t1" },
|
||||
{ targetId: "BBB", tabId: "t2", label: "docs" },
|
||||
]),
|
||||
).toEqual({ ok: true, targetId: "BBB" });
|
||||
});
|
||||
|
||||
it("resolves unique prefixes (case-insensitive)", () => {
|
||||
const res = resolveTargetIdFromTabs("57a01309", [
|
||||
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },
|
||||
|
||||
@@ -200,13 +200,13 @@ export async function browserTabs(
|
||||
export async function browserOpenTab(
|
||||
baseUrl: string | undefined,
|
||||
url: string,
|
||||
opts?: { profile?: string },
|
||||
opts?: { profile?: string; label?: string },
|
||||
): Promise<BrowserTab> {
|
||||
const q = buildProfileQuery(opts?.profile);
|
||||
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ export type BrowserTransport = "cdp" | "chrome-mcp";
|
||||
|
||||
export type BrowserTab = {
|
||||
targetId: string;
|
||||
/** Stable, human-friendly tab handle for this profile runtime (for example t1). */
|
||||
tabId?: string;
|
||||
/** Optional user-assigned tab label. */
|
||||
label?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
wsUrl?: string;
|
||||
|
||||
@@ -72,6 +72,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
tabs: [
|
||||
{
|
||||
targetId: "PAGE-1",
|
||||
tabId: "t1",
|
||||
title: "WordPress",
|
||||
url: "https://example.com/wp-login.php",
|
||||
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
|
||||
@@ -93,6 +93,14 @@ function baseProfileContext() {
|
||||
url: "https://example.com",
|
||||
type: "page",
|
||||
})),
|
||||
labelTab: vi.fn(async (_targetId: string, label: string) => ({
|
||||
targetId: "T1",
|
||||
tabId: "t1",
|
||||
label,
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
type: "page",
|
||||
})),
|
||||
focusTab: vi.fn(async () => {}),
|
||||
closeTab: vi.fn(async () => {}),
|
||||
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
|
||||
@@ -118,6 +126,7 @@ function createRouteContext(profileCtx: ProfileContext, options?: { ssrfPolicy?:
|
||||
isReachable: profileCtx.isReachable,
|
||||
listTabs: profileCtx.listTabs,
|
||||
openTab: profileCtx.openTab,
|
||||
labelTab: profileCtx.labelTab,
|
||||
focusTab: profileCtx.focusTab,
|
||||
closeTab: profileCtx.closeTab,
|
||||
stopRunningBrowser: profileCtx.stopRunningBrowser,
|
||||
@@ -325,6 +334,29 @@ describe("browser tab routes", () => {
|
||||
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels tabs by friendly target handles", async () => {
|
||||
const profileCtx = createProfileContext();
|
||||
|
||||
const response = await callTabsAction({
|
||||
body: { action: "label", targetId: "t1", label: "meet" },
|
||||
profileCtx,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
ok: true,
|
||||
tab: {
|
||||
targetId: "T1",
|
||||
tabId: "t1",
|
||||
label: "meet",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
type: "page",
|
||||
},
|
||||
});
|
||||
expect(profileCtx.labelTab).toHaveBeenCalledWith("t1", "meet");
|
||||
});
|
||||
|
||||
it("redacts blocked tab URLs for /tabs/action list", async () => {
|
||||
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
|
||||
async (opts?: { url: string }) => {
|
||||
|
||||
@@ -121,6 +121,11 @@ function parseRequiredTargetId(res: BrowserResponse, rawTargetId: unknown): stri
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function readOptionalTabLabel(body: unknown): string | undefined {
|
||||
const label = toStringOrEmpty((body as { label?: unknown })?.label);
|
||||
return label || undefined;
|
||||
}
|
||||
|
||||
async function runTabTargetMutation(params: {
|
||||
req: BrowserRequest;
|
||||
res: BrowserResponse;
|
||||
@@ -170,6 +175,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
"/tabs/open",
|
||||
asyncBrowserRoute(async (req, res) => {
|
||||
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
|
||||
const label = readOptionalTabLabel(req.body);
|
||||
if (!url) {
|
||||
return jsonError(res, 400, "url is required");
|
||||
}
|
||||
@@ -185,7 +191,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
|
||||
});
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
const tab = await profileCtx.openTab(url);
|
||||
const tab = await profileCtx.openTab(url, { label });
|
||||
res.json(tab);
|
||||
},
|
||||
});
|
||||
@@ -275,7 +281,28 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
|
||||
if (action === "new") {
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
const tab = await profileCtx.openTab("about:blank");
|
||||
const tab = await profileCtx.openTab("about:blank", {
|
||||
label: readOptionalTabLabel(req.body),
|
||||
});
|
||||
return res.json({ ok: true, tab });
|
||||
}
|
||||
|
||||
if (action === "label") {
|
||||
if (!(await ensureBrowserRunning(profileCtx, res))) {
|
||||
return;
|
||||
}
|
||||
const targetId = parseRequiredTargetId(
|
||||
res,
|
||||
(req.body as { targetId?: unknown })?.targetId,
|
||||
);
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const label = readOptionalTabLabel(req.body);
|
||||
if (!label) {
|
||||
return jsonError(res, 400, "label is required");
|
||||
}
|
||||
const tab = await profileCtx.labelTab(targetId, label);
|
||||
return res.json({ ok: true, tab });
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,35 @@ describe("browser remote profile tab ops via Playwright", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assigns stable tab ids and resolves labels", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
page("A", "https://example.com"),
|
||||
page("B", "https://docs.example.com"),
|
||||
]);
|
||||
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = deps.createRemoteRouteHarness();
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((tab) => [tab.targetId, tab.tabId])).toEqual([
|
||||
["A", "t1"],
|
||||
["B", "t2"],
|
||||
]);
|
||||
|
||||
const labeled = await remote.labelTab("t2", "docs");
|
||||
expect(labeled).toMatchObject({ targetId: "B", tabId: "t2", label: "docs" });
|
||||
|
||||
await remote.focusTab("docs");
|
||||
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ targetId: "B" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
|
||||
const responses = [
|
||||
[
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
BrowserTab,
|
||||
ProfileRuntimeState,
|
||||
} from "./server-context.types.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
|
||||
type TabOpsDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
@@ -38,7 +40,8 @@ type TabOpsDeps = {
|
||||
|
||||
type ProfileTabOps = {
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
|
||||
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,6 +66,58 @@ type CdpTarget = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const TAB_LABEL_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
|
||||
|
||||
function normalizeTabLabel(label: string): string {
|
||||
const trimmed = label.trim();
|
||||
if (!TAB_LABEL_PATTERN.test(trimmed)) {
|
||||
throw new Error("tab label must be 1-64 chars and use only letters, numbers, _, ., :, or -");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getTabAliasState(
|
||||
profileState: ProfileRuntimeState,
|
||||
): NonNullable<ProfileRuntimeState["tabAliases"]> {
|
||||
profileState.tabAliases ??= { nextTabNumber: 1, byTargetId: {} };
|
||||
return profileState.tabAliases;
|
||||
}
|
||||
|
||||
function assignTabAlias(params: {
|
||||
profileState: ProfileRuntimeState;
|
||||
tab: BrowserTab;
|
||||
label?: string;
|
||||
}): BrowserTab {
|
||||
const aliases = getTabAliasState(params.profileState);
|
||||
let entry = aliases.byTargetId[params.tab.targetId];
|
||||
if (!entry) {
|
||||
entry = { tabId: `t${aliases.nextTabNumber}` };
|
||||
aliases.nextTabNumber += 1;
|
||||
aliases.byTargetId[params.tab.targetId] = entry;
|
||||
}
|
||||
if (params.label) {
|
||||
const label = normalizeTabLabel(params.label);
|
||||
for (const [targetId, current] of Object.entries(aliases.byTargetId)) {
|
||||
if (targetId !== params.tab.targetId && current.label === label) {
|
||||
delete current.label;
|
||||
}
|
||||
}
|
||||
entry.label = label;
|
||||
}
|
||||
return { ...params.tab, tabId: entry.tabId, ...(entry.label ? { label: entry.label } : {}) };
|
||||
}
|
||||
|
||||
function assignTabAliases(profileState: ProfileRuntimeState, tabs: BrowserTab[]): BrowserTab[] {
|
||||
const aliases = getTabAliasState(profileState);
|
||||
const liveTargetIds = new Set(tabs.map((tab) => tab.targetId));
|
||||
for (const targetId of Object.keys(aliases.byTargetId)) {
|
||||
if (!liveTargetIds.has(targetId)) {
|
||||
delete aliases.byTargetId[targetId];
|
||||
}
|
||||
}
|
||||
return tabs.map((tab) => assignTabAlias({ profileState, tab }));
|
||||
}
|
||||
|
||||
export function createProfileTabOps({
|
||||
profile,
|
||||
state,
|
||||
@@ -72,7 +127,7 @@ export function createProfileTabOps({
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
const readTabs = async (): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const { listChromeMcpTabs } = await getChromeMcpModule();
|
||||
return await listChromeMcpTabs(profile.name, profile.userDataDir);
|
||||
@@ -114,6 +169,11 @@ export function createProfileTabOps({
|
||||
.filter((t) => Boolean(t.targetId));
|
||||
};
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
const tabs = await readTabs();
|
||||
return assignTabAliases(getProfileState(), tabs);
|
||||
};
|
||||
|
||||
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
if (
|
||||
@@ -151,7 +211,7 @@ export function createProfileTabOps({
|
||||
});
|
||||
};
|
||||
|
||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||
const openTab = async (url: string, opts?: { label?: string }): Promise<BrowserTab> => {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
@@ -161,7 +221,7 @@ export function createProfileTabOps({
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = page.targetId;
|
||||
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
|
||||
return page;
|
||||
return assignTabAlias({ profileState, tab: page, label: opts?.label });
|
||||
}
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
@@ -176,12 +236,16 @@ export function createProfileTabOps({
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = page.targetId;
|
||||
triggerManagedTabLimit(page.targetId);
|
||||
return {
|
||||
targetId: page.targetId,
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
type: page.type,
|
||||
};
|
||||
return assignTabAlias({
|
||||
profileState,
|
||||
label: opts?.label,
|
||||
tab: {
|
||||
targetId: page.targetId,
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
type: page.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,12 +273,16 @@ export function createProfileTabOps({
|
||||
if (found) {
|
||||
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
|
||||
triggerManagedTabLimit(found.targetId);
|
||||
return found;
|
||||
return assignTabAlias({ profileState, tab: found, label: opts?.label });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
|
||||
}
|
||||
triggerManagedTabLimit(createdViaCdp);
|
||||
return { targetId: createdViaCdp, title: "", url, type: "page" };
|
||||
return assignTabAlias({
|
||||
profileState,
|
||||
tab: { targetId: createdViaCdp, title: "", url, type: "page" },
|
||||
label: opts?.label,
|
||||
});
|
||||
}
|
||||
|
||||
const encoded = encodeURIComponent(url);
|
||||
@@ -253,17 +321,39 @@ export function createProfileTabOps({
|
||||
const resolvedUrl = created.url ?? url;
|
||||
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
|
||||
triggerManagedTabLimit(created.id);
|
||||
return {
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: resolvedUrl,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: created.type,
|
||||
};
|
||||
return assignTabAlias({
|
||||
profileState,
|
||||
label: opts?.label,
|
||||
tab: {
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: resolvedUrl,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: created.type,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const labelTab = async (targetId: string, label: string): Promise<BrowserTab> => {
|
||||
const normalizedLabel = normalizeTabLabel(label);
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
const tab = tabs.find((candidate) => candidate.targetId === resolved.targetId);
|
||||
if (!tab) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return assignTabAlias({ profileState: getProfileState(), tab, label: normalizedLabel });
|
||||
};
|
||||
|
||||
return {
|
||||
listTabs,
|
||||
openTab,
|
||||
labelTab,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function createProfileContext(
|
||||
profileState.running = running;
|
||||
};
|
||||
|
||||
const { listTabs, openTab } = createProfileTabOps({
|
||||
const { listTabs, openTab, labelTab } = createProfileTabOps({
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
@@ -113,6 +113,7 @@ function createProfileContext(
|
||||
isReachable,
|
||||
listTabs,
|
||||
openTab,
|
||||
labelTab,
|
||||
focusTab,
|
||||
closeTab,
|
||||
stopRunningBrowser,
|
||||
@@ -252,7 +253,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
|
||||
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
|
||||
listTabs: () => getDefaultContext().listTabs(),
|
||||
openTab: (url) => getDefaultContext().openTab(url),
|
||||
openTab: (url, opts) => getDefaultContext().openTab(url, opts),
|
||||
labelTab: (targetId, label) => getDefaultContext().labelTab(targetId, label),
|
||||
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
|
||||
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
|
||||
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
|
||||
|
||||
@@ -13,6 +13,11 @@ export type ProfileRuntimeState = {
|
||||
running: RunningChrome | null;
|
||||
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
|
||||
lastTargetId?: string | null;
|
||||
/** Stable, user-facing tab aliases scoped to this profile runtime. */
|
||||
tabAliases?: {
|
||||
nextTabNumber: number;
|
||||
byTargetId: Record<string, { tabId: string; label?: string }>;
|
||||
};
|
||||
reconcile?: {
|
||||
previousProfile: ResolvedBrowserProfile;
|
||||
reason: string;
|
||||
@@ -32,7 +37,8 @@ type BrowserProfileActions = {
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
|
||||
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
|
||||
@@ -6,14 +6,14 @@ export type TargetIdResolution =
|
||||
|
||||
export function resolveTargetIdFromTabs(
|
||||
input: string,
|
||||
tabs: Array<{ targetId: string }>,
|
||||
tabs: Array<{ targetId: string; tabId?: string; label?: string }>,
|
||||
): TargetIdResolution {
|
||||
const needle = input.trim();
|
||||
if (!needle) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
const exact = tabs.find((t) => t.targetId === needle);
|
||||
const exact = tabs.find((t) => t.targetId === needle || t.tabId === needle || t.label === needle);
|
||||
if (exact) {
|
||||
return { ok: true, targetId: exact.targetId };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
|
||||
async function callTabAction(
|
||||
parent: BrowserParentOpts,
|
||||
profile: string | undefined,
|
||||
body: { action: "new" | "select" | "close"; index?: number },
|
||||
body:
|
||||
| { action: "new"; label?: string }
|
||||
| { action: "select" | "close"; index?: number }
|
||||
| { action: "label"; targetId: string; label: string },
|
||||
) {
|
||||
return callBrowserRequest(
|
||||
parent,
|
||||
@@ -99,7 +102,10 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
|
||||
}
|
||||
defaultRuntime.log(
|
||||
tabs
|
||||
.map((t, i) => `${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`)
|
||||
.map((t, i) => {
|
||||
const alias = [t.tabId, t.label ? `label:${t.label}` : undefined].filter(Boolean).join(" ");
|
||||
return `${i + 1}. ${t.title || "(untitled)"}${alias ? ` [${alias}]` : ""}\n ${t.url}\n id: ${t.targetId}`;
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
@@ -271,15 +277,39 @@ export function registerBrowserManageCommands(
|
||||
tab
|
||||
.command("new")
|
||||
.description("Open a new tab (about:blank)")
|
||||
.action(async (_opts, cmd) => {
|
||||
.option("--label <label>", "Assign a friendly tab label")
|
||||
.action(async (opts: { label?: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await callTabAction(parent, profile, { action: "new" });
|
||||
const result = await callTabAction(parent, profile, { action: "new", label: opts.label });
|
||||
if (printJsonResult(parent, result)) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("opened new tab");
|
||||
const opened = (result as { tab?: BrowserTab }).tab;
|
||||
defaultRuntime.log(
|
||||
opened?.tabId
|
||||
? `opened new tab ${opened.tabId}${opened.label ? ` (${opened.label})` : ""}`
|
||||
: "opened new tab",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
tab
|
||||
.command("label")
|
||||
.description("Assign a friendly label to a tab")
|
||||
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
|
||||
.argument("<label>", "Friendly label")
|
||||
.action(async (targetId: string, label: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await callTabAction(parent, profile, { action: "label", targetId, label });
|
||||
if (printJsonResult(parent, result)) {
|
||||
return;
|
||||
}
|
||||
const tab = (result as { tab?: BrowserTab }).tab;
|
||||
defaultRuntime.log(`labeled tab ${tab?.tabId ?? targetId} as ${tab?.label ?? label}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,7 +364,8 @@ export function registerBrowserManageCommands(
|
||||
.command("open")
|
||||
.description("Open a URL in a new tab")
|
||||
.argument("<url>", "URL to open")
|
||||
.action(async (url: string, _opts, cmd) => {
|
||||
.option("--label <label>", "Assign a friendly tab label")
|
||||
.action(async (url: string, opts: { label?: string }, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
@@ -344,21 +375,23 @@ export function registerBrowserManageCommands(
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
query: resolveProfileQuery(profile),
|
||||
body: { url },
|
||||
body: { url, ...(opts.label ? { label: opts.label } : {}) },
|
||||
},
|
||||
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
if (printJsonResult(parent, tab)) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
|
||||
defaultRuntime.log(
|
||||
`opened: ${tab.url}\n${tab.tabId ? `tab: ${tab.tabId}\n` : ""}${tab.label ? `label: ${tab.label}\n` : ""}id: ${tab.targetId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
browser
|
||||
.command("focus")
|
||||
.description("Focus a tab by target id (or unique prefix)")
|
||||
.argument("<targetId>", "Target id or unique prefix")
|
||||
.description("Focus a tab by target id, tab id, label, or unique target id prefix")
|
||||
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
|
||||
.action(async (targetId: string, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
@@ -383,7 +416,7 @@ export function registerBrowserManageCommands(
|
||||
browser
|
||||
.command("close")
|
||||
.description("Close a tab (target id optional)")
|
||||
.argument("[targetId]", "Target id or unique prefix (optional)")
|
||||
.argument("[targetId]", "Target id, tab id, label, or unique target id prefix (optional)")
|
||||
.action(async (targetId: string | undefined, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
|
||||
Reference in New Issue
Block a user