Skip to content

Repository files navigation

passenger

Generic web access through a real, logged-in Chrome that sites can't distinguish from your daily driver — with a human handoff when a site puts up a challenge the agent shouldn't (and shouldn't try to) solve.

It runs on your machine. Not a service, not a shared host: one process on your laptop, launched by your own agent, driving your own Chrome with your own logins in it. Everything below rests on that. A caller's script is code you asked for, run in your own process with your own globals in scope -- there is no sandbox here and none is pretended, because there is nobody on the other side of the wall to keep out. Ticket 074 removed the last two rules that acted as though there were. If you ever put this behind a socket somebody else can reach, that premise is gone and so is the design.

It is an MCP server and nothing else. Register it with Claude Code for every project:

claude mcp add passenger --scope user -- \
  nix run /path/to/passenger -- serve

Then ask for a page. The first call starts the Chrome daemon itself; when a site puts up a login or a captcha, showBrowser puts the window in front of you, you solve it by hand, and the profile keeps the result.

What you type yourself is three verbs, and each is there because a person, not an agent, is the one who should hold it:

passenger show            put the browser on screen, for you
passenger hide [--force]  take it off screen again
passenger stop [--force]  restart Chrome, losing the warm session

show is showBrowser without a lane -- the same viewer, claimed on your behalf, for when you want to look at what an agent is doing or fix something in the profile by hand. The screen is refcounted per lane, so your look holds a claim of its own and no agent's hideBrowser takes the window out from under you.

The two --force flags are the same rule twice: hide --force drops every lane's claim, and stop throws away the logged-in session. Both refuse first and say what would be lost, and neither is a tool, because an agent that hits a timeout and helpfully "resets" the browser costs its owner a captcha someone was halfway through -- or every login on the machine.

Everything else this used to offer at a terminal -- script, tabs, open, close-tabs, serve, status -- was a second copy of a tool, and nobody had ever run any of it. See docs/wayfinder/tickets/057-delete-the-cli.md; show and hide came back because that deletion took the only way to see the browser without an agent asking on your behalf.

There is no fetch tool. There was, with an article mode and a dom mode, and ticket 046 retired both: extraction is a judgement about what a page means, and this tool's whole design says judgement belongs to the caller. script is the only door onto a page — it navigates, drives and hands back what you return. The recipes for reading one, including the DOM walk that used to be dom mode, live in skills/using-passenger/. (The function fetch is in a script's scope, along with the rest of node's globals -- but it goes around the browser and therefore around the session, so Page.request is the one you want.)

As an MCP server

passenger serve      # stdio, and the only thing a client launches

The flake builds a real derivation, so the wrapper already carries sway and wayvnc on its PATH. That matters more than it looks: without them Chrome resolves only if they happen to be installed system-wide, and a machine without them would start Chrome visible.

One trap worth knowing if you rewire this: nix develop --command forwards the shellHook to stdout, which corrupts any stdio protocol. This flake's hook prints to stderr for that reason. nix run does not run the hook at all, which is why the registration above is the simpler of the two.

Tools: openLane, setTtl, script, listTabs, closeTabs, closeAllTabs, destroyLane, showBrowser, hideBrowser, browserStatus.

Four choices worth knowing, all deliberate:

  • An agent opens a lane first. One Chrome is shared by every agent on the machine -- two sessions are two processes, and subagents of one session share a single process, so nothing in the transport tells the likely colliders apart. A lane owns the tabs opened in it: nobody else can see, list or close them. It collects itself after 30 minutes of quiet, closing its tabs. The parameter is required rather than defaulted, because a caller isolated by accident cannot tell which lane it is in.
  • script does not wait for a human. A tool call that hangs for five minutes while someone hunts for a captcha is a bad citizen, so a blocked page comes straight back with a blocked field saying what is in the way and how to clear it. The agent tells the user, the user solves it, the agent calls again -- the profile kept the result. waitSeconds opts into blocking.
  • The daemon starts on demand. Nothing has to be started first, by anyone.
  • showBrowser exists at all. The caller is not the human, so summoning one is a tool. It waits until they close the viewer, which is the only "done" signal this side can observe without ruling on the page.

Operating knowledge for the agent lives in skills/using-passenger, not in the tool docstrings, which carry the call contract and stop there. That skill is the one place that says what blocked does not catch, how to recognise a wall this side cannot name, that a read is only the first screen, how to read a page at all, and why reading a page beats driving it. It is shipped from this repo and symlinked into the agent's skill directory, so it sits beside the code it describes. Six skills in the owner's notes had each hand-copied a paragraph of it before that existed; ticket 032 has the reasoning, and the rule that came out of it -- the tool reports what it measured, the skill holds what to look for.

Layout

Functional core, imperative shell. The core is pure and testable without a browser; everything that touches Chrome, the disk, the clock, or a subprocess lives in the shell.

src/core/       pure: no browser, no disk, no clock, no subprocess
  Models.res      every boundary shape, as records and variants
  Detect.res      blocked-or-not, given a measurement
  Errors.res      the codes, and the one structural exception
  Script.res      compiling a caller's JavaScript; what may cross back

src/shell/      everything that touches the world
  Service.res     the one script orchestration
  Session.res     attaching Playwright, and the rescue when a tab wedges it
  Browser.res     Chrome daemon lifecycle
  Lanes.res       which lane owns which tab, and when its time is up
  Targets.res     Chrome's targets over CDP, going around Playwright
  Probe.res       measuring a live page into a probe record
  Geometry.res    at what density the nested browser renders
  Handoff.res     summon, notify, poll for a human
  Launch.res      how Chrome is started so it comes up hidden
  Present.res     putting the hidden browser in front of a human
  NestedSessions.res  which sway/wayvnc/viewer processes are ours
  Webserve.res    serving the page a human takes the browser over in
  Config.res      the PASSENGER_* env boundary
  Assets.res      the four files read from assets/

src/cli/        the entry point and the verbs a human types
  Cli.res         argument parsing, on node:util.parseArgs
  Main.res        serve: the ten tools, and the MCP server behind them
  Screen.res      show/hide: the window, claimed for a person
  Stop.res        stop: the destructive one, and its refusal

src/runtime/    other people's APIs, bound thinly -- nothing here is ours
  Fs Proc Posix Sqlite Timers WebSocket Node    what the BCL used to supply
  Mcp Pw                                        the SDK and Playwright
  Poll                                          waiting, and async find

skill   skills/using-passenger/SKILL.md        how an agent operates this: the judgement
        skills/using-passenger/references/     the mechanics, read on demand
        skills/using-passenger/scripts/        markdown.js, unstrip-asides.js, pictures.js

Detection is pure because the shell measures first: Probe.Run tests every candidate selector against the live page and records the hits in a PageProbe, so Detect.Classify is a function of that record alone.

PageProbe(Url: ..., Title: "Just a moment...")
  -> KnownBlocker(Signature: "cloudflare-interstitial")

Blockers are a discriminated union: Detect.Classify returns one of a small sealed set of record types, and an unhandled case in a switch expression is a compiler error rather than a silent fallthrough.

This started as a rewrite of an earlier implementation. docs/wayfinder/tickets/023-rewriting-into-csharp.md has the reasoning and the measurements.

Design

Transport and extraction are separate layers, and only one of them is here. Per-site scrapers rot because they fuse the two. This side owns transport: a warm real browser, a tab, a handoff when a human is needed, and a measurement of what it handed over. Extraction is the caller's, and lives in skills/using-passenger/scripts/markdown.js as a recipe rather than in this codebase as a mode.

That was not the original design. There were two extractors -- article (trafilatura) and dom (a live-DOM walk) -- and the caller chose between them. Ticket 046 retired both, on the accumulated evidence of this project's own history: six mechanisms had been deleted for ruling on what a page means (a yield floor, a word-count tier, a learned signature registry, an auto mode, a wall hint, and a withheld-content reporter that was refused before it was built), while the two extractors that survived kept failing in the same way -- a root heuristic that returned a promo card as the document, an article that deleted a six-page article's pagination links and swallowed a hidden share overlay, a dom that returned 128,718 characters of comments around a 9,569-character post, and a ticket page that extracted zero characters from a page holding 2,647.

Extraction is a judgement about what a page means. The agent knows what it asked for; this side does not.

What survived the deletion is one thing: the blocked verdict, a fixed table of vendors' own markup, where a vendor either serves it or does not. The picture geometry and charCount survived it by a year and then went too (ticket 048) -- not because they were judgement, but because they arrived whether or not anyone asked. A reply now carries what the script returned, and a wall if there is one. The wall check itself can be turned off with checkWall, and the reply says wallChecked: false when it is, so a page nobody looked at never reads like a page that came back clean.

The DOM walk is still this repo's in the sense that it ships here, and so does pictures.js now -- both in skills/using-passenger/scripts/, both recipes the caller reads off disk and runs in the page. Neither is something this tool decides to run.

Known signatures — Cloudflare, Turnstile, reCAPTCHA, hCaptcha, Arkose, DataDome, PerimeterX, login walls. Cheap, exact, and the only thing that can mark a page blocked. The script description and the using-passenger skill both list them; the table is fixed at build time and is the same on every machine.

There was a second tier: anything extracting to under --min-words was treated as blocked, screenshotted, and turned into a proposed signature. It is gone. A signature match is a positive claim made from things the caller cannot see — a third-party challenge iframe, a title, a URL. A low word count is not: its whole evidence is a number already reported back as char_count, so the tool was ruling on something the caller could see for itself, and ruling badly.

It was wrong in three ways at once. The count came from whichever extractor --mode selected, so the same page came back as content or as blocked depending on a presentation choice. It fired on every legitimately short page — reading https://example.com, thirty-odd words, used to put the browser on your screen and block for five minutes. And the rules it guessed were worse than nothing: a thin page once taught it ^Example Domain, which then "blocked" every later read of that site.

So a short page is now simply a short page. You get the content and its size in characters, and you decide.

And the table never grows. Removing the tier that proposed rules left the store that held them — an on-disk signatures.json, with --approve and --forget to curate it — and that is gone too. A tool that learns is a second memory owned by the wrong party: a judgement made once, from one page, filed where the agent it would affect cannot see it, cannot explain it, and can only be surprised by it. Anything durable about a site belongs in the calling agent's memory, which is written deliberately, attributed, re-read in context, and cheap to delete when it turns out to be wrong. The builtins are not an exception — they are how challenge vendors identify themselves, true regardless of who is calling.

Hiding the window

Chrome runs inside its own nested sway compositor, started headless with a config this tool writes on every launch — no bar, no keybindings, no decorations. Your host compositor never sees a window, so this works identically on any Wayland compositor, or over SSH — and survives switching between them. show attaches a VNC viewer to that session; a challenge handoff does it automatically and re-hides afterwards.

It was cage until ticket 063, and cage is a kiosk: one output, one fullscreened window, nothing to ask. Three things were paying for that. wayvnc implements the clipboard in both directions and cage offered no data-control protocol for it to talk to, so nothing pasted across the glass. There was no text-input or input-method, so no IME could run in the session at all — which on a CJK machine means the browser you are handed cannot be typed into. And one output forever means two humans can never be handed two screens (docs/wayfinder/tickets/041-multiple-display-windows.md). sway costs 33 MiB more in a 658 MiB closure, answers on a socket, and offers all three.

The fullscreen is gone with cage: sway does not fullscreen what it starts, so the browser is windowed from the start. Chrome is still put back into a window before every handoff, because a page can call the Fullscreen API and a human can press F11 — and a fullscreen Chrome hides its own tab strip and toolbar, so whoever is solving the captcha would have no address bar, no back button and no tabs.

Hardware GL survives the move (the session uses /dev/dri/renderD128), so the fingerprint is unchanged. The one delta is screen: 1280x720, which is now set in the generated config rather than inherited from a compositor default — change it there, or at runtime with swaymsg output HEADLESS-1 resolution 1920x1080.

Selectable via PASSENGER_WM:

Presenters (PASSENGER_PRESENTER)

value how you take over notes
local a chromeless window of your own browser default; app mode, so no tab strip or address bar
web the URL, to open wherever you are for containers/servers with no display of their own
none nothing honest about having no way to show it

What crosses the glass: keys, pointer, and clipboard text in both directions -- the clipboard read from the host each time the viewer window takes focus, since noVNC swallows the keystroke that would otherwise be a paste. Files do not: RFB has no file transfer, so a file dropped on the viewer is refused outright rather than left for the host browser to open in a window of its own. A file gets in through the page's own file input, whose chooser opens on the host desktop and can reach any path. CJK is typed rather than pasted, because the session runs the host's own IME inside itself -- see below.

Both are the same page (passenger/web/viewer.html), a full-bleed noVNC screen. There is no native VNC client involved: noVNC asks for the framebuffer size its window needs and keeps asking as the window changes, which is something no native client here did: the lightweight ones stretch whatever they are sent and freeze that aspect at connect time, and the one that does resize costs 1.2 GiB against noVNC's 1.8 MB.

Launch (PASSENGER_WM)

value mechanism notes
nested sway + wayvnc (default) the only real mechanism; portable everywhere
none no-op fallback when sway/wayvnc are missing — the window stays visible

Compositor-specific backends (hyprctl special workspaces, wlrctl minimize) were tried and removed. They break: Hyprland 0.56 dropped hyprctl keyword and moved dispatch to Lua, and did it while still exiting 0. Running Chrome in its own compositor sidesteps that whole class of breakage.

Typing in the nested browser

The session borrows the fcitx5 you are already running -- not a second instance. fcitx5 exposes OpenWaylandConnection on its D-Bus interface for exactly this, one process serving several compositors, so the session starts by asking yours to attach to the nested display as well:

dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call /controller \
  org.fcitx.Fcitx.Controller1.OpenWaylandConnection string:"$WAYLAND_DISPLAY"

Which means your real config, your real learned dictionary, and whatever you changed this morning -- live, not copied. It also means nothing to tear down: fcitx5 drops the connection when the display goes away.

Sway offers the text-input and input-method protocols cage never did, and Chrome already speaks them, so that one call is the entire mechanism.

An earlier version started a private fcitx5 on a private bus against a copy of ~/.config/fcitx5. It was replaced within the hour, for the reason its owner asked out loud: why run a second one? The copy also turned out to be a symlink back to the original, so the isolation it claimed was never real.

It is off by default, because it crashes Chrome. PASSENGER_IME=fcitx5 turns it on; measured on fresh profiles one variable apart, the browser runs a clean minute without it and segfaults within a second with it. A separate fcitx5 attached by hand crashes it the same way, so the fault is an input method being present rather than this reuse. Ticket 067 has the measurements and the places to look. Until then CJK goes in by pasting, which the viewer's clipboard supports both ways.

Environment

PASSENGER_STATE         state dir (default ~/.local/share/passenger)
PASSENGER_PORT          CDP port (default 9222)
PASSENGER_CHROME        chrome binary (default google-chrome-stable)
PASSENGER_HANDOFF_TIMEOUT  seconds to wait for you (default 300)
PASSENGER_IME           `none` (default; see ticket 067) or `fcitx5`, which
                        asks the running one to serve the session too
PASSENGER_WM            window backend
PASSENGER_VNC_HOST/PORT default 127.0.0.1:5900 (websocket)
PASSENGER_NOVNC_PORT    viewer page (default 6080)
PASSENGER_NOVNC         noVNC install dir (the flake sets this)
PASSENGER_VIEWER        browser for the viewer window (default: the Chrome above)
PASSENGER_VNC_SCALE     nested output scale (default: the host screen's)

Install

With nix

nix develop            # dev shell: sway, wayvnc, noVNC, node + npm
nix run . -- serve     # run the MCP server
nix run . -- stop      # the one verb a human types

The flake pins everything except the browser. package-lock.json locks every npm package by hash and npmDepsHash in flake.nix locks the lot of them, so nix build needs no network once both are current -- change a dependency and nix will tell you the new hash to paste in. The ReScript compiler comes off npm like anything else and needs no patching: its binaries are statically linked (static-pie), so they run in the sandbox as they come. Chrome deliberately comes from the host (PASSENGER_CHROME, default google-chrome-stable): pinning it would freeze its version, and the version string is one of the most visible fingerprint fields there is -- a browser months behind what real users run is a tell in itself, and stops getting security updates.

That is a decision about which browser, and it was never one about the browser's configuration. The nested session passes --ozone-platform=wayland itself -- it serves Wayland and nothing else, and used to be left to work that out -- and runs Chrome against a symlink mirror of $XDG_CONFIG_HOME with chrome-flags.conf left out, since the distribution's launcher is a shell wrapper that splices that file into argv. A mirror rather than an empty directory: fontconfig's config lives there too, and inheriting the host's fonts is the argument below against containers.

Three things are kept out of the closure on purpose (ticket 060), because the same argument that makes this tool inherit the host's fonts makes it worth noticing what arrives for no reason at all. libinput's dev output is stopped from propagating its bin output, which is eleven Python analysis tools and so a 135 MiB CPython, reachable from an MCP server through wlroots' headers. Xwayland is compiled out of wlroots and sway, taking gtk+3 with it -- it was only ever there to catch a Chrome that fell back to X11, and since the browser is told --ozone-platform=wayland outright there is no fallback to catch. And the node substituted for Patchright's is nodejs-slim, the same interpreter without npm and corepack. Together: 896 MiB down to 658, 268 store paths down to 216, and nix bundle --bundler github:NixOS/bundlers#toArx . down from 302 MB to 222.

Without nix

Nothing here needs nix; it only needs the same pieces in the same place. There are four steps, and only the third is unusual.

1. The runtime pieces, from your distribution. What must be on the PATH of the process your MCP client launches:

sway, swaymsg, wayvnc     required -- without all three, `hideBrowser`
                          answers CANNOT_HIDE and Chrome starts visible
wlr-randr, wayland-info   the nested output's scale, which sets the density
                          the browser renders at (wayland-info ships with
                          wayland-utils)
dbus-send                 only for PASSENGER_IME=fcitx5
google-chrome-stable      the browser, or whatever PASSENGER_CHROME names

Plus noVNC's static files, which are the viewer page. /usr/share/webapps/novnc, /usr/share/novnc and /usr/local/share/novnc are probed in that order; anywhere else, say where with PASSENGER_NOVNC.

There is no wrapper outside nix, so this is your PATH, not the package's. A client that launches servers with a stripped environment has to be told.

2. Node 22 or newer. node:sqlite (the lane registry) and a global WebSocket (the CDP socket) are both builtins there, which is why neither is a dependency.

3. Build it.

npm install
npm run build          # rescript build, emitting src/*.res.mjs
npm test               # optional, and it builds first anyway

Then register the entry module instead of nix run:

claude mcp add passenger --scope user \
  --env PASSENGER_NOVNC=/usr/share/novnc \
  -- node /path/to/passenger/src/cli/Main.res.mjs serve

Nothing about that tree is nix-specific: playwright-core is a library in this process, not a driver on the other end of a pipe, so there is no bundled interpreter to be linked against the wrong loader. Substituting nixpkgs' node for it is a fix for a problem only the store has.

Why not Docker

This tool's whole value is inheriting the host: its fonts (819 here, 115 of them CJK), its GPU, its residential IP, its timezone. A container isolates exactly those, so a Dockerfile would spend its length painstakingly rebuilding them -- baking in a font set, passing through /dev/dri, pinning TZ and locale -- and would still only approximate the real thing. Measured under nix, the fingerprint is untouched:

webgl : ANGLE (Intel, Mesa Intel(R) Graphics (LNL), OpenGL ES 3.2)
tz    : Asia/Shanghai

Nix isolates the dependency graph, which was the actual problem, and leaves the machine identity alone.

Docker is still the right tool for a headless server deployment, where there is no host identity worth inheriting. Present.res (web/noVNC) and Notify.res (webhook) exist so that case works.

Requires without nix: sway wayvnc (swaymsg ships with sway) and noVNC's static files. wayvnc serves the websocket itself, so nothing else has to run; if the page cannot be served the tool prints the address so you can point your own noVNC at it, from another machine or a phone.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages