NanoBrowser, part 2: tabs, the headless agent, and fourteen pits

2026-10-16 · 24 min · Stack Frame

This is part two. Part one covered the shell, the in-process CDP core, the hand-written WebSocket server and the docked DevTools panel.

Core 6: the tab engine

tabs.rs is the largest business file, and it is full of small algorithms worth looking at on their own.

Omnibox parsing has three branches. Empty input goes to the new tab page. An internal nano:// address is used as written. Anything that looks like a URL gets https:// prepended if needed. Everything else is handed to the configured search engine.

The "looks like a URL" test is where the judgement lives. An explicit scheme must also survive Url::parse. A string containing spaces, or an empty string, is treated as a search, because that is a strong signal the user is typing words. Otherwise the candidate gets https:// and must then have a host of localhost, or parse as an IP, or contain at least two dot-separated segments, and must start with an ASCII letter or digit.

Those rules draw the least-wrong line between "example.com is an address" and "how to code is a query".

Downloads handle name collisions with report (1).pdf and so on. That sounds trivial until two downloads start together. Both can decide the path is free and both can write the same file.

The fix is an atomic reservation. The winner claims the path with an exclusive create, and only then does WebView2 fill in the content. The naming helper tries up to a thousand candidates. In one line: let the filesystem arbitrate the name, not an in-memory check.

Favicons must include the port. Dropping it on a site running on a non-default port points at a different origin's icon, which is not just ugly but wrong.

Zoom is a fixed fifteen-step ladder. The index is clamped before use, so an out-of-range value in an old profile cannot panic. In a build with panic = "abort", one out-of-bounds read ends the process, so a test pins the clamp.

Shortcuts are handled in two places. Keystrokes inside a page are reported by the injected script and handled in Rust. Keystrokes inside the shell are handled by shell.js. The map covers tab creation and closing, reopen, cycling and number selection, address bar focus, find, bookmark, history, downloads, bookmark manager, back and forward, zoom, reload, fullscreen, and the DevTools toggle.

One act of restraint is worth noting. Editing keys, Ctrl+R, Ctrl+P and Ctrl+Shift+1 through 9 are left to the engine. The worst thing a browser shell can do is swallow a key the page needs. F5 is left alone for the same reason.

Core 7: the zero-framework frontend

The frontend is hand-written HTML, CSS and JavaScript in ui/. No bundler, no framework, no npm.

Three things improve. There is no build step, so editing a line of HTML and reloading is the whole cycle. UI asset size is close to zero. And the interface to Rust is trivial, because withGlobalTauri is on and the page calls the 40 commands directly.

Two things get worse. There is no component reuse and no type checking, and state plus DOM are managed by hand. That is why so much of shell.js is careful, manual interaction code.

Tab dragging is the most delicate part. Pointer move, up and cancel drive it. The dragged tab follows the cursor while its siblings slide aside with FLIP transitions, so the strip always previews where the drop will land. On release, a command tells Rust to reorder for real.

One detail decides whether this feels broken. The browser still fires a click after a drag, so without care you drag a tab and accidentally select it. The project sets a suppression flag and clears it on the next pointer down, not on a timer. That is deterministic and does not depend on task-queue timing.

Smaller notes. Suggestions ask Rust for matches across history and bookmarks, at most eight and deduplicated. Themes resolve the setting plus the system flag into a final value and set one attribute on the document element, so no component knows about themes. And window control icons are injected by JavaScript, which means the SVG's CSS class must travel with the markup, or glyphs sized by that class render at their default enormous size.

Core 8: nanoagent, an invisible browser

nanoagent is the same browser engine in a window that is never shown, driven by AI tools.

The window is created hidden, without a taskbar entry and without decorations. It uses its own WebView2 user-data directory, fully separate from the GUI profile, so automated logins and personal cookies never mix. Control goes through the in-process bridge, which means no debugging port. One-shot commands start a fresh browser each time. MCP mode keeps a single warm page across many calls.

nanoagent --mcp                              MCP over stdio (long session)
nanoagent <command> [args...]                run one action and exit

