Agent-First UI v0.6.0: Remote Until Proven Local

by Agent-First Kit Contributors

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.

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:

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

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