aboutsummaryrefslogtreecommitdiff
path: root/src/domains
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-05 23:23:59 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-05 23:23:59 +0530
commit20ceaa1c6ade4950ed7d16caa5269ad2f4805db4 (patch)
treedf4459a67b08533704f0dd2a1c85010578471917 /src/domains
parente42d9538ad5dafd4e11cf8f5a3912ebf0763d770 (diff)
feat: tab ownership — isolate harness tabs from the user's browsing
Track which page targets this harness session opened so the agent can never attach to, switch to, or close a tab the user opened themselves. New tabs are spawned in a dedicated Chrome window (newWindow:true on first open, openerId chaining after) so harness activity stays visually separate. - new OwnershipRegistry (src/cdp/ownership.ts) tracks owned targetIds and the harness window seed; mutations notify a single listener that persists to BrowserState (ownedTargetIds, harnessWindowTargetId) - attachFirstPage prefers an already-owned live target; otherwise opens a fresh harness-owned tab in its own window instead of grabbing whatever page the user is on - session subscribes Target.setDiscoverTargets and reaps targetDestroyed events so the registry stays in sync with reality - listTabs returns owned:boolean per tab and reconciles dead ids - browser_list_tabs defaults to scope:"owned"; pass scope:"all" to inspect the user's other tabs (read-only — they cannot be switched to or closed) - browser_switch_tab refuses non-owned tabs, with a clear remediation hint - browser_new_tab opens inside the harness window via openerId, falling back to a fresh window when the seed has been closed - new browser_close_tab tool — owned tabs only, supports unique hex prefix Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/domains')
-rw-r--r--src/domains/tabs.ts152
1 files changed, 113 insertions, 39 deletions
diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts
index d2cd646..f706100 100644
--- a/src/domains/tabs.ts
+++ b/src/domains/tabs.ts
@@ -4,26 +4,41 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
const ListTabsArgs = Type.Object({
includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
+ scope: Type.Optional(
+ Type.Union([Type.Literal("owned"), Type.Literal("all")], {
+ default: "owned",
+ description: "'owned' (default) shows only tabs this harness session opened; 'all' shows every tab in the browser.",
+ }),
+ ),
});
export const listTabsTool = defineBrowserTool({
name: "browser_list_tabs",
label: "Browser List Tabs",
- description: "List all open browser tabs (page targets).",
+ description: "List browser tabs. Defaults to tabs this harness session opened (scope:'owned'). Pass scope:'all' to see the user's other tabs too.",
promptSnippet: "List browser tabs",
promptGuidelines: [
- "Use to find a full targetId for browser_switch_tab.",
- "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
+ "Defaults to scope:'owned' — only tabs this session created. Pass scope:'all' to inspect the user's other tabs.",
+ "browser_switch_tab and browser_close_tab refuse non-owned tabs; use browser_new_tab to open one this session controls.",
"Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
+ "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
],
parameters: ListTabsArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.listTabs(args.includeInternal ?? true);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
- const lines = r.data.map((t, i) => ` [${i}] ${t.targetId} ${t.url}\n ${t.title}`);
+ const scope = args.scope ?? "owned";
+ const filtered = scope === "owned" ? r.data.filter((t) => t.owned) : r.data;
+ const lines = filtered.map((t, i) => {
+ const flag = t.owned ? "★" : " ";
+ return ` ${flag} [${i}] ${t.targetId} ${t.url}\n ${t.title}`;
+ });
+ const header = scope === "owned"
+ ? `Owned tabs (${filtered.length}):`
+ : `All tabs (${filtered.length}, ★ = owned by this session):`;
return ok({
- text: `Tabs (${r.data.length}):\n${lines.join("\n")}`,
- details: { tabs: r.data },
+ text: `${header}\n${lines.join("\n")}`,
+ details: { tabs: filtered, scope },
});
},
});
@@ -33,7 +48,7 @@ export const currentTabTool = defineBrowserTool({
label: "Browser Current Tab",
description: "Get info about the currently attached tab.",
promptSnippet: "Get current tab info",
- promptGuidelines: ["Returns targetId, url, title."],
+ promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
parameters: Type.Object({}),
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const cur = client.current();
@@ -41,9 +56,10 @@ export const currentTabTool = defineBrowserTool({
const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
+ const owned = client.owns(info.targetId);
return ok({
- text: `Current tab:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
- details: { targetId: info.targetId, url: info.url, title: info.title },
+ text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
+ details: { targetId: info.targetId, url: info.url, title: info.title, owned },
});
},
});
@@ -52,47 +68,59 @@ const SwitchTabArgs = Type.Object({
targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
});
+const ownershipDeniedMessage = (id: string, namespace: string): string =>
+ `Tab ${id} is not owned by this harness session (namespace: ${namespace}). ` +
+ `Use browser_list_tabs with scope:'all' to see it, or browser_new_tab to open one this session can control.`;
+
export const switchTabTool = defineBrowserTool({
name: "browser_switch_tab",
label: "Browser Switch Tab",
- description: "Switch to and attach to a different tab by targetId.",
+ description: "Switch to and attach to a different tab by targetId. Only tabs this harness session opened are accepted.",
promptSnippet: "Switch tabs",
promptGuidelines: [
"Get a targetId via browser_list_tabs first.",
"Accepts exact targetId or a unique prefix of at least 8 hex characters.",
+ "Refuses tabs not owned by this session — use browser_new_tab to open a controllable tab instead.",
],
parameters: SwitchTabArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- // Try exact match first
- let r = await client.switchTab(args.targetId);
- if (r.success) {
- return ok({ text: `Switched to ${args.targetId}`, details: { targetId: args.targetId } });
- }
- // If exact match failed and input looks like a short prefix (≥8 hex chars), try prefix resolution
- const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
- if (!isHexPrefix) {
- return err({ kind: "cdp_error", message: r.error.message });
- }
- const tabs = await client.listTabs(true);
- if (!tabs.success) return err({ kind: "cdp_error", message: r.error.message });
- const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId));
- if (matches.length === 0) {
- return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` });
+ // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
+ // first so the ownership check sees the canonical id, not the prefix.
+ let resolved = args.targetId;
+ if (!client.owns(resolved)) {
+ const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
+ if (isHexPrefix) {
+ const tabs = await client.listTabs(true);
+ if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
+ const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId));
+ if (matches.length === 0) {
+ return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` });
+ }
+ if (matches.length > 1) {
+ const ids = matches.map((t) => t.targetId).join(", ");
+ return err({
+ kind: "invalid_state",
+ message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`,
+ details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
+ });
+ }
+ resolved = matches[0]!.targetId;
+ }
+ if (!client.owns(resolved)) {
+ return err({
+ kind: "invalid_state",
+ message: ownershipDeniedMessage(resolved, client.namespace),
+ details: { targetId: resolved, namespace: client.namespace },
+ });
+ }
}
- if (matches.length > 1) {
- const ids = matches.map((t) => t.targetId).join(", ");
- return err({
- kind: "invalid_state",
- message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`,
- details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
- });
- }
- const resolved = matches[0]!.targetId;
- r = await client.switchTab(resolved);
+
+ const r = await client.switchTab(resolved);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ const note = resolved === args.targetId ? "" : ` (resolved from prefix "${args.targetId}")`;
return ok({
- text: `Switched to ${resolved} (resolved from prefix "${args.targetId}")`,
- details: { targetId: resolved, resolvedPrefix: args.targetId },
+ text: `Switched to ${resolved}${note}`,
+ details: { targetId: resolved },
});
},
});
@@ -104,9 +132,12 @@ const NewTabArgs = Type.Object({
export const newTabTool = defineBrowserTool({
name: "browser_new_tab",
label: "Browser New Tab",
- description: "Open a new tab and switch to it. Optionally navigate to a URL.",
+ description: "Open a new tab in the harness's dedicated Chrome window and switch to it. Optionally navigate to a URL.",
promptSnippet: "Open a new tab",
- promptGuidelines: ["Pass url to navigate immediately."],
+ promptGuidelines: [
+ "All tabs opened by this tool live in a single dedicated Chrome window separate from the user's main browsing.",
+ "Pass url to navigate immediately.",
+ ],
parameters: NewTabArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.newTab(args.url);
@@ -119,3 +150,46 @@ export const newTabTool = defineBrowserTool({
});
},
});
+
+const CloseTabArgs = Type.Object({
+ targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
+});
+
+export const closeTabTool = defineBrowserTool({
+ name: "browser_close_tab",
+ label: "Browser Close Tab",
+ description: "Close a tab the harness owns. Refuses tabs not opened by this session.",
+ promptSnippet: "Close an owned tab",
+ promptGuidelines: [
+ "Only closes tabs this session opened (visible in browser_list_tabs default scope).",
+ "Accepts exact targetId or a unique hex prefix (>=8 chars).",
+ ],
+ parameters: CloseTabArgs,
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
+ let resolved = args.targetId;
+ if (!client.owns(resolved)) {
+ const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
+ if (isHexPrefix) {
+ const owned = client.ownership().list();
+ const matches = owned.filter((id) => id.startsWith(args.targetId));
+ if (matches.length === 1) resolved = matches[0]!;
+ else if (matches.length > 1) {
+ return err({
+ kind: "invalid_state",
+ message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} owned tabs: ${matches.join(", ")}`,
+ });
+ }
+ }
+ if (!client.owns(resolved)) {
+ return err({
+ kind: "invalid_state",
+ message: ownershipDeniedMessage(resolved, client.namespace),
+ details: { targetId: resolved, namespace: client.namespace },
+ });
+ }
+ }
+ const r = await client.closeTab(resolved);
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ return ok({ text: `Closed tab ${resolved}`, details: { targetId: resolved } });
+ },
+});