navigate <url>                               open a URL, print {url,title}
read <url> [selector] [--html]               print page text (or HTML)
shot <url> <out.png>                         save a PNG screenshot
cookies <url>                                list cookies for that page
traffic <url> [limit]                        print recorded requests (redacted)
eval <url> <js>                              run JS, print the value
click <url> <selector>                       click with a trusted mouse event
fill <url> <selector> <value>                type into an input
wait <url> <selector> [timeout_ms]           wait for an element

Three implementation details, all of them earned.

Input is real. Clicking dispatches mouse events and typing inserts text through the input domain. The page sees genuine user input rather than a scripted assignment to a DOM property, which is what defeats pages that detect automation through isTrusted.

Screenshots go through screencast. WebView2 does not implement Page.captureScreenshot. A hidden window still composites, so the agent subscribes to the screencast stream and caches frames. The README keeps one fallback on record: if hidden windows ever stop compositing, move the window off-screen instead of setting it invisible.

Readiness needs discipline on both sides. The worker polls for readiness up to 600 times at 50 ms intervals, because a webview must have a running event loop before it answers CDP. And while waiting for a reply it never blocks the UI thread. The reply lands in shared state and the worker waits on a condition variable. That is exactly what the bridge design buys in agent scenarios.

Core 9: the MCP server and its nine tools

--mcp speaks JSON-RPC 2.0 over stdin and stdout, implements tools/list and tools/call, and reports its name as nanobrowser-agent.

The nine tools map cleanly onto what an agent needs. Navigate, read, click, fill, wait, screenshot and eval are hands and eyes. Cookies are the session. Traffic is observability.

The loop is deliberately plain: read a line, parse method, params and id, dispatch initialize, list or call, write the reply. Pure stdio means no network and no port, which makes the process easy for a local agent to own and easy to kill.

Core 10: traffic capture and capture-time redaction

traffic.rs subscribes to network events and records requests. Its security rule is one sentence: redact at capture time, not at output time.

The difference matters. Store first and redact later, and the plaintext secret has already sat in a log buffer where any dump can take it. Redacting at capture means the secret never entered the buffer.

Eighteen key substrings are matched case-insensitively, covering password, token, secret, API key, auth, cookie, session, CSRF, card, CVV, SSN and OTP. A hit becomes three asterisks in both JSON bodies and header maps. Authorization and cookie headers and form bodies follow the same rules. Bodies are truncated, the buffer holds 500 entries, and the log lives only in memory. Redaction cannot be turned off.

One trap sits underneath. A redirect reuses the same CDP request id, so keying records by that id lets a later record overwrite the original request and lose its body. Entries are keyed by a unique id derived from the redirect chain instead.

Five security layers

Layer one: event origin. Each tab's init script embeds an opaque random token minted in Rust, twenty-four hex digits. A page cannot forge events for another tab because it does not know that tab's token, and Rust resolves the sender through the token rather than trusting a self-reported id. Tab identity is minted on the Rust side and injected into the page.

Layer two: favicons are hostile input. A favicon is reported by a page and displayed by the trusted shell, so it is escaped before insertion and gets an error handler that swaps in a built-in globe. A page can never inject a URL of its choosing into the shell.

Layer three: per-webview capabilities. Tauri capabilities are scoped by webview label. The shell gets defaults plus window control. Internal pages get defaults plus the ability to emit, which is the full application IPC. Remote pages get the ability to emit and nothing else, so a remote page can report UI events and can never call a command.

That last cell is the crux of the model. But it comes with a trap the README calls out. The remote capability must cover all ports. A site on a non-default port matched by no capability declaration loses every injected feature at once: shortcuts, hover state, find highlighting and favicon reporting all fail together. A configuration problem that looks like broken code is the hardest kind to diagnose.

Layer four: no debugging socket. Neither binary passes --remote-debugging-port, and the DevTools WebSocket binds a random loopback port that accepts only the session id minted for the panel.

Layer five: profile isolation. The automation browser has its own data directory, and cookies never cross between it and the human profile in either direction.

Where 4.52 MB comes from

Add up the earlier decisions. No rendering engine removes the largest block. The release profile is tuned entirely for size. No WebSocket library removes a dependency tree. No framework and no bundler keep UI assets near zero. And Tauri never bundles a webview, which is an order of magnitude smaller than embedding Chromium.

