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 | |
| 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')
| -rw-r--r-- | src/cdp/ownership.ts | 72 | ||||
| -rw-r--r-- | src/cdp/session.ts | 59 | ||||
| -rw-r--r-- | src/cdp/types.ts | 1 | ||||
| -rw-r--r-- | src/client.ts | 79 | ||||
| -rw-r--r-- | src/domains/tabs.ts | 152 | ||||
| -rw-r--r-- | src/index.ts | 18 | ||||
| -rw-r--r-- | src/registry.ts | 3 | ||||
| -rw-r--r-- | src/state.ts | 7 |
8 files changed, 332 insertions, 59 deletions
diff --git a/src/cdp/ownership.ts b/src/cdp/ownership.ts new file mode 100644 index 0000000..f221d6a --- /dev/null +++ b/src/cdp/ownership.ts @@ -0,0 +1,72 @@ +/** + * Tracks which tab targetIds belong to this harness session. + * + * The harness attaches to the user's running Chrome, which is also full of + * the user's own tabs. Without an ownership boundary the agent could attach + * to (or close) personal tabs by accident. The registry is the single source + * of truth for "did this session open that tab?". + * + * Mutations notify a single change listener so the orchestrator can persist + * to disk via the existing BrowserState flow without the registry needing to + * know about the pi storage API. + */ + +export type OwnershipRegistry = { + add(targetId: string): void; + remove(targetId: string): void; + has(targetId: string): boolean; + list(): ReadonlyArray<string>; + /** Replace the full set — used on restoreState to re-hydrate from disk. */ + replaceAll(ids: ReadonlyArray<string>): void; + setHarnessWindow(targetId: string | undefined): void; + harnessWindow(): string | undefined; + onChange(cb: () => void): void; +}; + +export const createOwnershipRegistry = ( + initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string }, +): OwnershipRegistry => { + const owned = new Set<string>(initial?.ownedTargetIds ?? []); + let harnessWindow: string | undefined = initial?.harnessWindowTargetId; + let listener: (() => void) | null = null; + + const notify = (): void => { + if (listener) listener(); + }; + + return { + add(targetId) { + if (owned.has(targetId)) return; + owned.add(targetId); + notify(); + }, + remove(targetId) { + if (!owned.has(targetId) && harnessWindow !== targetId) return; + owned.delete(targetId); + if (harnessWindow === targetId) harnessWindow = undefined; + notify(); + }, + has(targetId) { + return owned.has(targetId); + }, + list() { + return [...owned]; + }, + replaceAll(ids) { + owned.clear(); + for (const id of ids) owned.add(id); + notify(); + }, + setHarnessWindow(targetId) { + if (harnessWindow === targetId) return; + harnessWindow = targetId; + notify(); + }, + harnessWindow() { + return harnessWindow; + }, + onChange(cb) { + listener = cb; + }, + }; +}; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index b1ae71c..42f6107 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -1,7 +1,7 @@ -import { type Result, err, ok } from "../util/result"; -import { type CdpError, cdpError } from "./errors"; +import { type Result, ok } from "../util/result"; +import type { CdpError } from "./errors"; import type { DialogInfo } from "./types"; -import { isInternalUrl } from "./types"; +import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./transport"; export type CdpSession = { @@ -15,7 +15,10 @@ export type CdpSession = { drainPageInfoInvalidations(): boolean; }; -export const createCdpSession = (transport: CdpTransport): CdpSession => { +export const createCdpSession = ( + transport: CdpTransport, + ownership?: OwnershipRegistry, +): CdpSession => { let sessionId: string | null = null; let targetId: string | null = null; let dialog: DialogInfo | null = null; @@ -41,6 +44,10 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => { if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") { pageInfoDirty = true; } + if (ev.method === "Target.targetDestroyed" && ownership) { + const params = ev.params as { targetId?: string } | undefined; + if (params?.targetId) ownership.remove(params.targetId); + } } }; @@ -81,25 +88,51 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => { // as a clear Error in normal use. return { async attachFirstPage() { + // Subscribe to Target.* events so we can react to targetDestroyed. + // Best-effort: failing to enable discovery is not fatal for attach. + await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null }); + const targets = await transport.request("Target.getTargets", {}, { sessionId: null }); if (!targets.success) return targets; const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> }; - let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url)); - if (pages.length === 0) { - const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null }); + const allPages = data.targetInfos.filter((t) => t.type === "page"); + // Reconcile the persisted ownership set against live targets — drop dead IDs. + if (ownership) { + const live = new Set(allPages.map((p) => p.targetId)); + const survivors = ownership.list().filter((id) => live.has(id)); + if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors); + const hw = ownership.harnessWindow(); + if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); + } + + // Prefer attaching to a tab this session already owns. Falls back to + // creating a fresh harness-owned tab in a dedicated window — never + // grabs the user's foreground tab. + let pickTargetId: string | undefined; + if (ownership) { + const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id)); + pickTargetId = ownedLive[0]; + } + if (!pickTargetId) { + const createParams: Record<string, unknown> = { url: "about:blank" }; + if (ownership) createParams["newWindow"] = true; + const created = await transport.request("Target.createTarget", createParams, { sessionId: null }); if (!created.success) return created; const c = created.data as { targetId: string }; - pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }]; + pickTargetId = c.targetId; + if (ownership) { + ownership.setHarnessWindow(c.targetId); + ownership.add(c.targetId); + } } - const first = pages[0]; - if (!first) return err(cdpError("invalid_response", "no page targets after creation")); - const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null }); + + const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null }); if (!attached.success) return attached; const a = attached.data as { sessionId: string }; sessionId = a.sessionId; - targetId = first.targetId; + targetId = pickTargetId; await enableDomains(a.sessionId); - return ok({ targetId: first.targetId, sessionId: a.sessionId }); + return ok({ targetId: pickTargetId, sessionId: a.sessionId }); }, async switchTo(tid) { const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null }); diff --git a/src/cdp/types.ts b/src/cdp/types.ts index 2bcc352..d57255f 100644 --- a/src/cdp/types.ts +++ b/src/cdp/types.ts @@ -23,6 +23,7 @@ export type TabInfo = { readonly targetId: string; readonly title: string; readonly url: string; + readonly owned?: boolean; }; export type PageInfo = { diff --git a/src/client.ts b/src/client.ts index 94b6ac0..2e451e8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,11 +6,20 @@ import { type CdpError, cdpError } from "./cdp/errors"; import type { CdpTransport } from "./cdp/transport"; import { createCdpTransport } from "./cdp/transport"; import { type CdpSession, createCdpSession } from "./cdp/session"; +import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership"; import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types"; export type BrowserClientOptions = { readonly namespace: string; readonly remote?: { readonly cdpUrl: string; readonly browserId: string }; + readonly initialOwnership?: { + readonly ownedTargetIds?: ReadonlyArray<string>; + readonly harnessWindowTargetId?: string; + }; + readonly onOwnershipChange?: (snapshot: { + readonly ownedTargetIds: ReadonlyArray<string>; + readonly harnessWindowTargetId: string | undefined; + }) => void; }; export type BrowserClient = { @@ -25,6 +34,9 @@ export type BrowserClient = { listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>; switchTab(targetId: string): Promise<Result<void, CdpError>>; newTab(url?: string): Promise<Result<string, CdpError>>; + closeTab(targetId: string): Promise<Result<void, CdpError>>; + owns(targetId: string): boolean; + ownership(): OwnershipRegistry; current(): { readonly sessionId: string; readonly targetId: string } | null; session(): CdpSession; transport(): CdpTransport; @@ -66,7 +78,24 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => { export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { const transport = createCdpTransport(); - const session = createCdpSession(transport); + const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + if (opts.initialOwnership?.ownedTargetIds !== undefined) { + ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds; + } + if (opts.initialOwnership?.harnessWindowTargetId !== undefined) { + ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId; + } + const ownership = createOwnershipRegistry(ownershipInit); + if (opts.onOwnershipChange) { + const cb = opts.onOwnershipChange; + ownership.onChange(() => { + cb({ + ownedTargetIds: ownership.list(), + harnessWindowTargetId: ownership.harnessWindow(), + }); + }); + } + const session = createCdpSession(transport, ownership); const mutationMutex = createMutex(); let lastHealth = 0; let pageCache: { readonly info: PageInfo; readonly at: number } | null = null; @@ -186,7 +215,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = const tabs = data.targetInfos .filter((t) => t.type === "page") .filter((t) => includeInternal || !t.url.startsWith("chrome://")) - .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url })); + .map((t): TabInfo => ({ + targetId: t.targetId, + title: t.title, + url: t.url, + owned: ownership.has(t.targetId), + })); + // Reconcile any persisted owned ids that no longer exist as live page targets. + const live = new Set(tabs.map((t) => t.targetId)); + const owned = ownership.list(); + const survivors = owned.filter((id) => live.has(id)); + if (survivors.length !== owned.length) ownership.replaceAll(survivors); + const hw = ownership.harnessWindow(); + if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); return ok(tabs); }; @@ -204,9 +245,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = }; const newTab = async (url?: string): Promise<Result<string, CdpError>> => { - const created = await session.callBrowser("Target.createTarget", { url: "about:blank" }); + // Verify the recorded harness window still exists. Querying the live + // target list also gives listTabs's reconciliation a chance to run. + const tabsResult = await listTabs(true); + if (!tabsResult.success) return tabsResult; + const live = new Set(tabsResult.data.map((t) => t.targetId)); + const hw = ownership.harnessWindow(); + + const params: Record<string, unknown> = { url: "about:blank" }; + if (hw && live.has(hw)) { + // Open as a child of the harness window's seed tab — Chrome places + // it in the same window, giving us visual grouping for free. + params["openerId"] = hw; + } else { + // Either fresh session or the harness window was closed by the user. + // Spawn a new dedicated window. + params["newWindow"] = true; + } + const created = await session.callBrowser("Target.createTarget", params); if (!created.success) return created; const c = created.data as { targetId: string }; + + if (params["newWindow"]) ownership.setHarnessWindow(c.targetId); + ownership.add(c.targetId); + const switched = await switchTab(c.targetId); if (!switched.success) return switched; if (url && url !== "about:blank") { @@ -217,6 +279,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(c.targetId); }; + const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => { + const r = await session.callBrowser("Target.closeTarget", { targetId }); + if (!r.success) return r; + ownership.remove(targetId); + return ok(undefined); + }; + const status = (): DaemonStatus => ({ alive: transport.state() === "open" && session.current() !== null, sessionId: session.current()?.sessionId ?? null, @@ -229,7 +298,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = ensureAlive, status, start, stop, evaluateJs, pageInfo, takeDialog: () => session.takeDialog(), - listTabs, switchTab, newTab, + listTabs, switchTab, newTab, closeTab, + owns: (id: string) => ownership.has(id), + ownership: () => ownership, current: () => session.current(), session: () => session, transport: () => transport, 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 } }); + }, +}); diff --git a/src/index.ts b/src/index.ts index ee59f10..c2aa2d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,7 +98,23 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { state = restoreState(ctx, state.namespace); - client = createBrowserClient({ namespace: state.namespace }); + const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; + if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; + client = createBrowserClient({ + namespace: state.namespace, + ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), + onOwnershipChange: (snap) => { + state = { + ...state, + ownedTargetIds: snap.ownedTargetIds, + ...(snap.harnessWindowTargetId !== undefined + ? { harnessWindowTargetId: snap.harnessWindowTargetId } + : {}), + }; + persistState(pi, state); + }, + }); // failure is fine — surfaced via tool errors when the agent first tries to use the browser await client.start(); if (!toolsRegistered) { diff --git a/src/registry.ts b/src/registry.ts index d9b7cea..d205bf6 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -9,7 +9,7 @@ import { handleDialogTool } from "./domains/dialog"; import { screenshotTool } from "./domains/screenshot"; import { navigateTool, openUrlsTool } from "./domains/navigate"; import { goBackTool, goForwardTool, reloadTool } from "./domains/history"; -import { listTabsTool, currentTabTool, switchTabTool, newTabTool } from "./domains/tabs"; +import { listTabsTool, currentTabTool, switchTabTool, newTabTool, closeTabTool } from "./domains/tabs"; import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files"; import { viewportResizeTool } from "./domains/viewport"; import { dragAndDropTool } from "./domains/drag"; @@ -39,6 +39,7 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ currentTabTool, { ...switchTabTool, serialized: true }, { ...newTabTool, serialized: true }, + { ...closeTabTool, serialized: true }, { ...uploadFileTool, serialized: true }, { ...downloadTool, serialized: true }, { ...printToPdfTool, serialized: true }, diff --git a/src/state.ts b/src/state.ts index 9dcef69..0629f40 100644 --- a/src/state.ts +++ b/src/state.ts @@ -10,9 +10,14 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age export type BrowserState = { readonly namespace: string; readonly remoteBrowserId?: string; + readonly ownedTargetIds?: ReadonlyArray<string>; + readonly harnessWindowTargetId?: string; }; -export const defaultState = (namespace = "default"): BrowserState => ({ namespace }); +export const defaultState = (namespace = "default"): BrowserState => ({ + namespace, + ownedTargetIds: [], +}); export const persistState = (pi: ExtensionAPI, state: BrowserState): void => { pi.appendEntry<BrowserState>("browser-harness-state", state); |
