Skip to content

Hatchling 0.5.0

Choose a tag to compare

@github-actions github-actions released this 02 Nov 17:30
· 71 commits to main since this release
76003bc

Added

  • Release binaries you can check, and rebuild to the same bytes (WO-013). A version tag on main now releases it: the release workflow builds hatchling-broker, hatchling-agent, hatchling-host, hatchling-browser and hatchling for x86_64 and aarch64 Linux, with the pinned toolchain and Cargo.lock's dependencies, each target twice on separate runners from different paths, and releases only if the two builds are byte-identical. Each program comes with a CycloneDX SBOM, and its dependency list embedded by cargo auditable; every file, SHA256SUMS included, has a build-provenance attestation, so gh attestation verify <file> --repo undergroundrap/hatchling checks it was built by this repository's release workflow from the tag. The release notes are the version's section of this file, and a tag without one, or whose commit isn't on main, is refused. crates.io publishing moved into the same workflow, still through trusted publishing. Releasing says how to cut a release, verify one, and reproduce its builds.
  • The cell suites run as root in CI (WO-015). The README runs the broker as root, and a root broker takes paths an unprivileged one never does: its cells run as a user of their own, and it hands its sockets and run directories to other users and drops to its own. A new required job, "cells as root", runs every cell suite that way with the README's users and the pinned headless Chromium, and none may skip. HATCHLING_ROOT_PASS=1 bash scripts/check.sh runs the same pass locally: see CONTRIBUTING. Every failure the first root runs found was the tests' own assumption, each fixed: a root test's cells were given nobody, which the broker refuses, and its own directories and sockets weren't handed over as a root broker's are.
  • Add a coding CLI by a manifest, OpenCode first (WO-018). Beside Claude Code and the Codex CLI, the broker runs any CLI that speaks Anthropic's or OpenAI's API, defined by a manifest in HATCHLING_HARNESS_MANIFESTS: its binary and companion files, each pinned by SHA-256; its API and its one destination; its command line, with {task}; and the variables that point it at its relay ({relay}) and carry its surrogate key ({key}). It runs as the built-in harnesses do: in a CLI cell, through the relay with a surrogate, its key the vault secret harness-<name>, its patch computed by Hatchling. The directory and each manifest must be the broker's user's or root's and writable by no one else, and a manifest that's wrong in any way refuses the start, naming the field. OpenCode 1.19.5 is the worked example, tested end to end in a real cell. For it, a CLI cell now allows epoll_wait and pwritev2 (the same as epoll_pwait and the pwrite64 it had), and answers sendmmsg, which a name's lookup sends with, with an error instead of killing the CLI. See Harness manifests.
  • Approved tools: the programs a coding CLI's cell runs, listed and pinned (WO-019, part 2). The broker keeps a list of them, approved.json in <vault dir>.tools/, each with its SHA-256 and when it was approved. hatchling-broker tools add <path> approves one (a regular file at an absolute path), tools list shows each and whether it's still that program, and tools remove <path> takes one away; a change takes effect at the broker's next start. A cell's tools are pinned to the hashes approved: a tool that changed doesn't stop the start, and every run that would get it is refused, naming it, until it's that program again or approved again. The list must be the user's the broker starts as (root, for a root broker) and writable by no one else, or the broker refuses to start. An existing deployment sees no change: its first start approves the tools it has, HATCHLING_HARNESS_TOOLS's or the host's defaults. After that, HATCHLING_HARNESS_TOOLS must name the list's tools or be unset. See Approved tools.
  • What you said earlier stays with the agent (WO-026, part 4). Past the conversation's bounds (8 exchanges, 24 KiB), an exchange's answer goes, and what you said in it is still given to each chat run, word for word: up to 32 of your messages and 8 KiB, newest kept. A decision or a correction you made is never summarized, so nothing a model wrote, or a page it read, can change it. The model sees them as one message, labeled as Hatchling's record, before the exchanges (HATCHLING_RUN_EARLIER, which the agent refuses past the bounds or for any run that isn't a chat turn). With a conversation store they survive a restart, and the app shows them under a notice of their own. Breaking for hatchling-scheduler, hatchling-broker and hatchling-agent: RunRequest, ScheduledRun and the agent's Config have new fields; Chat::resuming, Keeps and Resumed::Kept take a Conversation.
  • The conversation survives a restart (WO-026, part 3). Set HATCHLING_CONVERSATION_DIR (absolute, with HATCHLING_SCHEDULER_DIR, apart from every other store), and the chat keeps the conversation sealed there, in a store of its own: what a run is given, nothing more. After a restart the chat goes on with it, and the app shows each exchange under Hatchling's notice, so you see what the agent is still given. New conversation removes it before you're told it started. A store that can't be read is removed, and the app says a new conversation started. Unset, nothing changes: a restart starts a new conversation, and nothing is written. Breaking for hatchling-broker: SchedulerConfig has a new field, conversation.
  • A new conversation, from the app (WO-026, part 2). The chat's header has New conversation. The owner's alone (new_conversation in the local API): the next run is given nothing said before it, and Hatchling's notice shows where it starts. The messages stay on screen. A message sent before it, and the answer to one the agent was working on, aren't part of the new conversation. Breaking for hatchling-broker: Chat::step takes each message with the conversation it was sent in, and the conversation now.
  • The agent proposes builds from the chat, and the owner decides (WO-032). A chat turn now has five tools: idea_capture, idea_list, idea_refine and idea_note keep ideas in the broker's build store, and build_propose asks the owner, in the app, to build one, with the builder the operator named for its kind (HATCHLING_ARTIFACT_BUILDERS, kind=name@version, comma-separated, beside HATCHLING_ARTIFACT_DIR). A chat run's session has ideas alone: it can't build, deliver or describe a build, or record an idea as the owner's, and it proposes only with the operator's builder; the owner's approval is what builds. Scheduled runs and subagents still have no build store. The broker tells a chat run the builders (HATCHLING_RUN_BUILDERS), and the tools are offered only then. Breaking for hatchling-broker and hatchling-agent: ArtifactConfig, ScheduledRun and the agent's Config have new fields.
  • A sign-in goes from the vault keeper to the browser's controller, and never through the broker (WO-031, part 2c-ii). The broker asks the keeper to fill a sign-in into a profile's browser, and names the fill to the browser's controller by its nonce (BrowserCommand::SignIn). The controller reads the sign-in from its own vault channel (HATCHLING_VAULT_FD) and types it on the origin the keeper sent, hidden from the agent as before. The broker can no longer ask the keeper for a sign-in's secrets: a bug that discloses the broker's memory or log finds no sign-in, kept or filled. A broker without a vault keeper fills none. A browser whose profile the owner kept while it ran gets no fill until it's opened again.
  • A browser started for a signed-in profile gets a vault channel to the keeper, which fills its sign-ins on it (WO-031, part 2c-i). The broker names the profile at the browser's start, and the spawner hands the cell's vault channel to the keeper named with it, never to the broker. Asked to fill a sign-in into that browser, the keeper checks it's the profile's and kept for the page's site, records the fill in the vault's access log (fill, browser-fill), and sends the sign-in on the cell's channel, telling the broker only the fill's nonce; a fill it refuses is recorded as denied. Nothing asks for one until part 2c-ii, when the browser's controller types it.
  • The sign-ins are the vault keeper's (WO-031, part 2a). The store of the owner's site sign-ins (HATCHLING_BROWSER_CREDENTIALS_DIR) is now the keeper's, as the vault is: the broker is handed no key to it, and run as root, its directory is hatchling-keeper's, outside the broker's user and its Landlock rules. An installation from before is handed over at the next start. The broker still checks that a new sign-in is for one of its profile's sites, then asks the keeper to keep it, list them or delete one. Until the browser's cell is given a fill itself (part 2b), the broker asks for one sign-in's secrets to fill, and the keeper records each such read in the vault's access log (browser-fill) before sending it.
  • A cell's vault ops go straight to the keeper, and the broker relays none (WO-031, part 1c). The agent sends its vault ops on its vault channel (HATCHLING_VAULT_FD) when its cell was given one, after checking the channel's peer as it checks its connection's. With a keeper running, the broker denies a vault op sent to it, and the keeper no longer takes a cell's op from the broker at all (Request::Cell is gone): a compromised broker reads no secret a cell fetches or stores, and can't ask for one in a cell's name.
  • The spawner hands each agent cell's vault channel to the keeper (WO-031, part 1b). The broker gives the spawner the keeper's channel at start and keeps no copy. A connected agent cell, the broker's own or a scheduled run's, a subagent's or a chat's, now gets a vault channel, named with the grants its session holds. Its helper hands both its channels to the spawner, which passes the connection to the broker and the vault channel to the keeper: the broker never holds that end. The agent sends its vault ops there from part 1c. Only a connected agent cell may ask for one, and a spawner without a keeper refuses such a spec.
  • A cell can be given a vault channel of its own (WO-031, part 1b). CellSpec::vault_channel asks the isolation layer for a second connected socket pair beside the cell's connection: the program finds its end at the descriptor HATCHLING_VAULT_FD names, and the owner takes the other once (Cell::vault_channel), to hand to whoever answers vault ops. A spec's own value for the variable never reaches the program. Breaking for hatchling-isolation: CellSpec has a new field and Cell a new method.
  • The vault keeper answers a cell on a channel of its own (WO-031, part 1a). Started with a third socket pair, for the spawner, the keeper takes each cell's own channel over it, named with the cell, its uid and the grants its session holds. On that channel it answers vault ops alone, under those grants where its configuration makes them too, and records each as that cell's. Nothing hands it a channel yet: the spawner does from part 1b, so a cell's secrets stop passing through the broker.
  • Keep me signed in, in the app (WO-030, part 1). While the owner drives the agent's browser, its view offers Keep me signed in, which says what it means and asks before keeping anything. Settings, under Permissions, lists the signed-in sites the broker keeps, each with Sign out (its session dropped, its sites kept) and Delete. The frozen command union gains browser.keep-signed-in, profile.sign-out and profile.delete, all human-only, and the app state gains signedInSites, with the maintainer's approval recorded in PROTOCOL-LOCK.json.
  • A signed-in session's cookie values are hidden from the agent too (WO-030). A page can show its own cookies (document.cookie), and an agent shown a session token could type it into another site it may reach. Every cookie value the broker restores into a signed-in browser, or reads out of it to keep, is now hidden from each snapshot the agent is given, as a filled sign-in and code are. A test runs a whole signed-in session (restored, a page setting its own cookie and showing everything, a sign-in filled, a code given, the session kept) and finds no username, password, cookie value or code in anything the agent was told or in the broker's log.
  • Sign-ins, kept from the app (WO-030, part 2). Settings, under Signed-in sites, lists each site's sign-ins by name and the address they're filled on, with Delete, and offers Add a sign-in: the site's address, a name, the username and the password. The form hands them to the live adapter's SignInPort, which sends them straight to the broker as their own request: never a Command, so they never reach the command log, automation or the app's state, and the form clears them whatever the answer. The frozen protocol gains SignedInSite.signIns (labels only) and the human-only sign-in.delete, with the maintainer's approval recorded in PROTOCOL-LOCK.json. CI's real-browser end to end keeps a sign-in through the form and checks the page holds it nowhere after.
  • One-time codes, in the app (WO-030, part 3). While a signed-in site waits for a code, its browser's view says which site asks and offers a code box, Give code and Decline. The code goes to the live adapter's CodePort, which sends it straight to the broker as its own request: never a Command, never in the app's state, and the box clears as it's sent. The request leaves the view once answered, filled, declined, expired or the browser ends. The frozen protocol gains BrowserTask.codeRequest and the human-only browser.decline-code, with the maintainer's approval recorded in PROTOCOL-LOCK.json.
  • One-time codes, typed by the owner (WO-030, part 3). When a signed-in site asks for a code, the agent's new browser_ask_code names its field and the owner is asked in the app, on the page's origin (browser_code on the stream). They give it (give_code) or decline (decline_code), and only they may. The broker types it into the field asked for, through the same exact-origin fill as a sign-in, into a text, number or telephone field only, hides it from every snapshot, and forgets it: used once, never kept, and never seen by the agent. A request is answered once, and expires after 5 minutes; the agent's op waits up to 30 seconds for the owner each time it asks. Hatchling keeps no authenticator seed, by the maintainer's choice. The session protocol is v17.
  • The broker fills a sign-in; the agent never sees it (WO-030, part 2). The agent's new browser_sign_in tool names a sign-in form's fields by their ids, and the broker does the rest: only in a browser running a signed-in profile, only with that profile's sign-in for the exact origin the browser reports its page on, chosen by label when there are several. The browser's controller checks that origin with the browser itself before the field takes the focus and again before typing, and types a username only into a text or email field and a password only into a password field, never a box that would show it (BrowserCommand::Fill, BrowserFailure::OtherOrigin). Whatever was filled is hidden from every snapshot the agent is given, wherever the page shows it, until the browser closes; the broker's log names the sign-in by its id and label. The session protocol is v16 (BrowserOp::SignIn).
  • Sign-ins the broker fills, sealed (WO-030, part 2). hatchling_broker::browser_credentials keeps the owner's site sign-ins in a store of their own (HATCHLING_BROWSER_CREDENTIALS_DIR, the vault's new credentials store): each one for one signed-in profile and one exact https origin among its sites, with a label, a username and a password. The owner's own API call, manage_sign_ins, keeps, lists and deletes them; a list names each by label and origin, never its username or password, and the password travels in Concealed, whose Debug hides it and which is wiped when dropped. Deleting a profile deletes its sign-ins. Bounded: 64 sign-ins, a 64-character label, 512 bytes of username and 1 KiB of password. The broker fills them into the page next; the agent never sees one.
  • A signed-in site opens in its own browser, and stays signed in (WO-030, part 1). While the owner drives the agent's browser, keep_signed_in keeps its sign-in: the site it's visiting and the session's other sites become a profile, and its cookies are sealed for it. From then on, a page the agent opens on one of a profile's sites isn't put to the owner: it opens in a browser of its own, holding only that profile's sites, its session restored before the page loads. The session is kept again after each page the agent opens or acts on, when the owner hands the browser back, when the agent closes it, and when it leaves for another site, which gets a fresh browser only once it's allowed. manage_profiles lists, signs out and deletes profiles, and only the owner can call either. The app's button and list come next.
  • Signed-in profiles, sealed (WO-030, part 1). hatchling_broker::browser_profiles keeps the owner's signed-in profiles in a store of their own (HATCHLING_BROWSER_PROFILES_DIR, the vault's new profiles store): each one's sites, and its session as cookies, spread over as many sealed entries as the jar needs. A cookie is kept only for the profile's sites, its domain one of their hosts or a parent of one; any other is dropped as the jar is kept and again as it's read back, and logged by its name and domain, never its value. Bounded: 32 profiles, 16 sites each, 256 cookies a jar. Signing out drops a jar; deleting drops the profile. The browser and the app use it next.
  • The operator's certificate authorities for the browser (WO-029). HATCHLING_BROWSER_CA names a PEM file of up to 8 certificate authorities the browser trusts beside its own: an internal or corporate CA. The broker reads it once, at start, and the cell is told only their public keys' SHA-256, which the controller passes to Chromium (--ignore-certificate-errors-spki-list). A chain holding one of those keys loads whatever else its certificates say, so a key named here is trusted as fully as the operator's own; the proxy still lets the browser reach only the sites a session holds. A file that can't be read, holds no certificate, more than 8, or one that doesn't parse, stops the broker at start.
  • The browser cell reads its cookie jar out, and sets cookies (WO-030, part 1). A signed-in profile keeps its session as cookies, never as a profile directory, so the controller gains Cookies { from }, the jar in its order (domain, path, name) a page at a time within one frame, and SetCookies. A cookie past a browser's bounds (4 KiB of name and value) is left out, and so is a partitioned one, which restored would be sent where it never was; a jar of more than 1024 is refused whole. A cookie's value never shows in its Debug. The broker keeps and restores the jar next.
  • The app's controls for driving the agent's browser (WO-029, part 4). Once control is the owner's, a click on the live picture clicks the page at that point, mapped through the picture's letterboxing to the page's own pixels. The view also gives a text box (typed where the page's focus is), the keys a form needs, and scrolling. Each goes to the broker as browser_input, only while the owner drives; the broker's answer is shown in plain words. A draft survives new frames. The frozen command union gains browser.click, browser.key and browser.scroll, all human-only, with the maintainer's approval recorded in PROTOCOL-LOCK.json.
  • The owner drives the agent's browser from the app (WO-029, part 4). Once control is theirs, UiAction::BrowserInput gives the browser the owner's own input: a click at a point of the page, text typed or a key pressed where its focus is, or a scroll. It answers InputDone, and a frame of the page follows; or InputFailed with the browser's reason; or ControlRefused when control isn't the owner's. Each input is shown in the action log in their words, never the text typed. The board and the agent's session now share the browser, and the board numbers every frame. A browser that answers the owner's input out of turn, or not at all, is closed, and shown failed at the agent's next op. The app's controls for it are next.
  • The browser cell takes the owner's own input (WO-029, part 4). BrowserCommand::Input carries an OwnerInput: a left click at a point of the viewport, text typed or a key pressed where the page's focus is, or a scroll. The controller does it through the browser's input, as a person's would, waits for a load it started, and answers with a picture of the page. A point outside the viewport, or text past the agent's own bound, is refused. The broker and the app drive it next.
  • The owner can take the agent's browser over, and hand it back (WO-029, part 4). TakeControl and ReturnControl, until now refused as unavailable, are the owner's alone, and control moves only as Control::after allows: the table the app applies too, so the broker and the app agree on every step. Taking it is only from the agent: Takeover, then Granted once no op of the agent's is in flight, at once or when that op ends, so the owner never acts on a page mid-op. While the owner drives, or control changes hands, the agent's ops reach nothing and are answered OwnerDriving. Handing it back is only from the latest frame the owner saw (StaleFrame otherwise): HandBack, and the agent's next op isn't done: it looks at the page afresh, takes a frame, and is answered with the page as it is now (HandedBack), the app told Resumed with that frame. A frame too big to show isn't the latest. The owner's own clicks and keys on the page come next.
  • The owner watches the agent's browser, and can stop it (WO-029, part 4). The broker now drives the app's browser view (WO-021), which until now had nothing behind it. Each browser cell is a session there, and after each of the agent's ops the broker publishes the session (the site it's visiting, where it stands, the step it's on), the action in the owner's words for the action log (an element by its role and name, typed text as how many characters, never the text), and, when an app is watching, a frame of the page. A frame is shown only if it's standard base64 of a JPEG within its bound; a frame's JPEG is at most 60 KiB of base64, which leaves room in one frame for the event that carries it. Nothing is published that the app's stream couldn't carry in one frame, since an event too big would end the stream and stay to end it again. The owner can stop a browser from the app (StopBrowser, the owner's alone): its sites are sealed at once, every one revoked, every tunnel closed and none granted again, whatever is in flight; the agent's next op closes it, and the agent is told the owner stopped it (BrowserResult::Stopped). It stays stopped for the session. Taking control, and handing it back, are next.
  • A picture of the browser's page (WO-029, part 4 begins): BrowserCommand::Frame, answered BrowserReply::Frame { jpeg }, what the page's viewport shows at half size, for the owner to watch. The browser's viewport is fixed at 1280 by 800, so a frame always shows the same area; the controller captures what's visible where the page is scrolled to, and steps the JPEG's quality down (60, 40, 25, 15) until its base64 is at most 60 KiB (MAX_FRAME_JPEG_BASE64), or refuses: that leaves room in one frame for the event that carries it on to the owner's app. A picture is the page's pixels, untrusted like the rest of what the cell sends; the broker's agent session never takes one in place of a page: a controller that answers out of turn is closed. The owner's view of the browser, from these frames, is next.
  • A page's refused sites can be put to the owner (WO-029, part 3): browser_allow (BrowserOp::Allow { site, reason }) asks for one of the sites the browser was refused for a page, which the page needs (its fonts, its scripts, its data), through the owner's approvals as a page's site is: once, for the task, always or never. Only a site the browser was refused can be asked for; anything else is a page to open. The browser's visits are now tracked: a site allowed once, a page's or one a page needed, is held while the browser stays on the site it's visiting, opening that site's pages again included, and revoked, its tunnels closed, when it opens another; a page that's still waiting on the owner leaves nothing. The agent opens the page again to load what it needed.
  • The browser's whole bundle is pinned, and runs from a verified snapshot (WO-029). HATCHLING_BROWSER_SHA256 was the SHA-256 of the browser's executable alone, so its libraries, .pak files and ICU data could change unseen. It's now the digest of the bundle's manifest, a line for each of its directories and files, each file by whether it's executable, its size and its SHA-256. hatchling-broker browser-digest <browser> prints it. At the first browser, the broker copies the bundle into a staging directory of its own, through handles that follow no symlink (hatchling_sys::copy_tree), refusing anything but files and directories and stopping at 4096 entries, 1 GiB and 8 levels. It hashes each file's bytes as they're written, and keeps the copy only if its digest is the pin, read-only, for its life. Every browser cell binds the copy, never the operator's directory: a file changed after the check never reaches a browser. A broker that's gone leaves the copy to the next one's sweep. The claim is a verified bundle on a trusted system runtime: the loader and the system libraries the browser links are the host's.
  • The agent's browser tools (WO-029, part 3): browser_open (a page by its https URL, with a reason for the owner), browser_read (the page as it is now, or a screen further down or up), browser_act (click, type, press a key or choose an option, on an element of the last page, by its id) and browser_close. A page comes to the model as its text and the elements it can act on, inside the untrusted-data envelope every tool's output gets, with the sites the browser was refused, if any, and a note that the page may look incomplete without them. A site waiting on the owner is told as waiting, not a failure; every refusal and failure is told in a sentence that says what to do next. The keys it can press are the ones a form needs, never the browser's own shortcuts.
  • The agent's browser session (WO-029, part 3), protocol v15: SessionRequest::Browser(BrowserOp) and SessionResponse::Browser(BrowserResult), answered by hatchling_broker::browser_ops. Opening a page asks for its site exactly as web_fetch does, through the owner's approvals: the session holds it, the owner always allows it, or the owner is asked and allows it once, for the task, or always; a site allowed for one is allowed for the other. The session's browser is started on its first allowed page, in a cell of its own, and reaches only the sites the session holds. A site allowed once is held for that visit: opening another site revokes it and closes its connections. Text the agent types is checked against the secrets the broker holds and refused, never typed, if it holds one, or if they can't be read. A page comes back with the sites the browser was refused, and is cut to fit one frame with them. At most 4 browsers run at once in all, each for at most 30 minutes; one that breaks is closed, and the next page starts another; the session's browser ends with the session. The broker reads its browser settings at startup (HATCHLING_BROWSER, HATCHLING_BROWSER_SHA256, HATCHLING_BROWSER_CONTROLLER, HATCHLING_BROWSER_FONTS), and a browser without a resource group (HATCHLING_CELL_CGROUP) stops it there. The agent's tools come next.
  • Every browser session is strict (WO-029, part 3 begins). A browser cell's proxy admits a CONNECT only to a site its session holds (browser_proxy::Sites), and checks before the site's name is resolved: a name never granted never reaches a resolver, so a secret spelled into one (<secret>.attacker.example) can't leave as a lookup. A refused site is answered 403 and recorded as no_grant, and the sites a session was refused are kept, up to 64, for the owner to see and approve. Revoking a site closes the tunnels it had open, both ways, not only new ones; a tunnel admitted just before a revoke is never opened. The end-to-end test opens a site the session doesn't hold, and finds it never looked up and kept as refused. Which sites a session holds is the broker's to decide, from the owner's approvals, next. A browser cell's start also waits again when its wait for the controller's connection is interrupted: a wait with a timeout returns EINTR once the broker is stopped and continued, even with no signal handler, and the start then failed and killed a helper still handing over. A unit test caught it once, as a helper killed before it could say why.
  • The broker starts a browser cell (WO-029, part 2), hatchling_broker::browser_cell. A browser session starts from a session thread, so its cell is spawned by a fresh helper, hatchling-broker browser-cell <spec> (or hatchling browser-cell), which hands the broker the controller's connection on its stdin and the cell's relay at a handoff socket in the cell's own run directory, and keeps neither. The browser is checked against its pin before every start, and the cell gets a resource group of its own. The broker serves the cell's proxy on the relay and sends the controller one command at a time; a reply that doesn't come within 90 seconds, or a cell past its lifetime, ends its commands, so a late reply can never answer the wrong one. However the cell ends, its helper is killed, the cell with it, and its group and run directory are removed. The cell also gets the libraries NSS opens when the browser starts TLS (libsoftokn3.so, libfreeblpriv3.so), found beside the host's libnss3.so: the browser opens them by name rather than linking them, so they were never among the libraries it brought, and without them it aborted on its first https page, which the controller's own test, on plain http, never reached. A proxy test that checked a stopped proxy's port was closed could find another test's listener there, given the freed port: it now keeps the port, and checks the proxy never takes a connection made after it stops. The Machine view lists it as a browser cell (browser on the wire). A test launches one as the broker does, with a real Chromium, against a site on this host whose certificate was made fresh on each run and signed by nobody the browser trusts: the browser reaches the site only through the proxy, asks for no other name, and refuses the certificate, never finishing the handshake. The broker reads its browser settings at startup, and the agent's tools reach a browser cell, next.
  • A cell can get a private scratch tmpfs (CellSpec::scratch, MountPlan::with_scratch): writable by the cell alone (mode 0700), nosuid,nodev, capped at the size the spec gives, which can't be zero (a tmpfs of size 0 is unbounded). No host path backs it, its pages count toward the cell's memory in its resource group, and it goes with the cell's mount namespace however the cell ends, so nothing written there outlives the cell. A plan that binds something at /scratch can't also have one. For the browser cell's profile and caches (WO-029), next.
  • A browser cell's configuration (WO-029, part 2). The operator names a headless browser (HATCHLING_BROWSER) and its SHA-256 (HATCHLING_BROWSER_SHA256), hashed again before every start, so one that changed is never started; the controller a browser cell runs (HATCHLING_BROWSER_CONTROLLER); and, optionally, fonts (HATCHLING_BROWSER_FONTS). Hatchling bundles no browser. A browser cell (CellConfig::browser) runs the controller with its own connection to the broker, no socket and no broker uid; sees the browser's directory and the fonts read-only, the browser's libraries and the devices a process-running cell writes, and nothing else, which a test lists from the backend's own view of the cell; keeps its profile, caches and temporary files in a private 512 MiB scratch tmpfs, so no host path is writable to it and nothing of it outlives the cell, however it ends; reaches the network only through the relay; and is held to 2 GiB and 512 processes in its resource group. The settings are parsed and checked here; the broker reads them at startup, and starts a browser cell, with the browser session, next.
  • A browser cell's only way out: the broker's CONNECT proxy (WO-029, part 2), hatchling_broker::browser_proxy, served on the cell's relay. It accepts CONNECT and nothing else, to https on port 443, for a site named as a URL's authority must be (Origin::from_authority: no IP address, no user). Each connection is resolved when it's made and refused if any address the site resolves to is internal (WO-023), then made to the addresses checked, never a fresh lookup; the bytes pass as they are, so TLS runs end to end and the browser checks the certificate itself. Every connection is in the egress decisions the operator's Machine view shows, with its site, and its byte counts in the broker's log. At most 64 tunnels are open at once, each idle for at most 5 minutes and at most 256 MiB each way; past them, up to 64 more connections are answered 503 once their request is read (closing one unread would reset it, and the answer with it), and past those a connection is dropped. Nothing starts a browser cell yet: the broker's browser session is next.
  • The browser cell's controller (WO-029, part 1b), hatchling-browser, not published. It starts a headless Chromium with the DevTools pipe on fds 3 and 4 and nothing else of its own, drives it over that pipe, never a port, and answers the broker on the cell's connection: open a page, snapshot it, click, type, press a key, choose, scroll, close. A snapshot is the page's text and the elements it can be acted on through, read in an isolated world the page's scripts can't see, with a password field's value never read, and bounded to one frame. Clicks and keys go through the browser's own input. Every wait is bounded and every message read is too. The protocol is hatchling_core::browser. Chromium is held to the cell's relay as its proxy, loopback included, with QUIC off and WebRTC only through the proxy. A test drives a real Chromium in a real browser cell through a relay the test answers, and finds every request went there, for its own host alone. Fonts come from a directory the cell sees (HATCHLING_BROWSER_FONTS), with a font configuration the controller writes itself, naming that directory alone: without fonts, Chromium's renderer can't lay out text. Driving a real page in a real cell found four more calls the browser profile needs, none of which a data: page made: fstatfs (fontconfig's cache), link answered EPERM (fontconfig then locks its cache with a directory), and waitid and pidfd_send_signal, the controller's wait for its browser and its kill. Nothing starts a browser cell yet: the broker's side is part 2.
  • A browser cell is bounded as a whole, or not started (WO-029, part 1b). A Filter::Browser cell without a resource group (a delegated cgroup), or without a memory or a process limit, is refused before anything is made: a page can fill memory with files it never maps (memfd_create, fallocate), which no per-process limit counts. CI now installs the pinned headless Chromium (the build the frontend's Playwright pins, its SHA-256 checked) and requires the browser cell's tests to run (HATCHLING_REQUIRE_BROWSER=1), in a resource group like every cgroup test there.
  • The browser cell answers sched_setaffinity with an error (WO-029). In a cell, a headless Chromium sets which CPUs its threads run on, a call it never makes outside one, so its trace never showed it: likely its fallback once a thread priority is refused. The profile killed its processes for it, on CI's runners and in the sandbox, while the test page still rendered. It now gets EPERM, like the thread priorities.
  • Descriptors placed on fixed numbers for an exec (WO-029, part 1b). hatchling_sys::place_across_exec puts descriptors on 3, 4 and so on, open across the next exec and nothing else: what Chromium's --remote-debugging-pipe reads, so the browser's controller can drive it over a pipe, never a port. A source already on another's target isn't lost, and stdio is never placed over. WO-029's notes now record the browser cell's design as reviewed: the relay is the proxy, so nothing in the cell forwards; a browser cell needs its resource group; Chromium gets descriptors 0 to 4 and nothing else; and signed-in profiles, later, get a cell each.
  • A browser cell's profile (WO-029, part 1a), the first step toward a browser the agent drives. hatchling_isolation::Filter::Browser is a CLI cell whose seccomp profile (SeccompProfile::browser) adds the calls a headless Chromium was traced needing: sendmsg and recvmsg between its own processes, the event loop's epoll_wait and ppoll, and its shared memory (memfd_create, mremap, fallocate). Five calls it only probes for are answered with an errno instead (landlock_create_ruleset, pkey_alloc, inotify_init, the thread priorities). Everything else is the CLI profile, argument checks and all: no namespaces, no mount, no trace, and no socket but AF_UNIX and AF_INET. Chromium's own sandbox can't run in a cell, so the cell is the boundary. Nothing uses it yet.
  • Allow always, for a site (WO-028, part 4b). With a place to keep them (HATCHLING_WEB_GRANTS_DIR, a new sealed store), a site's request offers Allow always: the site is kept, and every session reads it without asking until you revoke it. The app lists them under Permissions → Websites, each with Revoke; the local API's manage_sites lists and revokes them, the owner's alone. At most 100 are kept, and a new one past that is refused, never one dropped. A cell has no way to the store. WO-028 is done. Protocol, additive: decide gains approve_always; confirmation gains always; UiAction::ManageSites and UiActionResult::Sites. On upgrade: the new store is sealed under its own key, made when the broker first starts with it.
  • The app offers "Allow for task" on a site the agent asks to read (WO-028, part 4a). It sends approve_session: any page on that site, until the agent's session ends, which in a chat is that answer. A request that doesn't offer it (building an artifact) still shows only Allow once and Deny, and the app refuses the choice before sending anything; the broker refuses it too (not_offered). The card reads "Allowed for this task" once decided.
  • The agent can search the web, through the broker (WO-028, part 3). Its web_search tool sends a query; the broker asks Brave Search (HATCHLING_SEARCH=brave) with the key it keeps in the vault as search-brave, which the vault keeper releases to the broker for search alone, and no grant may name. The key never enters a cell, and travels only in the request's own wiped buffer. Up to 10 results come back, each a title, a URL and a snippet, as data. Each search is logged with its session and its length, and its text only when the owner opts in (HATCHLING_SEARCH_LOG=queries). Reading a result is a web_fetch, which asks for its site like any other; the owner's prompt says which result led there, and shows the search as the agent's words. Protocol, additive to v14: WebOp::Search and WebResult::Results.
  • The agent can read a web page, from a site you allow (WO-028, part 2). Its web_fetch tool names a page and says why; the broker does the rest. The web is off until the operator turns it on (HATCHLING_WEB=sites), and hosts the operator refuses (HATCHLING_WEB_REFUSE) are never asked for. A page on a site the session hasn't been allowed is put to you in the app, on the approvals board: the site, the page and the agent's reason, the reason labeled as the agent's. You allow it once (that page), for the session (any page on the site until the session ends), or say no (the site is refused from then on, for every session). Nothing is fetched until you say yes. A chat waits up to two minutes for your decision; a scheduled run asks and goes on without the page. A redirect on the same site is followed, up to 5; one to another site is returned for the agent to ask for. A read takes at most 60 seconds. What comes back is the site's words, given to the model as data. Protocol: the session protocol is v14 (WebOp / WebResult), so the agent and the broker must be the same release, as they always ship; the app's decide gains approve_session, confirmation gains session, and a decision the request doesn't offer is rejected as not_offered.
  • The broker can read a web page, from a site it names exactly (WO-028, part 1), the first step toward an agent that looks things up with the owner deciding where it goes. hatchling_egress::web parses a URL strictly (absolute https, no user name or password, no IP address, an international host in its xn-- form) into its origin, the site a grant will name. A fetch resolves the host when it runs, refuses it if any address is internal before connecting, and connects only to the addresses it checked. It sends one GET with no credential, reads at most 1 MiB, returns a redirect rather than following it, and gives back text: HTML reduced to what it shows, plain text, JSON or XML as they are, and anything else refused. Nothing calls it yet; the agent's web_fetch tool and the owner's site grants are part 2.
  • A scheduled run a broker left in flight comes back as an unknown outcome, for the owner to resolve or retry (WO-025, part 2). As the broker starts, every fire an earlier broker had claimed or started is marked of unknown outcome: never marked succeeded, never called failed before doing anything, never run again on its own. It holds its schedule, whose later fires are held (TickReport::held), and the schedule list shows it (held). The owner marks it resolved (resolve), and the schedule resumes, or retries it (retry): it runs again now, as a new attempt, under the grants the creator holds then. The owner can also list the results that couldn't be delivered (list-parked) and dismiss them (dismiss). A cell's session is refused all four. Protocol, additive: ScheduleOp gains resolve, retry, list-parked and dismiss; ScheduleResult gains resolved, retried, parked and dismissed; ScheduleSummary gains held.
  • The approval card lists what you're approving (WO-027, part 3). The card shows the request's fields above its scope: the broker's first, then the agent's own words, each agent field tagged From the agent. Values are whole and inert, each isolated for text direction, and every hidden or direction-control character is shown as its code point (⟨U+202E⟩), so a right-to-left override (U+202E) can't make setup + U+202E + txt.exe read as a text file. The approvals history shows the same list.
  • Approvals reach you in the app (WO-027, part 2). When the agent proposes building an idea into an artifact, the broker freezes the request and asks you on the UI stream. You decide in the app, never in the chat. The confirmation names the action's digest and lists its fields in review order, each labeled as the broker's or the agent's words. decide names the confirmation, its revision and the digest, and only the owner can send it. On approve, the broker's worker runs the stored activation once and tells you the outcome in the chat. A session can't run it, and the agent is told when it couldn't ask (approval-refused). In the app, a decision carries the digest, and the review lists the fields, the agent's labeled as the agent's. Breaking, for API clients: decide needs digest, and confirmation gains digest and fields.
  • The approvals ledger and the frozen action's digest (WO-027, part 1). Nothing uses them yet. hatchling_core::action writes an action's canonical form (RFC 8785, for objects, arrays, strings, booleans and integers within ±(2^53 - 1); no normalization) and its digest, SHA-256 over a domain tag and that form. hatchling_broker::approvals holds the requests to approve frozen actions: pending, approved, started, then succeeded, failed or outcome unknown, or denied, expired or withdrawn before starting. A decision names the request's id, revision and digest. The claim to run is atomic, and a run that ends unrecorded is never run again. At most 5 wait (1 per task, 15 minutes each), at most 20 are made in an hour, and a denied effect stays denied, with a 10-minute pause on new requests.
  • The chat remembers the conversation (WO-026, first slice). Each chat run, still a fresh cell, is given the conversation's last exchanges: up to 8 and 24 KiB, newest kept. The model sees them in their own roles, before the new message. The broker keeps them, not a cell, and keeps an exchange only when its answer reached you as written: a failed run's sentence, or an answer the output gate blocked or replaced, never comes back. A run is given them typed (HATCHLING_RUN_HISTORY), and the agent refuses any past the bounds or given to a run that isn't a chat turn. The conversation lives in the broker's memory for now, so a restart starts a new one.
  • hatchling_sys::subid, the first part of running the full broker without root (WO-014). It reads the owner's subordinate uid and gid ranges (/etc/subuid, /etc/subgid) and refuses one that holds uid 0, the owner's uid or any account's. It refuses map helpers that aren't regular, setuid files of root's. It writes a cell's maps through newuidmap and newgidmap. It leaves setgroups allowed, so a cell can drop the owner's supplementary groups (deny would make it keep them). Nothing uses it yet.
  • Cells on the owner's subordinate ids (WO-014, part 2): with HATCHLING_CELL_IDS=subordinate, an unprivileged broker gives every cell (agent, scheduled run, subagent, skill) that uid and the first of the gid range. The local API, the chat ingress, the device endpoint and the operator socket then start without root, since no cell has the broker's uid. A harness runs this way too (part 2b): its work tree and scratch go to the cell's ids before the run, and come back once the cell is gone, through hatchling-broker harness-tree, a fresh single-threaded helper. The helper enters a user namespace of its own; the broker maps the owner as its 0 and the cell as its 1 (newuidmap/newgidmap, one call each); the helper changes the trees with hatchling_sys::chown_tree, a bounded walk that follows no symlink, crosses no mount, restores its owner's search bits on every directory and strips set-id bits. The refusals that find cells sharing the broker's uid now say how to give them their own. HATCHLING_AGENT_UID can be left out: the broker takes the first id of the owner's /etc/subuid range, the one its cells run as (set, it must be that id).
    • IsolationBackend::subordinate_identity gives a cell its own uid and gid from the owner's ranges (subid::cell_ids, which says how to grant a range when there's none). It refuses a root broker, an unsafe range, and helpers it can't trust.
    • The spawner writes such a cell's maps through newuidmap and newgidmap, after checking the identity against the owner's ranges again. The cell then drops the owner's supplementary groups and checks that none is left before anything else runs; if it can't, it doesn't start.
    • A real-cell test reads, from the host, each of the cell's processes: its uid and gid (all four of each), its groups (none) and its maps.
  • A quickstart without root (WO-014, part 3), in the README beside the root one: install the map helpers, give your user a subordinate range, load an AppArmor profile that lets the broker create user namespaces where Ubuntu restricts them, and run the broker, the stand-in model and the app as yourself. CI runs it as written, as an unprivileged user on a stock Ubuntu 24.04 runner with the restriction left on, and fails unless the agent's answer arrives and the app answers in a browser. The same job runs the subordinate-id tests with HATCHLING_REQUIRE_SUBID=1, on distinct uid and gid ranges. scripts/readme_quickstart.py --rootless runs that section, then the app's.

Changed

  • A harness's tools are pinned, and its cells run copies (WO-019, part 1). A CLI cell's tools (HATCHLING_HARNESS_TOOLS, or the host's defaults) were mounted from their host paths, so a program replaced or rewritten there was what the next cell ran, and nothing noticed. Now the broker hashes each tool when it starts, and every CLI cell runs a copy of them, in a directory of the broker's own: made at the first run that gets tools, from bytes that still hash to their pins, once per program (other names are hard links). A write to a tool's path never reaches a cell. Before every run that gets tools, each is checked: one whose file changed is hashed again, and if it's another program the run is refused, and its report names the tool. A tool that can't be read stops the broker at its start. A FIFO at a tool's path is refused rather than waited on, a tool past 256 MiB is refused, and an explicit list naming one twice is refused. Run as root, the spawner no longer allows the host tool paths at all.

  • The broker's confinement lets it read what a harness run checks (WO-019). Confined with Landlock, the broker could read its system paths, but not a harness binary, the Codex host or a tool installed elsewhere (~/.local/bin, say), and each is read again before a run. Their paths are now among what it reads.

  • An approval names each input by its length as well as its hash (WO-027). The action the owner approves held each build input by name and SHA-256 alone, and the app showed a hash no one can read. It now holds each input's length too, from the bytes the broker pinned, never from what the agent says, and the card shows it: idea (2140 bytes, sha256 …). A different length is a different action, with its own digest.

  • A session request naming a field it doesn't have is refused (WO-027). Every op a cell sends, and everything it holds (a build input, a schedule's time, a grant, a delivery target, a page action), now refuses a field its type doesn't name, and the session ends unanswered. Before, such a field was skipped. The protocol schema changes shape: each family's variants are drawn inside SessionRequest, beside its kind tag, so the families no longer have definitions of their own.

  • What a cell sends is read strictly (WO-027). A session request, or a vault op on a cell's own channel, that could be read two ways now ends the session unanswered: a key given twice in one object (compared once unescaped), a number that isn't an integer within ±(2^53 - 1), or a lone surrogate, even in a field the request doesn't use. Before, a field the broker didn't use was skipped whatever it held, so a key given twice or an inexact number there passed unseen. hatchling_core::strict_json::check is the reading.

  • What's typed into a sign-in form is hidden by the browser's controller, not the broker (WO-031, part 2b). The controller keeps what each fill types (a sign-in's username and password, a one-time code) until the browser closes, and hides it from every page the agent is shown after, as the broker did. The broker keeps none of it; it still hides a signed-in session's cookie values.

  • The spawner holds every cell spec to its own policy (WO-017, step 3). It reads the policy from its own configuration, never the broker's word, and refuses a spec before any privileged call when:

    • its identity or the broker's uid in the cell isn't the configured cell's;
    • it has an endpoint, whose socket the spawner would hand to the cell's uid;
    • its limits aren't the configured ones (a browser cell's own);
    • its group isn't directly in the delegated group;
    • any host path it would give the cell is neither configured nor inside one of the broker's own run directories.

    The configured paths are the agent binary, the harnesses' tools, the browser's controller, fonts and NSS modules, and the scheduler's run directories. A run directory is named as the broker names them, owned by the broker, writable by no one else, and nothing on the way into it is a link. Each rule is tested on its own. The spawner now reads the broker's configuration at its start, as the keeper does.

  • The owner-facing sockets admit the broker's owner (WO-017, step 3). The API, UI, operator, device and ingress sockets admitted the broker's own uid. They now admit its owner, recorded at start: HATCHLING_OWNER_UID when set; else, for a broker sudo started as root, the user who ran sudo (SUDO_UID); else the broker's own user.

    • The sockets and the UI and operator tokens are made for that owner and given to it. A stale socket or token may be the owner's or the broker's own.
    • A socket refuses to start when its owner is also the agent's uid, as before.
    • The root quickstart changes: the broker still starts in the root shell, but you run its clients as yourself (sudo -u "#$SUDO_UID" …). That's what lets the broker stop being root without the owner losing access.
    • The hosted-model and browser end-to-end scripts, whose clients run as root in their own namespaces, set HATCHLING_OWNER_UID=0.
  • An approved workspace delivery is written by the spawner, as yours (WO-017, step 3). With a spawner running, the broker hands the approved bytes to the spawner, which writes them into the configured workspace (HATCHLING_ARTIFACT_WORKSPACE) as a new file of the owner's, mode 0600. A root broker used to write it as root, so the file was root's and the owner couldn't read it; and once the broker isn't root, it can't write in the owner's workspace at all. The spawner writes only:

    • into the configured workspace, opened without following a link, and only when the owner owns it;
    • under a workspace name, never over a file or through a link (O_CREAT | O_EXCL | O_NOFOLLOW), at most 24 MiB, sent in frames;
    • given to the owner before a byte is written (hatchling_sys::write_new_file_for).
    • a harness's patch goes the same way: before, the broker wrote it into the workspace itself, which a broker that isn't root can't.
  • The spawner makes each cell's group (WO-017, step 3). With a delegated group (HATCHLING_CELL_CGROUP) and a spawner running, the broker no longer makes a cell's group. A spec names the delegated group itself, and the spawner's policy now requires exactly that. The spawner makes the cell's group in it, with the configured limits, names it in the spec its helper reads, and ends it, with whatever is left in it, once the helper has ended. A broker holding its cells' groups could lift their limits. A real-delegation test shows the group made in the delegated one and gone after its helper.

  • A harness's trees on the cell's own ids go through the spawner (WO-017, step 3). With a spawner running, a harness's work tree and scratch directory are given to its cell, and taken back after it, by the spawner, as on subordinate ids already. Taking them back is new: a root broker read what the cell left by overriding file permissions, and a broker that isn't root can't. The spawner holds such a request to its policy:

    • a tree in one of the broker's harness run directories (hatchling-harness-…, the broker's, writable by no one else);
    • given only to the configured cell's identity, or taken back only to the broker's.

    Each tree is opened beneath the temp directory without leaving it or following any link (hatchling_sys::chown_tree_beneath, openat2 with RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS), so a broker that renames something meanwhile can't point the change elsewhere.

  • A helper runs in a directory of the spawner's own (WO-017, step 3). The spawner used to write each cell spec into a run directory the broker named, and the helper staged its cell there, both as root. Now the spawner makes a private directory for each helper, writes the spec there, gives it to the helper as its temp directory, and removes it once the helper has ended. So once the broker isn't root, nothing root writes is placed under a name the broker could change meanwhile. The broker's request no longer names a directory.

  • A cell's identity is worked out for the spawner, which makes it (WO-017, step 3). The broker records the spawner's uid and gid when it starts it (hatchling_isolation::linux::spawns_as). Each cell's identity is then decided for that maker, and so is the uid the agent sees its maker as, not from whatever user the broker runs as when it builds the spec. While the broker is root, nothing changes; this is what lets it stop being root. A rootless spawner's cells take their subordinate ids from its own ranges (hatchling_sys::subid::cell_ids_for). A cell's view runs ldd from /usr/bin or /bin, never from PATH.

  • Run as root, the egress authority runs as hatchling-egress (WO-017, step 3). The process that alone reads the credentials of the requests it makes no longer runs as root. A root broker refuses to start without the user, and says how to create it (the README gains that line). What the authority reads at its start must be readable by that user: the CA bundle (HATCHLING_EGRESS_CA_BUNDLE), and the harnesses' binaries and project roots. The authority now tells the broker it started, or why it refused, and the broker waits for that word: before, an authority that couldn't start was found out only at its first request. It logs the user it runs as.

  • WO-017 is done: the trusted side is privilege-separated. Run as root, it is four processes under four users: the vault keeper (hatchling-keeper), the egress authority (hatchling-egress), the spawner (root, which starts every cell), and the broker, which drops to hatchling-broker once its start is done. Either way, the broker then confines its own file access with Landlock. The CI proof of a root broker reads all four processes' /proc status, scans the broker's memory for the run's canary credential, and is refused the vault as the broker's user. A cell's vault secrets and browser sign-ins still reach their cell through the broker; WO-031 moves them. The entries below are its parts. The README, the module map and the trust boundary describe it.

  • The broker confines its own file access (WO-017, slice 4: your choice, 2026-10-24). Without root, the broker runs as you, so a compromised broker could read anything you can: your SSH keys, your projects. Once its start is done, on one thread, the broker now holds itself for good, with Landlock (no privilege needed), to what it keeps and reads:

    • read and write: its stores and state (made first if absent), its temp directory and its socket directory;
    • read only: the system's paths (/etc, /usr, /lib, /proc, /sys, /run), its skills and their trust roots, and the browser bundle it snapshots;
    • nothing it may run: every program a cell needs is started by the spawner.

    The vault, the rest of your home and every other file are out of its reach. A root broker, dropped to hatchling-broker, is held the same way. A kernel without Landlock (before 5.13, or with it off) leaves the broker as it was, and the broker says so in its log. hatchling_sys::landlock builds the ruleset from raw syscalls, handling every file right the running kernel knows.

  • ldd never runs as root on a binary another user could change (WO-017, step 3). Building a cell's view, the root helper ran ldd on its program and tools, among them a browser in the broker's own snapshot, so the dynamic loader parsed, as root, an ELF that the broker wrote. Once the broker isn't root, a loader bug there would have been a way back to root. ldd now runs as whoever could change what the path names: the first owner other than root of the binary or a directory above it, with only that user's group. Only what root alone could have changed is read as root, as before.

  • Run as root, the broker runs as hatchling-broker (WO-017, step 3). Once everything that needs root is done (the keeper, the egress authority and the spawner started, the owner's sockets and tokens bound and given to the owner, the agent's cell started), and while it still has one thread, the broker drops to its own user for good: its real, effective and saved uids and gids all that user's, no supplementary group, no capability left, and no exec that could gain one (PR_SET_NO_NEW_PRIVS), each checked after it is done (hatchling_sys::drop_to). The hosted-model CI proves it from /proc. What it keeps that wasn't handed over with its stores is handed over at start: memory, goals, the scheduler's run directories, and its activity log, a file made beside the store (hatchling_sys::hand_file_to). Every cell is started through the spawner, which now holds the broker's run directories to that user's ids, and makes each cell's group, so the broker no longer checks the delegated group once it has a spawner. The sweep of an earlier broker's leftovers runs after the drop, as the user that made them, and clean, run as root, takes the serving lock and then drops too. The skills' trust roots file may now be root's as well as the broker's own: root's, which a broker that isn't root reads and can't rewrite, is better. The run directories a cell binds from (the browser's snapshot, a harness's trees, a skill run's) are now 0711: a cell's setup opens what it binds inside a user namespace where hatchling-broker isn't mapped, so only their bits let it through, and no one but the broker lists or writes them.

  • Run as root, the broker's stores belong to hatchling-broker (WO-017, step 3). The broker's sealed stores (builds, spaces, schedules, the web stores, and the output gate's directory with its redaction store) are handed to that user at start, made if absent, as the vault was to the keeper's. Until the broker itself stops being root, it acts for that user in them (hatchling_vault::act_for): each is opened as that user's, and what the broker makes there is given to it. So are the operator's commands run as root (migrate-vault, rekey-vault, redactions, …), which act for the keeper's user and the broker's at once: act_for now takes several users, each once, never two in one directory. A root broker refuses to start without the user, and says how to create it (the README's quickstart gains that one line). Memory, goals, the run directories, the activity log, the serving lock and the trust roots follow with the broker's own drop.

  • Run as root, the vault keeper runs as hatchling-keeper (WO-017, step 3). The vault, its master key and its keyring belong to that user alone, and the keeper process runs as it, with no capability left after the exec. A root broker refuses to start without the user, and says how to create it (the README's quickstart gains that one line).

    • Handover: at its start, the broker hands the vault, its key's, its keyring's and its KEK's directories to the keeper's user, by handle, each change logged. It refuses one that user couldn't reach, naming the directory it may not search. The keeper logs the user it runs as.
    • Security keys: the broker opens the security keys before the keeper starts and hands them to it (HATCHLING_KEEPER_KEYS), so the hardware tier keeps working without a udev rule. A key plugged in later is seen at the next start.
    • The operator's vault commands (migrate-vault, rekey-vault, vault-keyring, and the others that open the vault) still run as root. They hand over the same directories first, and act for the keeper's user there (hatchling_vault::act_for): what they open there must be that user's, and what they make there is given to it.
    • Without root nothing changes: the keeper runs as the broker's own user.
    • docs/storage.md says who owns what. The hosted-model and browser end-to-end scripts create the user and make their state directories searchable by it.
  • The vault keeper no longer opens the broker's stores (WO-017, step 3). It used to open every sealed store to unwrap its key. Now it asks the broker for each of the broker's stores' keys as the store keeps them, wrapped, and unwraps them without reading or writing a file of the broker's (StoreKeys::unwrap_kept, wrapped_store_key, and the HKA1/HKK1 handoff in hatchling_vault::handoff). A store with no key yet is given a fresh one, kept on its first open as before. The keeper still opens only its own vault; the broker checks its own stores. A key given as another store's doesn't unwrap, and the broker refuses to be asked for the vault's key. This is what lets the keeper run as a user of its own, which can't open the broker's stores.

  • The broker's own agent cell starts through the spawner too (WO-017, step 3). The broker no longer forks any cell itself while a spawner runs. Its agent cell is the agent-run helper's to start, like a run's, in a private directory and a group of its own: the helper hands the cell's connection back over its stdin, logs as the broker does, and writes where the broker does. The spawner holds the cell to the agent's identity and the configured agent binary. A real-process test starts it through the real spawner and helper, and the real agent completes its handshake over the connection that came back.

  • Every cell's helper starts through a spawner process (WO-017, step 3 begins). The broker starts hatchling-broker spawner right after the vault keeper and the egress authority, while it still has one thread. Each helper (agent-run, skill-cell, browser-cell) is a connection of its own to it, handed over by SCM_RIGHTS: the broker sends the helper's spec, its run's private directory and its stdio, and the spawner checks the spec is one its helper starts, writes the spec file, starts the helper, and supervises it, reporting how it ended and killing it when the broker asks, at its deadline, or when the broker goes. A harness's work trees on subordinate ids are given and taken back through it too. The broker still runs as root; the spawner is what keeps the privilege once it doesn't. Served in-process, with no spawner running, the broker starts its helpers itself, as before.

  • A gate's scan no longer hands the broker the egress credentials (WO-017, step 2). The output and artifact gates scan for the values the vault keeper chooses, and it sends them to the broker; until now they included every egress destination's credential, which the broker handled itself. With the swap in the egress authority, the broker never holds one, and the keeper leaves them out. Served in-process, with no keeper, the gates still scan for them, since there the broker's own proxy holds them.

  • A harness run's relay runs in the egress authority; the broker never holds a vendor key (WO-017, step 2). A run's connection to the authority names its harness and the surrogate its cell holds. The authority finds the harness's vendor in its own configuration (its API, its one destination, its pinned addresses) and reads the vendor key from the vault keeper (egress authority (harness) in vault-audit log), so a run with no key is still refused before its cell starts. When the cell starts, the broker hands the authority the run's handoff socket (SCM_RIGHTS), and the authority takes the cell's listener from it and serves the relay; the broker gets the run's counts at its end. The keeper refuses the broker's own reads of the vendor keys. Served in-process, with no authority running, the broker relays as before.

  • The egress swap runs in the egress authority; the broker never holds an egress credential (WO-017, step 2). Each session's egress is a connection of its own to the authority, which the broker hands it over their pair (SCM_RIGHTS) and the authority serves on a thread of its own: the session's proxy, with its surrogate key, lives there and ends with the connection. The authority mints the cell's surrogates, and forwards each request with its destination's credential, read from the vault keeper for that request alone (egress authority (egress) in vault-audit log); the keeper refuses the broker's own reads of them. The broker still checks the session's grants first, keeps the operator's verdicts, and moves bodies in parts; the authority also refuses any destination no agent is granted. Request and answer bodies cross in parts of at most 60 KiB, to the proxy's 1 MiB bounds. A search is a connection of its own too, so nothing waits on another's request. Served in-process, with no authority running (as with no keeper), a session's egress is a proxy of the broker's own.

  • Web search runs in the egress authority, a process of its own (WO-017, step 2 begins). The broker starts hatchling-broker egress-authority right after the vault keeper, while it still has one thread, and joins it to the keeper by a socket pair the broker makes and closes. The keeper releases search's key (search-brave) on that channel alone and refuses it on the broker's own; it answers nothing but reads there, and records the authority's reads as its own (egress authority (search) in vault-audit log). The broker sends the query and gets the results back; it never holds the key. The authority isn't dumpable. It isn't yet an OS boundary: it runs as the broker's user until the spawner split. The egress swap and the harness relay move to it next.

  • The app is dark by default. A fresh app opens in dark mode whatever the system's appearance, from its first paint; Settings still offers Light and System, and System follows the system as before.

  • The gates scan only for secrets a cell has had (WO-017, step 5). The output and artifact gates asked the keeper for every secret the configuration names, so a broker claiming to be a gate could read them all. Now a gate asks the keeper to scan, and the keeper chooses: the secrets a cell has been given or has stored, and the channel accounts' and egress destinations' credentials, which the broker handles itself. A granted secret no cell has had never leaves the keeper. The keeper records a release before the cell has it, sealed in the vault as keeper-released (no grant may name it), so a secret a cell put in memory is still scanned for after a restart; a release that can't be recorded is refused.

  • A vault keeper holds the master key and the secrets store's key; the broker holds neither (WO-017, step 4). The broker starts hatchling-broker vault-keeper first, on a socket pair, before it touches a key. The keeper reads the configuration from its own environment and the operator's terminal (for a keyring's prompts), loads the master key, hands the broker the keys of its stores other than the secrets, and drops the master key. It answers the broker's reads only for names the configuration gives each purpose: an egress destination's credential, a configured harness vendor's key, a channel account's credential, the gates' held secrets. It answers cells' vault ops under their grants, and records every release in the vault's access log before sending it. A read the configuration doesn't give its purpose is refused and recorded as denied. The keeper isn't dumpable, so a process of the same user can't read its memory. It is not yet an operating-system boundary: it runs as the broker's user, and a root broker can reach any process, until the spawner split gives the broker a user of its own. A store that is off (artifacts or spaces with no directory) is no longer counted among the sealed stores.

  • The broker serves with its stores' keys alone (WO-017, step 2). At start it unwraps the key of each store it's configured with, then drops the master key, zeroized, before serving anyone. A store it wasn't configured with, or one of its stores at another path, doesn't open (StoreNotHeld). A store that doesn't exist yet is still made on first use: its key is made at start and kept in the store when it's first opened, unless a different key is there by then. The master key is now loaded only by the operator subcommands (migrate-vault, rekey-vault, vault-keyring, redactions). This is the interface the keeper process will fill. Breaking, for code built on hatchling-vault or hatchling-broker: SealedVault::open and open_in take any OpensStores (the MasterKey, or StoreKeys), so a &MasterKey still works but an &Arc<MasterKey> needs &*; the broker's store types and ops take &dyn OpensStores.

  • Every sealed store has its own key (WO-017, first slice). Each store (secrets, schedules, artifacts, spaces, redactions) seals its files under a random key of its own, kept in the store's .store-key, wrapped under the master key and bound to the store. A key is made once, the first time the store is opened, and never made again over files sealed under it. A wrapped key copied into another store is refused. rekey-vault gives each store a new key, and a rerun finishes one that was interrupted. This is what lets a later slice hand the broker its own stores' keys and keep the master key out of it. Files are now HVL3 envelopes. Breaking, on upgrade: stop the broker and run hatchling-broker migrate-vault, which re-seals HVL2 and HVL1 files into each store's key; until then the broker refuses to start, naming the files to re-seal. Back up .store-key with its store: without it the store can't be read.

  • WO-014 is done: the full broker runs without root, its cells on the owner's subordinate ids. A broker refusing to start an endpoint with cells on its own uid now says the agent's uid follows from the range, rather than to set HATCHLING_AGENT_UID.

  • Breaking, for code built on hatchling-broker: its Error gains SocketPathNotOurs and TokenPathNotOurs (WO-024), so the workspace moves to 0.5.0.

  • Breaking, for code built on hatchling-isolation or hatchling-sys (WO-014):

    • CellIdentity gains group (the cell's own gid) and mapping (Mapping::Direct or Mapping::Subordinate); CellIdentity::direct(principal, count) builds the identity every cell had before.
    • hatchling_sys::share_dir_with_cell takes the cell's gid.
    • subid::range_for takes the account's name as an Option.
  • Every delete and signal stands behind two guards (WO-024), after a mutant of WO-012's tree removal wiped a test machine. Each check is its own, so no one changed operator, or one bug, gets past both:

    • a stale socket is unlinked only when it's a socket, by its type and its mode, and the broker's user's; another user's socket now refuses the start, naming it;
    • a UI or operator token file is replaced only when it's a regular file of the broker's user; anything else now refuses the start, naming it;
    • the vault's KEK file is dropped only off the file tier and with no way in through it;
    • an expired memory item is deleted only when its own TTL agrees;
    • a cell's staging, and a run's, a skill run's and a skill's code directories, are removed through remove_tree, which never enters a mount or another user's directory;
    • hatchling-dev kills a timed-out process group before reaping its leader, and never after.

Fixed

  • A helper deletes only its own cell spec. A helper (agent-run and the other cell subcommands) reads the spec its broker wrote, then removes it, and it removed whatever absolute path it was given: run by hand as root with the wrong path, it deleted that file. Now it refuses a path unless the file is named cell-spec.json, isn't a link, and is its own user's, owner-only, as a broker writes it, and leaves anything else as it is. Found by the 0.5.0 release security checklist.
  • No OS error text reaches the owner or the agent from a failed delivery or search. A scheduled output the local channel couldn't take was parked with the channel's error, which can carry an OS error's text, as the reason the owner reads, and a search whose key the vault keeper couldn't read told the agent the keeper's error. Each now says a fixed sentence (a parked result still says when no local client was attached), and the detail goes to the broker's log. LocalHub::attached says whether a client is. Found by the 0.5.0 release security checklist.
  • A browser cell can't forge a line of the broker's log. A reply from a browser cell that wasn't one, and what its helper said on stderr (which the cell shares), went into the broker's reasons as written, and several log lines print those reasons as they are. They're now escaped where they enter the broker. The name and domain of a cookie dropped as another site's, which the browser chose, and a sign-in's label are logged escaped too. Found by the 0.5.0 release security checklist.
  • A profile's cookies are wiped from the broker's memory once used. A cookie's value, a signed-in session, now wipes itself when dropped, and so does every encoding of a jar the broker makes, and every frame it exchanges with a browser cell, which can hold cookies or a code being typed. Breaking for hatchling-core: Cookie implements Drop, so its fields can't be moved out of it. Found by the 0.5.0 release security checklist.
  • A chat run can't leave its words under the owner's label (WO-032). A chat run may not say an idea is the owner's, but it could refine one that was, and the idea kept saying it was captured from the owner's chat: an approval card could show a run's text as what the owner said. An idea a chat run refines is now labelled as not stated.
  • A run's relay handoff is the broker's alone (WO-029). The socket a harness or browser cell hands its relay listener in on was made under the broker's umask, and the first to connect is believed. Under a umask that leaves it group- or world-writable (0002, 0000), another local user could hand in a listener of their own, and for a browser cell the broker would serve the session's proxy on it. It's now made 0600 whatever the umask.
  • Scheduled runs and subagents no longer browse signed in as you (WO-030). Every session got the broker's browser, and a site covered by a profile you kept signed in opened in it without asking, so a scheduled run, a hook's or a subagent, whatever grants it was given, could act in your signed-in session, have your sign-in filled, and keep its cookies in your profile. Now only the agent's own session and a chat run, where you're there to see it, use your profiles and sign-ins; every other run browses signed out, and a kept site is put to you as any other page.
  • No other site can frame the app. hatchling-host now answers every request with Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN: a page you visit could otherwise lay the app under its own and take your clicks. (Its token never leaves the app's memory, so a framed copy couldn't reach the broker.) The app's own policy no longer lets it frame blob: documents, which it never does.
  • A chat's history is bounded as it's sent (WO-026). The 24 KiB a chat run is given was measured as text, but a run is sent it as JSON, where a control character takes six bytes. An answer the model was led to fill with them was kept, its history was then too big for the agent to accept (past 128 KiB, for the kernel to pass at all), and every chat run after it failed until a new conversation, or, with a conversation store, across restarts too. The bound is now measured as sent: such an answer isn't given back, and every history a run is given is one it accepts.
  • The sweep keeps every store and project root whole. A broker's start, and hatchling-broker clean, remove the leftovers of a broker that was killed, keeping each configured state directory whole even inside one. They didn't keep the always-allowed sites (HATCHLING_WEB_GRANTS_DIR), the browser's signed-in profiles (HATCHLING_BROWSER_PROFILES_DIR), the sign-ins (HATCHLING_BROWSER_CREDENTIALS_DIR) or your projects (HATCHLING_HARNESS_ROOTS), so one set inside a leftover directory was removed with it. Now they do, as docs/storage.md says.
  • What became of a build you approved is told in the app (WO-027). The broker told the outcome on the local channel, whose one reply path is a terminal client or, once you've sent the app a message, the app, which showed it as the agent's words. Approved in the app before any message, the outcome reached no one. It's now Hatchling's notice in the app's chat, never the agent's words. Found by the app's first approval test against a real broker.
  • A denied build stays denied, reworded or from a fresh idea (WO-027). The owner's denial of an activation was keyed by a fingerprint that took in the agent's description and the idea's id. So once the 10-minute cooldown passed, the agent could ask for the same build again in other words, or capture the same idea afresh, and the owner was asked again. The fingerprint is now what would be built: the kind, the builder and each input's name and hash. The builder never sees the description.
  • Asking again for the same build is the request you're deciding (WO-027). Each proposal got a new activation id. So an agent that asked again for a build already waiting on you was refused as queue-full, and its proposal replaced the one your waiting request pinned: approving that request then built nothing. The same build now keeps its activation, so it is the waiting request and you're asked once. A proposal that isn't put to you leaves the idea as it was.
  • A sign-in form never takes the focus while you type (WO-030). The form for a new sign-in in Settings moved the focus to its Name field a frame after it opened. On a busy machine that frame could come after you had moved on to the username or the password, and the next keys went into the name, which Settings shows. The form now focuses its first field only if none of its fields has the focus. The Secure store's add form had the same pattern and is fixed too.
  • Signing a profile out holds while its browser runs (WO-030). Sign out dropped the profile's sealed session, but a browser still running for it kept its session again after the agent's next action, on close, and when the owner handed it back, so the next launch was signed in again. Each profile now counts its sign-outs, and a browser keeps a session only if the count hasn't changed since it took the profile. The next browser for the profile starts signed out. Every cookie value a browser reads is also hidden from the agent before the broker tries to keep it: before, a session the store refused to keep left its cookie values visible to the agent.
  • A sign-in the owner keeps leaves no copy in the broker's memory (WO-031). The owner's app sends a sign-in to keep, and a one-time code it gives, in an API frame, and the broker freed each frame without wiping it: a bug that disclosed the broker's memory could find the username and password there. Every API frame is now wiped once it's read. CI's browser proof found the copy, and now checks for it: it keeps a canary sign-in from the app, has the agent sign in to a site with it, and scans the broker's memory, its log and every request the model was sent for it.
  • A scheduled run, a subagent or a chat holds only its own vault grants again. Since the vault keeper answered cells' vault ops (WO-017, #144, unreleased), it checked them against the operator's grants alone, not the narrower set the run's session holds. A prompt-injected scheduled run whose schedule granted no secret could fetch or store any secret the agent itself was granted. The broker now names the session's grants with each op, and the keeper answers under only the grants both it and the session hold.
  • A run's task and chat history no longer show in ps. The broker started each cell's helper (agent-run, skill-cell, browser-cell) with the cell's spec as an argument, and any local user can read a process's arguments (/proc/<pid>/cmdline) for as long as it runs. A scheduled or chat run's spec carries its task and its chat's history. The spec is now written to a file of its own in the run's private directory, owner-only, and the helper is given only its path; it reads the spec once and removes the file.
  • A page that makes a WebRTC peer loads in the agent's browser (WO-029). WebRTC's network thread opens its event loop with the old epoll_create, which the browser cell's seccomp filter didn't allow: the kernel killed the page's renderer, and the page never finished loading, so a page whose scripts make a peer as it loads (fingerprinting scripts do) timed out. The browser profile now allows it; it's epoll_create1, which it had, by its old name. Found by a real-browser test of what a page can reach: UDP still reaches nothing, the cell having no network but its loopback and Chromium none but its proxy.
  • A fresh sealed store opened by several at once gives each its key. An opener that found no key file, and then an entry another opener had just sealed, refused the store as StoreKeyMissing, though the key was there: the other had made it first, as every sealing does. Now an opener that finds a sealed entry reads the key file again before refusing. It showed as a rare failure of the vault's own race test under load; a stress round of it failed 8 of 30 runs on the old code and none on the new.
  • The agent's browser shows in the owner's chat (WO-029). The broker published each browser session with no chat, and the live app gives a browser a card only in its chat, so the owner had no way to open it: to watch it, stop it or take it over. Every browser now shows in the broker's one chat, whatever run started it. CI now proves it in the app (browser in the app): a broker with the pinned Chromium, a scripted model driving the agent's browser tools, and Playwright as the owner, allowing the site, opening the browser from the chat, taking control, scrolling and typing with the app's own controls (each answered with a newer picture of the page), and handing it back, with the agent reporting it waited while the owner drove.
  • A browser's message is read in time linear in its size. The browser cell's controller looked for a message's end from its start each time more of it arrived, so a message that came in small pieces cost time quadratic in its size: one at the 8 MiB bound could keep the controller busy for seconds, and a test of that bound timed out now and then. Each byte is now looked at once.
  • A scheduled run is never lost as it starts, never launched twice, and its result is never dropped (WO-025, part 1). A schedule's sealed record now holds its claimed fire. The fire is claimed in the same write that moves the cadence on, and the run is launched only once that write is committed, so a failed write launches nothing instead of launching the same fire again after the run ends. A one-shot is kept, its fire recorded, until that fire's result is resolved, instead of being removed as it starts. A finished run's result is stored before it is delivered, and delivered from the store; one that can't be delivered or parked stays stored and holds that schedule's next fire, loudly. Parking no longer drops the oldest result when 64 are kept: it refuses. A schedule the index names whose record is missing is an error, not skipped. A fire left claimed or running by a broker that died is held for now (never run again); reporting it as an unknown outcome to resolve or retry is part 2.
  • A claim whose write fails after it lands is launched once, not held forever (WO-025, part 3). A store write can fail after its new state is visible: the rename went through, and the directory's sync after it didn't. The dispatcher took such a claim as not made, so the fire was never launched and its schedule's later fires were skipped as overlapping, until a restart held it as unknown. Now a failed claim is uncertain: no schedule fires until the next tick writes it again, and then a claim that landed is launched once, as the fire it claimed, and one that didn't is claimed afresh. WO-025 is done.
  • A broker read of the vault that can't be recorded releases nothing (WO-017, step 3). The broker's own reads (an egress credential, a harness vendor's key, a channel account's credential, the gates' scan of held secrets) wrote their access-log line after reading and released the secret even when the line couldn't be written. A cell's vault ops already failed closed. Now every read of the secrets store goes through one place, hatchling_broker::secrets, which records the read, with what it was for and whether anything was found, before returning it, and returns nothing when it can't. An egress destination or harness whose credential can't be recorded gets none; a session whose channel accounts can't be recorded ends; the gates block. This is also the one place the keeper will take over.
  • A credential no longer lingers in freed memory after it's sent.
    • The egress proxy built each model request's Authorization: Bearer <key> in an ordinary string that grew as it went, and dropped it unwiped. The harness relay built its vendor key's request head the same way.
    • Each now writes the key once, into a buffer allocated at the request's exact size and wiped when dropped.
    • The broker's device signing seed is wiped on drop too.
    • What TLS buffers internally is outside the proxy's reach; the egress authority (WO-017) is where that narrows.
  • A cell that fails while it's being set up says why, at once. The spawner held its own copy of the pipe the cell reports on, so the report never reached its end: the broker waited out its 30-second timeout and dropped the reason ("child reported" and nothing). It now closes that copy first, and the reason (an unshare refused, say) arrives as soon as the cell gives up.
  • A chat message is answered with the chat prompt. The local chat path started its run with the background worker's prompt; a chat turn now says so, typed from the chat path to the agent (HATCHLING_RUN_CHAT), and gets the chat prompt.
  • A batch of tool calls is checked whole before any of it runs. An unknown tool or bad arguments later in a batch used to stop the loop after earlier calls in it had already run, unrecorded.
  • A reply the provider cut off isn't taken as finished. A reply with finish_reason length (the output limit) or content_filter is now a model failure, not a final answer.
  • An unprivileged broker whose uid and gid differ can run cells. A cell under the broker's own map took the uid's number for its gid, which the kernel refuses to map unless the broker's gid happens to equal its uid: an account whose primary group is shared (users, a domain group) couldn't start a cell. It now maps the broker's own gid. The spawner also checks every identity it is handed, a direct one as well as a subordinate one, before anything on the host changes for it, so a refused identity no longer leaves the broker socket handed over.
  • A harness's files are never read whole. The broker read each work-tree file, the question file and each staged file in full before checking its bound, and a cell may write files up to its file-size limit (8 GiB by default): a hijacked harness could leave one huge file and make the broker allocate all of it. Each read now stops one byte past what its bound allows: a work tree or staged slice past its bound is refused, and a question is cut, as before, without the rest being read.
  • pid_alive no longer says a dead process is alive. A process being reaped (state X) still answers a signal probe, and a zombie reaped between the probe and the read of its /proc stat left nothing to read; both counted as alive. Now neither does, and an unreadable stat asks the probe again, so a process hidden by hidepid still counts. The staging sweep relies on it, and a process-group test failed on it now and then.