Closed general

Ownership guardrails: reprimand AI threads into tracking + cleaning up windows they open/spawn

John Lauer · 7d ago ·closed by John Lauer

Problem: AI threads leak untracked windows they spawn — no way to find or close them

Real incident. An AI thread driving a user's real Chrome was filling an IRS form. To read a child's contact card, it clicked email addresses inside Google Contacts. Each click silently spawned a Gmail compose window. When the thread tried to close them, it couldn't — and here's exactly why, proven from live ABE state:

  • The composes opened as standalone Chrome windows (their own windowIds), not tabs in the thread's session window. Gmail compose always pops a standalone window.
  • ABE's ownership model only records windows/tabs created via open_window/open_tab. A window a driven page spawns (compose, target=_blank, window.open, OAuth popup, PDF viewer) is never adopted into the originating session.
  • Every ABE close/drive verb is session-keyed: nbrowser_close_window takes a sessionId, not a windowId (it errors unknown sessionId 'undefined' otherwise). A spawned window belongs to no session → no sessionId reaches it → no ABE verb can close it. Permanent leak.
  • Separately, nbrowser_list_windows did not return the calling thread's own session, so the agent had no way to reconcile "what is mine."

The linchpin: the popup belongs to no session, so there is no sessionId, so no cleanup path exists.

Design: registration + reprimand guardrails (fail-fast, teaching)

Make it structurally impossible to open an untracked window, forget a spawned one, or end a task leaving litter. Every guard is teaching — it refuses with a _hint carrying the corrected call, never a bare "no." Consistent with ABE's existing guard philosophy.

1. session_unregistered (the cornerstone). open_window/open_tab refuse unless the caller passes sessionId + thread (which AI thread) + purpose (human-readable, e.g. "finishing John's Trump Account signup"). Persist {sessionId, thread, purpose, createdAt}. Surface purpose in status/list so the Session Monitor shows who is driving each window and why.

2. unacknowledged_spawns (fixes this incident). Add chrome.tabs.onCreated (has openerTabId) + chrome.windows.onCreated listeners. When the opener belongs to a known session, adopt the new window/tab as spawned with {spawnedBy, openerTabId, reason, url, createdAt}, emit a spawned_window event, and refuse the session's next mutating verb until the thread acknowledges the spawn (adopt-keep or close). You cannot forget your own litter.

3. dirty_session. On a thread's done/disconnect while its session still owns windows, reprimand with the list + the cleanup call. Nag (non-fatal) in nbrowser_status while owned windows remain.

4. stale_coordinates. Refuse a raw {x,y} click if the page scrolled or re-rendered since those coords could have been valid. (This is the bug where a cached "Next" coordinate had become the "Back" button and popped a "Go back to Step 1?" modal repeatedly.) Force re-resolve by selector/text.

5. ambiguous_target. If click-by-text matches >1 visible element (two "Next"/"Back", three "Yes" radios), refuse and demand a disambiguating selector instead of silently clicking the first.

6. profile_mismatch. Refuse any verb where the named profile doesn't match the session's pinned profile. Guards against acting on the wrong Google account (personal vs corporate).

7. no_keepalive (warning). If a session is driven >10 min against a host known to idle-timeout with no keepalive set, warn. (An IRS idle-timeout mid-task cost a full ID.me re-login here.)

