aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
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/cdp
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/cdp')
-rw-r--r--src/cdp/ownership.ts72
-rw-r--r--src/cdp/session.ts59
-rw-r--r--src/cdp/types.ts1
3 files changed, 119 insertions, 13 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 = {