ContributingDraft
Agent browsers
Sent for the lead's review before any build, per direction. Owner: glass, since this replaces and
extends core/computers (mine) and adds a new module (browse, proposed) that sits in front of
it, core/hands-chrome and core/glass.
Why this replaces one-computer-per-agent
Today computers.checkout gives an agent a whole desktop -- Xvnc, a window manager, Chrome inside
it -- whether the agent needed a GUI app or just wanted to read a web page. That is the right tool
for a handful of cases and a wasteful one for most: hands-chrome's tools (chrome.snapshot,
chrome.click, chrome.type, chrome.screenshot) already drive Chrome through the accessibility
tree, never the screen, so most of what a computer buys an agent -- an X server, a compositor, VNC
-- goes unused. The new model asks the cheapest question first (does this even need a browser?
does it need a full desktop, or just Chrome?) and only pays for more when the cheaper thing
actually fails.
Three levels
Configured per install (a setting, below), and an agent's browse.task reads whichever is on to
know its own ceiling.
1. No computer (default)
API and connector work (Gmail, Calendar, a CRM -- whatever's already a module), plus a plain
fetch for public pages. No Chrome, no desktop, nothing checked out. This is most of what most
agents do most of the time, and it is what computers: off in config already gives you today.
2. Shared browser
One headless Chrome (no display, no Xvnc), reached the same way today's per-agent Chrome is --
computerd, --remote-debugging-pipe, core/computers/image/computerd/cdpmux.js fencing the
agent's CDP session (HIGH 1/MEDIUM 4 just landed there apply unchanged). The difference: instead
of one Chrome process per agent's own container, this is one Chrome process, one docker-api
computer, and each agent gets its own Target.createBrowserContext -- its own cookies, its own
logins, its own downloads, invisible to any other agent's context.
What changes in core/computers / cdpmux.js, concretely:
- The pool gains a
browserkind of computer alongside today's per-agentdesktopkind: no Xvnc, no window manager,computerdstarts headless Chrome once and keeps it running (idle-paused, below) rather than once per checkout. computerd/cdpmuxneed abrowserContextIdper agent, not per client-connection: first use callsTarget.createBrowserContext {disposeOnDetach: false}and remembers the id against the agent's name (in the same sqlitecomputerskeeps its rows in); every later session for that agent reuses it. This is new state cdpmux does not have today (today's "a browser session per client" is about CDP sessions, notTarget's own context concept) -- the main new mechanism this level needs.- Every
Target.createTargetan agent client sends is forced to that agent's ownbrowserContextId(refused or rewritten if it names a different one, the same shape as today's URL fence); target discovery (Target.setDiscoverTargets, auto-attach) is filtered to that context too, so one agent's client never learns another context's target ids exist. - Context profiles persist on disk (one folder per agent under the browser computer's volume) so
logins survive a Chrome restart, the same way
~/.chromiumalready survives a per-agent computer's restart today. hands-chromeitself should need no change. It already askscomputers.endpointfor where an agent's Chrome answers and holds one CDP connection per agent; ifcomputers.endpointresolves a browser-only agent to the shared computer's cdpmux (same URL and token shape it returns today) with that agent's context already the oneTarget.createTargetlands in,chrome.open/chrome.snapshot/chrome.click/chrome.type/chrome.screenshotall keep working unmodified. This is the reuse principle made concrete: the shared-browser work is computers/cdpmux's problem, not hands-chrome's.- Vault-injected logins: a site's saved credentials go into the agent's context before it
navigates there (cookies or a fill, whichever the vault item is), through the same fill
mechanism the private sign-in flow already uses (
core/computers/fill.js, ADR 0028) -- never typed by the model, never visible to it, matching how a person's private sign-in hides the page from the agent today.
3. Computer pool
What exists today, narrowed: 1 or 2 full desktops (configurable), borrowed for GUI apps or a
take-over, the agent's own work (files, state) living outside the computer so a wipe-and-return
loses nothing real. computers.checkout becomes a borrow with a queue (below) instead of an
implicit "you get your own, always" the pool silently grows to cover.
Borrow/return, concretely:
computers.checkout(existing tool, changed behaviour): when every desktop is busy, it waits (as it already can -- "Waits up to 30 s when every screen is held",computers.checkout's description today) but now against a real priority queue rather than just the existing 30s wait, since with 1-2 desktops shared across every agent that ask contention, not a rare case. Priority: a person's own take-over request preempts an agent's queued borrow; among agents, FIFO unless the lead wants something fancier later.- On return (
computers.release, or the ladder's own step budget running out): the desktop's agent-owned state -- its Chrome profile, its Downloads, anything under/home/agent-- is wiped (not just frozen, as today's idle freeze does) and the desktop goes back into the pool paused, ready for the next borrow. The agent's own project files were never on the desktop to begin with (per the design brief: "the agent's work lives outside the computer") -- this needshands-desktop/chrome's actions to write results back through the normal files/threads path, not onto the desktop's own disk, which may already be true (worth confirming againsthands-desktop.act's actual file-write paths before building, not assumed here).
Glass, for both levels
Glass keeps its promise -- the person can always watch any agent's Chrome, live -- through two
different mechanisms depending on level, both landing in the same deck/glass UI:
- Level 2 (shared browser, headless):
core/glass/providers/gains abrowserprovider alongside today'scomputerone. Instead of a VNC ticket, it opensPage.startScreencaston the agent's own tab inside its own context, running only while a person is actually watching (the existing "a hidden tab disconnects... visible again, a fresh glass.open" rule indeck/glass/watch.jsalready tears down when nobody's looking; this extends the same rule to mean "and no screencast runs," not just "no VNC socket"). Take-over: input goes back through the same CDP session asInput.dispatchKeyEvent/dispatchMouseEventon that tab, not a synthetic X server -- there is no X server at this level.deck/glass/watch.js'smountScreenwould need a second code path alongside its noVNC one (a<canvas>painted from screencast frames instead of an RFB decode) -- real new work, not a small one, and the reason this is L in the build list. - Level 3 (desktop pool): exactly what exists today -- the VNC relay,
core/glass/glass.js, no change.
Screencast works headless (it is Chrome's own frame capture, not a screen grab), which is why "headless by default" (the lead's addition) does not cost Glass anything at level 2.
Settings
One setting, "Agent computers": Off / Browser only / Browser + desktops, mapping to levels
1/1+2/1+2+3 -- an agent's browse.task never climbs past what's on. A second, independent
switch, "Glass viewing": on/off, gating whether the screencast/VNC machinery runs at all (someone
who never watches Glass shouldn't pay screencast's small per-tab cost, or keep a desktop's VNC
warm). Both belong in the settings hub (docs/adr/0035-settings-hub.md), each module declaring
its own per that ADR.
Real numbers, not estimates. Before the settings copy claims anything ("uses about N MB
idle"), measure it on testbox the way team/archive/work-journals/glass-live.md's perf-checks already do (CPU p95,
RSS mean/max, idle and active, host load noted) for: headless Chrome idle with zero contexts, one
context idle, one context active (a real page loaded, chrome.snapshot polling), and a desktop
idle/active for comparison. This needs the level-2 code built first; it is a build-phase task, not
something to estimate here.
Idle-pause and concurrency
- Shared Chrome idle-pause: the browser computer, not just an individual context, shuts down
after N minutes with zero agents using it (config, a sibling to
computers.handbackIdleMin's existing pattern) -- the whole point of "shared" is that it costs nothing when nobody's browsing. Per-context idle already has a cheaper answer: an unusedBrowserContextwith no open targets costs Chrome almost nothing on its own, so context-level pausing is not needed separately from the whole-process idle-pause. - Concurrency cap: a ceiling on simultaneously-open contexts, sized from free RAM at startup
(measure a real context's RSS delta first, per the measurement task above, then
cap = floor(free_ram * safety_margin / per_context_mb), config-overridable). Past the cap, a newbrowse.taskqueues the same way a level-3 borrow does when every desktop is busy.
The reach ladder
One registry call, browse.task, any module or agent uses instead of reaching for
chrome.open/computers.checkout directly. It climbs exactly one rung further only on a rung's
own clear failure signal, never speculatively:
- Connector or API -- Gmail, Calendar, a CRM, whatever module already covers the site.
- Plain
fetch+ a reader -- public pages, no login, no JS needed to see the content. - Headless context, DOM + accessibility-tree snapshots --
chrome.snapshot/chrome.click/chrome.typeagainst a level-2 context; never a screenshot at this rung. - Screenshot + vision -- only for canvas-drawn UI or a widget the accessibility tree can't
describe; still level 2, just a different
hands-chrometool (chrome.screenshot). - A borrowed desktop -- level 3, for a non-web app or a site that needs a real window (native file pickers, some payment widgets).
- Hand off to the person -- CAPTCHA, 2FA, a payment page, anything the Gate would hold
anyway.
browse.taskstops, notifies (the same "needs you" surface as any other held item), and the person takes over in Glass; the trace (below) is what they see to pick up where the agent left off.
Failure signals that climb a rung (not "this would probably work better," an actual signal): empty or JS-only content at rung 2, a bot-detection page, a login wall, or an action whose before/after page state didn't change the way it should have (next section).
Verify every action
"Clicked" is never enough. After every chrome.click/chrome.type/equivalent, browse.task
checks the page changed the way the step expected -- a new element present, a URL change, a
snapshot diff at the target node -- before calling the step done. A click that produced no visible
change is treated as a failure signal (retry once, then climb or stop), not silently accepted.
This is browse.task's own logic, layered on top of hands-chrome's existing per-action
chrome.acted {agent, action, summary, ok, why?} event, not a change to that event's shape.
Budgets and loop detection
Step and wall-clock caps per browse.task call (config, sane defaults TBD once level 2 exists to
measure against), and loop detection: the same action-on-the-same-target repeating with no state
change past N times stops the task and reports rather than continuing to retry. "Stop and report"
means a browse.task result the caller (an agent's own reasoning, or a module) sees as a clear
failure with why, not a silent timeout.
A trace per run
Every browse.task run keeps: the action log (chrome.acted/desktop.acted events already
emitted, just collected under the run's id), a DOM/accessibility snapshot at each step, and a
screenshot at key steps (not every step -- rung 3's whole point is not needing screenshots for the
common case, but a person replaying the run in Glass needs to see it, so key transitions get one).
Replayable in Glass whether or not anyone watched live -- this is close to what sight.steps
already gives Glass for a desktop-level agent's computer (core/sight's sight_steps table,
sight.steps tool); browse.task's trace should very likely reuse that table and event
(sight.stepped) rather than inventing a parallel one, since sight already exists exactly to
answer "what did the agent just do" for Glass. Worth a short conversation with cohesion before
building this piece specifically -- flagged, not decided here.
Credentials
Vault logins are injected into a level-2 context (cookies, or the existing fill mechanism) before
the agent's browsing starts, never typed by the model and never returned in any chrome.* result
or browse.task trace. This is the same rule the private sign-in flow already enforces for a
person signing in on an agent's screen (ADR 0028) applied to level 2's own logins, not a new rule.
Site recipes (memory-iq, coordinated 28 Sep)
memory-iq proposed keeping one recipe per site (landmarks, login shape, pitfalls, which rung
worked) so a browse.task run starts from what worked last time instead of rediscovering a site
cold every run. Answering their two questions:
browse.finishedfits.browse.taskis the one place that knows the site, the rung it landed on, the steps it took and whether it worked -- exactly what memory-iq's proposed event needs, and it's a natural companion to the trace above (the trace is the detailed record for a person;browse.finishedis the compressed lesson for memory). One thing to tighten before building: "steps" in the event should stay landmarks and actions only, as memory-iq specified (never typed values, secrets, cookies or query-string URLs) -- worth memory-iq and I agreeing on the exact landmark shape (a CSS selector? a role+name pair? both, with a preference order?) before either side builds against it.- The module name is
browse.browse.taskcallsmemory.recipe {site}before starting a level-2 or level-3 run and uses it as a hint (never a decision, per memory-iq's own trust rule), and emitsbrowse.finishedwhen the run ends.glassstays the viewing/take-over surface; it never emits this event.
Reuse, not a second path
Every piece of this reuses what exists rather than building parallel machinery:
computerdand its hardened CDP proxy (cdpmux.js, HIGH 1/MEDIUM 4 just landed) run Chrome for both level 2 and level 3 -- headless for level 2, inside Xvnc for level 3, same binary, same fence.- The pool (
core/computers/pool.js) gains abrowserkind of computer alongsidedesktop, not a second pool. - Vault injection reuses the fill mechanism (ADR 0028), not a new credential path.
hands-chrome's tools work unchanged at level 2 (see above) -- the DOM/accessibility-tree actions an agent takes are identical whether the tab lives in a shared context or a per-agent desktop's own Chrome.- Glass's level-3 path (VNC) is untouched; level 2 is new work but sits beside it in the same
deck/glassUI andcore/glassmodule, not a parallel viewer. - The trace likely reuses
core/sight's existing steps table andsight.steppedevent (flagged above, not decided) rather than a second "what did the agent just do" record.
Open questions for the lead
- Priority in the level-3 queue: FIFO among agents, a person's take-over always preempts -- good enough for 0.1.1, or does budget/priority need to be per-agent-configurable sooner?
- Where
browse.tasklives: a newcore/browse/module (this doc assumes so, since it spans connectors, hands-chrome, computers and memory, and none of those is the natural owner of the ladder itself) -- confirm before I scaffold it. - The trace vs.
sight: reusesight_steps/sight.stepped, or doesbrowse.task's trace need fields sight's schema doesn't have (a DOM snapshot, specifically -- sight's is screen pixels, not DOM)? Worth resolving with cohesion before building, not guessing here. - Landmark shape for both the trace and memory-iq's recipes -- selector, role+name, or both.
Slice 1, built (28 Sep): Chrome gets its own uid
Ahead of the rest of this doc, and independent of the reach ladder or the shared-browser level:
today's single-agent-per-container Chrome ran as the same uid as computerd, Xvnc and the AT-SPI
bus (vyre, 1001) -- meaning a Chrome exploit landed in a process that could read /var/lib/vyre's
secrets (.boot, the VNC password) and vyre's own trusted X cookie by path. Chrome now runs as a
fourth uid, browser (1002), with nothing of vyre's reachable to it:
core/computers/image/Dockerfile:browser(1002) and avyre-busgroup (1003, vyre and browser both members)./var/lib/vyremoves from 0700 to 0711 (traversable, not listable or readable by anyone but vyre) so browser can reach the one subdirectory it owns:/var/lib/vyre/browser(0750, group agent), pre-made at build time with its final ownership already on it --chromium/(0700 browser:browser, the profile) anddownloads/(2750 browser:agent, so the real agent can still read what Chrome downloads).entrypoint.sh: anas_browser()helper; browser gets its own untrusted X cookie (never the agent's, never vyre's trusted one); vyre chgrp's its own D-Bus session-bus socket directory tovyre-bus(an owner may hand a file to any group it belongs to, no CAP_CHOWN needed) so browser can still reach AT-SPI; a second one-time migration (vyre's oldchromium/to browser's new one, the same shape as the existing agent-to-vyre migration, since vyre cannot chown an existing volume's directory to browser without CAP_CHOWN either).cdpmux.js: the downloads default moves to/var/lib/vyre/browser/downloads, and the cookie scrub over agent CDP events (below) closes what the e2e MEDIUM 4 pass left open.core/computers/image/isolation.test.js: Chrome's uid check now expects 1002, not 1001; tests confirm browser cannot read.boot, the VNC password, or vyre's own X cookie, and can reach its own profile.
Revised after review (28 Sep): the first cut had computerd itself spawn Chrome with a
different uid, via spawn(..., {uid, gid}), which needed computerd to keep CAP_SETUID/CAP_SETGID
in its own ambient set for its entire life -- the reviewer's HIGH: ambient capabilities survive a
uid change between two non-zero uids, so a computerd compromise (it serves authenticated HTTP to
whatever a person's Glass session and the agent's hands reach) could have used them to become
browser, vyre, or root, undoing the whole split it was meant to enforce. Replaced with the
reviewer's own root-launcher design:
entrypoint.shlaunches Chrome itself (chrome_once/chrome_loop, root,setpriv --reuid=1002 ... --inh-caps=-all --ambient-caps=-all --bounding-set=-all) -- the same way it already launches Xvnc and fluxbox, just with the extra ambient/bounding-set strip since Chrome is the one process here that renders untrusted content. Chrome talks CDP over a pair of named FIFOs (/run/vyre-chrome/{in,out}, root:vyre-bus, 0660) instead of a spawned child's own pipe.computerd/index.js: nospawn()for Chrome at all in the default path --connectChromeFifoopens its own ends of the two FIFOs and waits (a FIFO open blocks until its peer opens too, which is exactly the "nothing to connect to yet" wait this needs), reconnecting with the same backoff a spawned Chrome's exit used to trigger. computerd's own setpriv call is back to a plain--inh-caps=-all, identical to every other process here: it holds no capability, at any point in its life.CHROME_BINstill spawns Chrome directly (no uid change, no FIFOs) for index.test.js/fs.test.js's fake-Chrome tests, which never exercised the uid split anyway.cdpmux.js: the agent-event cookie scrub (fab3fc0a/e2e MEDIUM 4) widened to cover every Network/Fetch/Audits event recursively, not three enumerated ones --associatedCookies,headersText, both WebSocket handshake events, and Audits'rawCookieLineall reach an agent's CDP client otherwise.Network.loadNetworkResourceandPage.getCookiesadded to the refused list (the same class of leakDOM.setFileInputFilesandNetwork.getCookieswere already refused for).core/computers/image/computerd/fs.js: computerd's/fsroutes (Glass's file browser) opened by a re-walked path string after resolveIn's own check -- a symlink swapped in for a checked plain directory in between would be followed.resolveOpennow pins the checked parent directory's fd (/proc/self/fd/<fd>/<name>, Linux's openat-equivalent without a native binding) and every operation opens, mkdirs or renames the leaf through it, never a re-walked path. A symlink found mid-chain gets resolveIn's own check redone at the moment of the open.isolation.test.js: a static check that neither setpriv call regains an ambient capability, and a live check (needs a container) that CapEff/CapAmb/CapPrm are all zero for both computerd and Chrome's own pids.- Tests (testbox): 261 run, 254 pass, 0 fail, 7 skipped, plus a new FIFO-connect/reconnect test in index.test.js (a real subprocess acting as Chrome, killed to simulate a crash, so the reconnect gets a real kernel-level fd close rather than an in-process stream's asynchronous one).
Residual, documented rather than fixed here: followChecked (fs.js) re-opens a symlink's
already-validated target by its resolved path string, not through its own further fd chain -- a
legitimate in-root symlink (reviewer's own example: docs-link in fs.test.js) still has a small
window between that validation and the open. Accepted for now; closing it fully means resolving
the target's own chain through fds too, not a quick fix.
Not yet true: "other agents' profiles" from the task brief doesn't apply to slice 1's own
architecture, since today's model is still one whole container per agent (no sharing within a
container to isolate). That guarantee is level 2's own job (per-agent BrowserContexts inside one
shared Chrome process, above) and should be checked again once that's built.
Throwaway-stack run, done (28 Sep, after the root-launcher redesign): built
vyre/computer:glass-browser from this branch and ran isolation.test.js against a real
container (docker create/start, the driver's own capability/volume/read-only shape by hand;
.boot seeded via docker cp's archive API through a throwaway unprivileged helper container,
since the driver's own seed() path needs the whole vyred, not stood up here). All 11 tests pass,
including the CapEff/CapAmb/CapPrm=0 check for both computerd and Chrome -- the reviewer's own
condition before this ships (1ae6fe9e has the numbers). Found and fixed two design assumptions
that were wrong, and one implementation bug, none caught by a unit test:
- dbus-launch on this box puts its socket directly in
/tmp(a file), not a private directory -- the code chgrp'd/tmpitself, which failed (root lacks CAP_CHOWN) and would have opened all of/tmpto browser had it somehow succeeded. Fixed: chgrp/chmod the socket file only. - Root creating
/run/vyre-chromehit the same CAP_CHOWN wall trying to hand it tovyre-bus. Moved it under/tmp(sticky, world-writable) and had vyre make and own it instead. chrome_once's FIFO/log redirects were on thesetprivcommand line itself -- bash opens those as root, before setpriv's own uid switch, so root (no group access) was the one failing to open them. Fixed: the redirects now live inside the innersh -csetpriv already switches to browser before running.- AT-SPI's own bus (
org.a11y.Bus) is a second, separately D-Bus-activated bus under vyre's own home, not the session bus -- lazily created on first use. Chrome's accessibility tree was empty until something forced it to exist early and opened it tovyre-bustoo.
Not fully resolved: even after the permission fix, Chrome's own accessibility-bus registration
(dbind) intermittently answered "did not receive a reply" -- both buses were independently
reachable with the right group when checked directly, so this reads as a D-Bus service-activation
timing/reliability issue in org.a11y.Bus's own lifecycle in this environment, not a uid or
permission gap. Flagged to the reviewer as a follow-up rather than blocking on it.
Also found, not yet fixed: Chrome's setpriv --bounding-set=-all silently does not drop the
bounding set (CapBnd stayed non-zero, 00000000000000c0) -- dropping bounding-set capabilities
itself needs CAP_SETPCAP, which root here does not have (only SETUID/SETGID, per
driver/policy.js's REQUIRED_CAPS). CapEff/CapAmb/CapPrm are all correctly zero regardless
(the property the reviewer's isolation test actually checks), so nothing currently usable survives;
the bounding set only bounds what a future exec with file capabilities could regain, and this
image ships no such binary. Flagged to the reviewer rather than changing REQUIRED_CAPS (a
security-critical file) unilaterally -- adding SETPCAP would fix it if wanted.
Containers, volumes and images from the throwaway run torn down; vyre/computer:glass-browser
itself left cached on testbox (harmless, matches this repo's own existing precedent).
Build list (once the rest of this design is approved)
| Item | Size | Depends on |
|---|---|---|
core/browse/ module skeleton, browse.task tool (rungs 1-2 only: connector/API, fetch+reader) | M | this doc approved |
Level 2: browser computer kind, per-agent Target.createBrowserContext, cdpmux context-scoping | L | pool.js changes |
computers.endpoint resolves a browser-only agent to the shared computer transparently | S | level 2 above |
| Vault injection into a level-2 context | M | ADR 0028's fill mechanism |
Rung 3-4: browse.task calling chrome.snapshot/chrome.click/chrome.type/chrome.screenshot with verify-every-action | M | level 2 above |
Glass browser provider: screencast watch + CDP-input take-over in deck/glass | L | level 2 above |
| Idle-pause for the shared browser, concurrency cap from free RAM | S | level 2 above, real measurements |
| Rung 5-6: desktop borrow/queue, handoff-to-person | M | existing pool, a real queue |
| Budgets, loop detection, stop-and-report | S | browse.task skeleton |
| Trace per run (pending the sight question above) | M | resolved with cohesion |
browse.finished + memory.recipe integration | S | memory-iq's landmark-shape answer |
| The two settings ("Agent computers", "Glass viewing") in the settings hub | S | levels 2/3 built enough to gate |
| Real memory/CPU numbers on testbox, settings copy written from them | S | level 2 built |