New / changed verbs

  • nbrowser_owned {sessionId} → everything the session owns, split opened vs spawned, each with windowId/tabId, url, title, ageMs, opener, reason.
  • list_windows/list_tabs: add owner/spawnedBy fields + mine:true filter; fix the caller's-own-session-missing bug.
  • close_window/close_tab: accept adopted spawns. Add nbrowser_close_window {sessionId, includeSpawned:true} and nbrowser_cleanup {sessionId} (closes only what the session spawned; never touches the user's pre-existing windows). Refuse close (close-all) unless confirmCloseAll:true.

Where it lives

Repo C:/Users/john/adom-browser-extensionextension/src/sw.js (service worker: session map, onCreated adoption, guards) + bridge/ verb surface (arg validation, _hint text). Extend tools/check-guard-coverage.mjs so CI fails if any verb ships unclassified for registration, spawn-awareness, and the targeting guards. Bump manifest.json. Then bump the driving-the-extension skill: new verbs, all reprimands, an end-of-task cleanup ritual (call nbrowser_owned, close every spawned), and the pitfall that clicking a mailto/email in Gmail-linked Contacts spawns a compose window (prefer navigating the person URL or fetch_url to read a contact).

Test plan (use the incident as the fixture)

  1. Open a session with sessionId/thread/purpose; go to Google Contacts.
  2. Click a contact's email → spawns a compose window. Assert: spawned_window event fires; the compose is in nbrowser_owned.spawned; the next mutating verb is refused unacknowledged_spawns.
  3. nbrowser_cleanup {sessionId} closes the compose; the user's pre-existing windows are untouched.
  4. open_window with no thread/purpose → refused session_unregistered.
  5. A cached {x,y} click after a scroll → refused stale_coordinates.
  6. nbrowser_list_windows now returns the caller's own session.

Acceptance

An AI thread cannot open an untracked window, cannot proceed with unacknowledged spawns, and cannot end with owned windows still open without being told. nbrowser_owned reliably returns opened + spawned. The exact incident is fully recoverable via nbrowser_cleanup. Guard-coverage check passes and covers the new guards. Do not weaken the existing focus/foreground (focus_violation) or cross-thread (session_owned_by_another_thread) guards — compose with them.

Priority: #1 + #2 together would have fully prevented this incident; #4 + #5 would have saved most of the wasted clicking.

2 Replies

John Lauer · 7d ago

Addendum: an emergency override for orphaned windows — gated by a dramatic, two-step justification

The guardrails above are deliberately strict, and that strictness created a dead end in this very incident: the spawned compose windows belonged to no session, so no ABE verb could reach them at all. Protection with no escape hatch means an AI that made the mess is powerless to clean it up. So ABE needs a last-resort override — but it must be hard enough to invoke that the AI genuinely stops and justifies it, never a reflexive single call.

Scope (what the override may and may NOT do)

  • May force-close a window/tab that ABE cannot attribute to any live session (a true orphan — e.g. today's spawned composes), or one owned by the calling session.
  • MUST NOT ever force-close a window owned by a different live thread. session_owned_by_another_thread stays absolute; the override does not pierce it. Cross-thread protection is never overridable.

The mechanism: a two-step challenge/attestation (nbrowser_force_close)

A single call always refuses — you cannot force-close in one shot. That's the point.

Step 1 — the AI requests an override. ABE responds with a challenge ticket, not an action: a one-time overrideTicket plus a full description of exactly what will be destroyed — windowId, title, url, owner: none (orphan), spawnedBy if known, ageMs. The refusal text spells out the consequence in plain language ("this will permanently close an unsaved Gmail compose draft addressed to [email protected]").

Step 2 — the AI must attest, echoing back what it read. To actually close, the second call must include ALL of:

  • the exact overrideTicket from step 1 (proves it saw the consequence description),
  • a written justification (free text, min ~40 chars) explaining why this override is warranted and why the normal path is impossible — not boilerplate; ABE rejects obviously templated/empty text,
  • a repeat-back of the specific reason ABE originally refused the ordinary close (so the AI has to restate what it's overriding, in its own words),
  • an explicit acknowledgment field, e.g. acknowledge: "I_AM_OVERRIDING_WINDOW_PROTECTION".

Only if the ticket matches, the justification is substantive, and the acknowledgment is present does ABE close the window.

Make it loud, and make it stick

  • Log it dramatically and surface it to the human. Every force-close writes a prominent entry to the Session Monitor: which thread, which window, and the verbatim justification the AI gave. The user sees "thread X force-closed an orphaned window because ." The override is never silent.
  • Emit a force_close event on nbrowser_events.
  • Rate-limit / cluster-flag: more than N force-closes in a short window from one thread is itself a reprimand ("you are force-closing repeatedly — this signals you're leaking windows upstream; fix the cause"). The escape hatch should feel like pulling a fire alarm, not a normal exit.

Why the friction is the feature

The two-step ticket, the mandatory written justification, the repeat-back, and the shouted audit log all exist to force the AI to think: is this really an orphan I created, is there truly no ordinary path, can I articulate why. If it can't write a real justification, it shouldn't be force-closing. This keeps the strong default protection intact while giving a supervised, accountable way out of exactly the dead end that produced this issue.

Acceptance addition: a single nbrowser_force_close call never closes anything (always returns a challenge ticket). A window owned by another live thread is never force-closable under any attestation. Every successful force-close appears in the Session Monitor with the AI's justification. Today's orphaned compose windows are recoverable only through the full two-step attested flow.

John Lauer · 5d ago

Resolved — everything in this issue (including the addendum's attestation override) shipped in ext/bridge v0.1.0 and is verified live.

  • Hard registration: open_window/open_tab refuse without sessionId+thread+purpose (session_unregistered, teaching hint); purpose surfaces in status/Session Monitor.
  • Spawn adoption: tabs.onCreated/windows.onCreated adopt via the openerTabId-into-an-agent-surface rule (transitive, never heuristic). spawned_window events; owner labels (agent-opened / agent-spawned / user) on list_windows/list_tabs.
  • The 7 guards: session_unregistered, unacknowledged_spawns, dirty_session, stale_coordinates, ambiguous_target, profile_mismatch, no_keepalive (warning) — all with teaching GuardErrors + corrected-call hints; CI gate (check-guard-coverage) enforces classification + hint coverage for every verb.
  • nbrowser_owned / nbrowser_cleanup / close_window{includeSpawned}: track-and-reap works; cleanup closes only agent-spawned litter, never user windows.
  • Emergency override (addendum): nbrowser_force_close is the two-step full-attestation flow (ticket + willDestroy + re-verified attribution on call 2); cross-thread ownership is never overridable (owned_by_other).
  • Verified end-to-end on the original incident fixture (Google Contacts -> Gmail compose spawns: adopted, listed, reaped; user windows untouched).

Skills pkg documents the ritual (driving-the-extension + native-browser-safety). Closing.

Log in to reply.