The GUI binary lands at 4.52 MB and the agent at 4.24 MB, sharing one library crate, so the protocol bridge exists once.

Startup follows the same logic. The launcher prefers the release binary and falls back to debug. For a shell, cold start is a window creation plus loading local HTML, with no engine to initialise.

Persistence

Every path derives from the executable's location, with environment overrides for tests. Runtime data, caches, downloads and the WebView2 user-data directory all stay inside the project directory and never write to the system drive.

The profile file holds settings, bookmarks, history capped at 500, downloads capped at 100, the session and the window geometry. Three practices are worth copying.

Saving is atomic: write a temporary file in the same directory, then rename over the profile, so a crash mid-write cannot corrupt it. Loads are cap-tolerant: history and downloads are truncated on read, so a hand-edited oversized file cannot exhaust memory. And window geometry is debounced, so dragging the window does not write to disk on every frame.

Fourteen pitfalls that cost real time

The README keeps these in their own section because they are the most valuable artefact of the project. Each one came out of a debugging session.

Layout and z-order. Every tab is an independent window, so a later-created page covers the shell and clips menus; expand and raise the shell while a menu is open, then re-assert order after any tab change. A child webview cannot see pointer movement outside itself, so panel resizing must route the pointer through the shell. And injected window icons need their CSS classes, or class-sized glyphs render enormous.

Concurrency and downloads. Closing tabs by index concurrently deletes the wrong neighbours, so look the tab up inside the lock. Concurrent downloads of the same URL overwrite each other when matched by URL, so reserve the path atomically.

CDP. Waiting for a reply on the UI thread deadlocks, especially in a hidden window. A redirect reusing the request id overwrites the original record. --remote-debugging-port is a dead end because only the shell becomes a target. Schema.getDomains lists domains without event names, so a static table and lazy subscription are required. And disabling native DevTools does not disable in-process CDP, which is the reason docking works at all.

Docked DevTools. The devtools:// page has neither the host object nor Tauri IPC, so a popup request the app denies is the only signalling channel. A second query key fails the whole load, which pushes extra data into the fragment, and while debugging that, the reported URL lies. The splitter sits on the edge facing the page, and the drag maths is a subtraction from the window edge.

Capabilities. A site on a non-default port matched by no declaration loses every injected feature at once.

Three of the fourteen are worth memorising outright. The DevTools URL rejects a second query key. Disabling native DevTools does not disable in-process CDP. And sibling webviews cannot see anything outside themselves. They are the most counter-intuitive, and the most likely to reappear in another project.

Known limits

  • History and the new tab page still guess favicons from the host, while only bookmarks store the icon a page actually reported.
  • The panel docks bottom or right only, with no left side and no detaching into a separate window.
  • Windows only, because the stack rests on WebView2 COM interop.
  • No release pipeline beyond the installer target declared in the Tauri config.
  • No LICENSE file yet, which the README itself flags before distribution.

Verdict

Three things are worth taking away, and none of them is really about writing a browser.

Building no engine is a capability, not laziness. Most projects default to implementing whatever they need. NanoBrowser asks what the system already has, finds Chromium, and builds only the shell. That buys 4.5 MB and near-zero engine maintenance, at the cost of Windows-only. Recognising which costs are acceptable matters more than any comparison table.

The hard part is the host's boundaries, not the features. Nearly all the complexity here comes from four invariants: sibling windows, callbacks on the UI thread without request ids, a privileged page with no conventional IPC, and a URL that rejects a second query key. Those constraints shape the architecture, and they explain the whole list of pitfalls.

A side effect can be a signal channel. When both legitimate channels are cut, find an effect the host is guaranteed to intercept and hang the parameters on it. It is not elegant. It is correct, reliable, and needs no cooperation from the host.

Put the three decisions together, no engine, no debugging port, hand-written hard dependencies, and they say one thing: push reuse to its limit, and shrink the self-written surface to the part that must be written and is worth writing. A 4.5 MB browser is not the result of trimming. It is the result of choosing not to rebuild something at every fork.

References

Related: NanoHermes, a Rust agent with no runtime and aidevops, an AI DevOps copilot in your terminal.