DEV Community

Krish Verma
Krish Verma

Posted on

Single-use tickets, one per drag: how my desktop AI companion captures webpages without ever trusting the browser

I run a desktop AI companion called Ankita — open-source, MIT, local-first, your models. You can find it at github.com/akyourowngames/A.N.K.I.T.A.

The companion has a small floating island on the desktop with a mascot. A few releases ago I wanted a natural way to hand it a webpage from Chrome: drag the mascot onto the page, drop, and the page's readable content lands as an attachment in the right teammate's conversation — even from a signed-in tab, where automation-based readers usually fail.

That needs a Manifest V3 browser helper with page-read access. And a browser extension with page-read access that phones home to a local HTTP server is, functionally, an exfiltration channel: the only thing stopping it from uploading your tabs is that it doesn't feel like doing so right now. I didn't want the security of that feature to be a promise. I wanted it to be a protocol where the helper is incapable of sending anything unless a human is mid-drag.

The rule: no drag, no capture

The design is one sentence: the helper cannot initiate anything. Every capture must be anchored to a physical drag gesture that the desktop witnessed. Here's how the pieces enforce it.

The bridge runs in the Electron main process (desktop/electron/companion-capture.mjs, 114 lines — the whole security boundary fits on two screens) and binds only to 127.0.0.1. The shared protocol module says it plainly: // IPv4 loopback only; never expose page context on the LAN. The OS assigns the first port and the address is remembered across restarts. Every request must pass two origin checks before anything else happens: the Origin header must match chrome-extension://[a-p]{32} and the Host header must equal the remembered 127.0.0.1:port. That second check is the quiet one — it's DNS-rebinding defence. A random website can't trick the browser into resolving an attacker domain to 127.0.0.1 and talking to the bridge, because the Host header won't match.

Pairing is a one-time, 5-minute out-of-band code (18 random bytes, compared with timingSafeEqual). Each helper gets its own 32-byte bearer token, and helpers are keyed by random UUID — not by extension origin. That last bit came from a real edge case found during implementation: Chrome and Edge can assign the same ID to the same unpacked extension, so origin-keyed credentials couldn't tell two installs apart. There's a migration in start() that re-keys old origin-based entries to UUIDs.

One drag, one ticket, two minutes

When you start dragging the mascot, the desktop mints a single-use ticket: a UUID the renderer registers with register(), binding the receiving teammate's thread ID, the surface (main or island), and the drag source. The ticket lives in an in-memory Map, expires after 2 minutes (long enough for a deliberate cross-window drag), and is marked used the moment a capture lands. The drag payload itself carries no credentials — just a MIME type (application/x-ankita-mascot).

On delivery, the capture endpoint re-validates everything:

  • bearer token, timing-safe, against the paired helper
  • instanceId match — every desktop process gets a fresh random UUID, so a helper holding state from a previous desktop session gets 409: This drop belongs to an earlier desktop session; drag again
  • ticket present and un-used (409 if missing, cancelled, or already delivered)
  • ticket unexpired (410: Page drop expired; drag the mascot again)
  • the receiving teammate still exists (410 if it was deleted mid-drag)
  • URL is http:/https: only, non-empty readable text, ≤200 KB

What lands in the draft is the page wrapped as an attachment whose first line reads "Webpage reference (untrusted content)" — title, source URL, then the bounded text. It prepares context in that teammate's conversation. It does not touch your existing draft text, and it never fires a model request on its own. The mascot animation reflects the reader's actual state; it doesn't invent progress.

On the extension side, the content script receives only mascot drops and reads bounded content from its own document. Its service worker validates the sender URL/tab against browser-supplied metadata before sending anything to the paired bridge. Normal browser and file drags keep their existing behavior. No browsing history, no unrelated tabs.

All of this is covered by 8 tests (test/desktop/companion-capture.test.mjs, all green), including the stale-instance 409 and the expired-ticket 410.

The honest limits

Browser-internal pages, extension stores, inaccessible frames, and unreadable viewer documents can't promise a capture — isPageUrl only allows http/https. Pending tickets cap at 64, and a full request is bounded at ~1.2 MB. And the pairing model assumes you trust the loopback interface itself, which is the one boundary I don't try to defend.

The design question

Here's the part I'm least sure about, and the reason I wrote this post: drag tickets live in memory and die with the process. If the desktop restarts between drag-start and drop — you grab the mascot, the app crashes or updates, you complete the drop — the ticket is gone and you get "drag again." The alternative is durable tickets: persist pending drags to disk so a restart doesn't lose the gesture.

I chose the ephemeral route deliberately: a drag is a gesture, and a gesture that outlives its session feels wrong — a two-hour-old pending drag completing silently against a teammate you may have deleted is exactly the kind of surprise this protocol is trying to eliminate. But I'll admit the UX cost is real, and "persist it" is one small write away.

So: is a drag a gesture that must die with the session, or a pending intent that deserves durability? If you'd built this, which would you pick — and what would change your mind? I'd genuinely like to hear the argument either way. Source is linked above; the whole thing is 114 lines plus the protocol file, so there's not much to hide behind.

Top comments (0)