diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-05 23:23:59 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-05 23:23:59 +0530 |
| commit | 20ceaa1c6ade4950ed7d16caa5269ad2f4805db4 (patch) | |
| tree | df4459a67b08533704f0dd2a1c85010578471917 /src/domains | |
| parent | e42d9538ad5dafd4e11cf8f5a3912ebf0763d770 (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.ts | 152 |
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 } }); + }, +}); |
