Agent-First UI v0.6.0: Remote Until Proven Local
A page can now open a link outside itself, in the system browser for a local window and in a new tab for a remote view. Deciding which case it was is the whole risk, so a surface counts as remote unless it presents a key only AFUI's own window holds. Also: sessions that close the way they say, streams that end when their attention budget does, and a registry that is written atomically on every platform.
A window is a bare web view: no address bar, no tabs, nothing to navigate away to. That is what makes it a window rather than a browser, and it is why a link inside one must never navigate there. A person who followed one would be on a site whose address they cannot see, in a surface that had just lost the session it existed for.
Until now a page had no other way to open anything. The macOS window swallowed
target="_blank" outright, and the Chromium window opened it in a throwaway
profile. This release gives a page that other way, and most of the work went
into deciding where the link is allowed to open.
A link opens outside the page
const ui = afui.connect({onState: render});
// later, when the person asks for the page itself
await ui.openExternal('https://example.org/report/7');
What happens depends on where the person is. In the local window, the host
hands the URL to the system default browser — open, xdg-open, or
rundll32 url.dll,FileProtocolHandler — as one argument, never through a shell.
In a remote view, the person’s own browser is right there, so a new tab opens
in it, and the host opens nothing on a machine nobody is sitting at.
The host, not the page, decides what is eligible: a plain http or https URL
of readable length. javascript:, data:, file: and mailto: are refused
as ui_open_external_url_invalid before any opener runs. The promise resolves
to {opened, via} or rejects with a code, and a request is not a call:
nothing is resent after a wire drop, because a link a person clicked once must
not open twice.
Which machine is the person at?
That one question carries all of the risk. Answer it wrong in one direction and a click does nothing. Answer it wrong in the other and a person on another machine can make this one open whatever URL they like, in the browser that holds this machine’s sign-ins.
The first answer was the natural one: AFUI’s own remote-view proxy marks every
request it forwards, so a connection carrying the mark is remote and one
without it is the local window. That holds only while the proxy is the sole way
in from elsewhere, and it is not. A Provider that serves sessions from its own
listener through a UiMount, a tunnel to a loopback port, a container with one
mapped port — each reaches the runtime directly from another machine, without
the mark, and would have been treated as the local window.
So the rule is the other way round now: a surface is remote unless it proves
it is local. When AFUI opens its own window — a window delivery,
ActiveUiSession::open_window, or afui session open — it puts a per-session
key in the URL’s fragment:
http://127.0.0.1:43117/3f9c…/#afui-window=<64 hex>
A fragment never leaves the browser: no server, proxy, or access log sees it.
The page kernel moves the key into this tab’s session storage and out of the
address as soon as it loads, so a reload keeps it and nothing that later
copies the address carries it along, and hands it back in the runtime hello. The runtime compares it in constant time. Only a matching
key makes a surface local, and a connection through AFUI’s proxy stays remote
even with one. The ready frame tells the page which case it is
(remote_view), and the host enforces the same answer again when the request
arrives, refusing a remote one as ui_open_external_remote.
Failing closed has one visible cost, and it is the right one to pay. A window
a Provider launches itself with UiWindow::launch on a bare access URL has no
key, so its links take the remote-view path.
Sessions that close the way they say
Three ways of ending a session did not end it:
- An upstream at the root path. The close request doubled the leading slash, so the Provider never saw the end. The path is now split and joined the way the proxy does it.
- An authenticated mount. The shell’s End control posted through the session’s framed path, which did not reach a session behind an authenticated mount. It now posts to the shell’s own origin, and the listener carries the close to the upstream under the mount’s existing credentials and transport, within one request budget.
- A Provider that never answers. The shell’s End route asks its own budget first, so the person gets a 504 that says the Provider timed out rather than the proxy’s 502.
And afui session serve reported a failure and still exited 0. It exits 1 now,
and the smoke test also proves that a normal interrupt still ends with a result
and exit 0.
Streams end when their attention does
A remote view’s attention budget — the idle timeout and the warning grace after it — advanced only when a new request arrived. A page that had already established its stream made no new requests, so its clock never moved, and the stream outlived the budget meant to end it. Direct link delivery and every proxied stream now share a clock with a wake-up of its own: expiry and revocation end an established stream without waiting for another request, and the check runs before each frame is forwarded.
The same pass tightened what an ending owns. A surface that upgrades and never says hello is dropped after a bounded wait instead of holding its socket for the life of the session. Tearing down a runtime cancels a write that is blocked on a peer that stopped reading, rather than waiting for it to resume.
A registry other processes can read while it changes
The session registry is how afui session list, open, and serve find
sessions in other processes. An entry is now written to a private temporary
file in the same directory and renamed into place, so a reader never sees half
of one, and an entry that was accidentally made readable by others is replaced
by a private file rather than rewritten in place. A record that cannot be
parsed no longer counts as proof its session ended: it is left for a later read
instead of deleted.
On Windows, replacing a file that another process has open fails when the
replacement goes through MoveFileEx, so a Provider republishing its entry
while something listed sessions got “access denied”. Publishing now uses a
rename that replaces a file opened with delete sharing. Cookie lifetimes are
also capped at 400 days (RFC 6265bis). Whether a very large Max-Age
overflowed the clock depended on the platform’s time type, so the same cookie
used to be dropped on Unix and kept on Windows.
Breaking changes
- The
servefeature is gone.afui session servebelongs tosession, which is the default. A plaincargo installused to lack a verb the packaged binary had; now every build has it. Dropservefrom your feature list. - A surface without the window key is remote. A page that called
openExternalfrom a window opened some other way now gets a new tab instead of the system browser. Open windows through AFUI’s delivery, oropen_window, to keep the local behaviour. UiSessionEntrygainswindow_url_secret, the URLafui session openlaunches. Code that builds entries with a struct literal must set it.
Also in this release: afui --version reports the commit it was built from, and
AFUI moves to Agent-First Data 0.35.0, Agent-First Slug 0.8.0 and dirs 7.
Getting it
$ brew install agentfirstkit/tap/afui
$ cargo install agent-first-ui