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/cdp/session.ts | |
| 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/cdp/session.ts')
| -rw-r--r-- | src/cdp/session.ts | 59 |
1 files changed, 46 insertions, 13 deletions
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 }); |
