ADR 0054: Several hosts in one window
- Status: accepted; §1–§8 implemented 2026-10-18; §9 not built, but for a linked host's Settings (amended 2026-10-23)
- Date: 2026-10-18
- Amends: 30 (a link is a device of the other host), 52 (§6, several hosts in one app)
Context
The person runs nodwyn on a laptop and on a server that is always on
(workshop), and wants both in one window: the laptop's tasks and workshop's side
by side, what waits for an answer on either, and a new task started on the
one that should run it. Today the console holds one socket, to the bridge
that served the page, which relays it to one daemon. Seeing workshop's tasks
takes workshop's own console in another tab, and the desktop window cannot open
one at all: it loads only this computer's loopback address
(cmd/nodwyn/app_bridge.go). A design canvas of 2026-10-17 (a private
claude.ai artifact, [link removed]) drew
the window: tasks grouped by host in the sidebar, Needs you across hosts, a
Runs on picker that the folders and models follow, a Hosts page and Add a
host.
What bounds the design: ADR 30's amendment "a computer nobody sits at" made pairing an invitation and the person a passkey, and the person asked for no hand-made tunnel, nothing that depends on one VPN, and no sign-in that lapses while it is used. A console's cookie, its content policy and a passkey all belong to its origin.
Weighed and not chosen:
- The page talks to each host's console itself. Each console refuses
another origin, its cookie is
SameSite=Strict, a passkey is bound to its host's name, and the window opens no address but loopback. - The bridge keeps the link's token in a file, beside
devices.toml. Simpler, but a token kept in clear is read by any command the person approved, and works from anywhere for 90 days. The keychain holds secrets, the daemon holds the keychain, and it sends a key only where a binding allows (ADR 6). - An approval with matching words, as the canvas drew it. An invitation already proves that the other host's person let this computer in: 100 bits, good once for ten minutes, and the way every device pairs.
- A link that touches a passkey. A page at this computer's address cannot ask for a passkey of another host's name (WebAuthn binds it to the RP ID), and a key the link made itself would prove only that it holds the keychain, which the token already proves.
Decision
- A host is another nodwyn this computer is linked to. Linking pairs
this computer with that host's console as a device, named as the person
chooses (
laptop), with an invitation made there (nodwyn devices invite, or Settings › Keys & devices on one of its devices).host.link {name, address, as, invite}on this computer's daemon posts the invitation to the host's/pairwithhost: trueand keeps the device cookie it gets back. A host's name is this computer's for it (workshop), in the form of a device's. - The link's token is a credential in this computer's keychain, of
class
nodwyn-host-NAME, whose value is the cookie (nodwyn_device=…), with binding rows for the host's name, GET and POST, atheader:Cookie. Linking needs the keychain open. The daemon sends the cookie through the one authorizer a model's key goes through (core.Keyed), so it goes to that host's name and nowhere else, and a command may not take it away.hosts.tomlin the config directory keeps each link's name, address and the name this computer has there. The daemon writes it, as the user, in the config directory (0700), which a task's file tools never reach; it holds nothing secret, and the one thing it decides, the address the daemon dials, is checked against the binding table, so an address changed there is sent no cookie. - The daemon relays.
host.connect {name}, on a daemon connection of its own, dials the host's/socketwith the cookie and the host's origin; after its reply, the connection carries the console's messages to the host and the host's back, one JSON message per line, until either end closes. A host that refuses the link (revoked there, or not used for 90 days) is said apart from one that cannot be reached or whose token is locked away, and a host that closes the link says why (host.closed). An upload to a host's task goes throughhost.attach. - The window keeps one origin and holds one connection per host:
/socketfor this computer,/socket?host=NAMEfor a linked host, which the bridge relays throughhost.connect, and/attach?host=NAMElikewise. A host's link is this computer's authority there, so this computer's browser and the person's device of this console use it, a device that only answers here does not, and only they link and unlink (ADR 30's tiers: using a link is the person's, linking is setup). - The other host's rules decide what the link may do. It is a device
there: it reads, starts tasks, answers and steers, recorded as
via web:laptop. It is the person's, and makes that host's choices (a task's model, judge, helpers, limits and schedule, a key the model asks for, unlocking its keychain), when its invitation was made by a device of that host signed in with the person's passkey, after a touch, and it linked at an address where a browser could use one (https, or alocalhostname), so what the person types through it does not cross the network in clear: the link then carries that passkey's name, and loses those choices when the passkey is removed, as a device signed in with it does. One made bynodwyn devices inviteor the host's own console, which vouch with no passkey, makes a link that answers. A link never changes the host's setup, since it cannot touch a passkey: the host's bridge offers it no setup method, and the Hosts page opens the host's own console for that (amended 2026-10-23: a link that is the person's changes the host's limits and models, unless the host refuses it). The host's Settings › Keys & devices says the device is another nodwyn. - Unlinking forgets the token, its binding rows and the line in
hosts.toml, having first asked the host to forget this computer (devices.leave, which any device may ask for itself), and says when the host could not be told. - What the window shows. With a linked host, the sidebar groups tasks by host, each with its state (ready, connecting, not reachable, refused, keys locked), under one Needs you for every host; Home's Runs on chooses the host a new task starts on, and its folders, models and what it may choose follow that host; the lists below it are every host's, each task naming its host; a task's page names its host; and Hosts lists this computer and each link, with its state, address, the name this computer has there and what it may do there, Add a host, Unlink, and a link to the host's own console. With no link the window is as it was. Settings, Connections, Workspaces and Workflows are this computer's (amended 2026-10-23: Settings also shows a linked host's).
- Notices are per host: a linked host's task that asks shows the same fixed notice, and opening it opens that task on that host.
- Not built: a home host that a phone reaches every host through (plan.md, Next 1.4); the TUI and line mode, which see this computer only; a linked host's Settings in this window (built 2026-10-23, see the amendment).
Evidence
internal/frontend/web:TestALinkedHostIsReachedThroughThisComputersDaemonruns two daemons and two consoles. A link vouched for by the person's passkey is recorded by the host's daemon asvia web:laptop passkey:phone, is offered the person's choices and no setup, opens a task and sends it a file there; one from the host's terminal answers only. The token is in the keychain and bound to the host's name alone; an address changed inhosts.tomlis sent no cookie; the host's revocation closes the open socket with 1008 and refuses the next; unlinking a revoked link says the host could not be told, and one the host still takes is told (devices.leave), each forgetting the token, its rows and its line.TestOnlyThePersonUsesThisComputersLinksplaces the methods in the bridge's tiers.- The
hostsbrowser check, two daemons and two consoles on one computer: Add a host, the sidebar by host, Runs on, a task started on the host with a file, Needs you across hosts and an answer recorded there as the laptop's, a notice that names its host and opens its task there, the phone's width, a revocation, and Unlink.
Not verified: two computers, https between them, or a host behind a proxy
that ends TLS; the vouched link in a browser (the check's host listens on
plain http, where a link answers); and the host's Settings › Keys & devices
saying a device is another nodwyn, which nodwyn devices and the Go test
show.
Consequences
- The daemon's protocol gains
host.list,host.link,host.unlink,host.connectandhost.attach; the console's bridge gainsdevices.leave,hoston/pairand on a device indevices.list. - Both hosts need this version: an older console refuses
hoston/pairas an unknown field, which Add a host shows as its answer. - A link is as strong as the keychain it is kept in: without a passphrase, the keychain's file is as safe as its permissions (ADR 5), and whoever reads it can act on the host as this computer until the host revokes it.
- Every request for a linked host passes this computer's daemon, so the window shows a host only while this computer's daemon runs.
Amendment 2026-10-23: a linked host's settings
The person could not change a linked host's settings from the window: §5 offered a link no setup method, since setup waits for a touch of the passkey and a link cannot give one, and §7 kept Settings to this computer. They asked to change them, by default, with a way for a host to refuse it. Built first as every settings method, it was narrowed the same day (see Review below) to the settings that cannot widen what a task may do.
Decision.
- A link that is the person's changes the host's limits and models
without a touch. The host's bridge grants a link vouched for by the
person's passkey (§5:
devices.tomlnames its passkey)settings.writefor the settings that do not widen what a task may do (the budgets, the loop's timeouts, the window,mcp.runtime, desktop notices),settings.model.writeandsettings.model.remove(a model of the list, among the providers already configured),models.test, andsettings.docs.writefor asking or refusing an upload to Google Docs. It says its hello with that passkey, so the host's daemon takes it as the person's; the daemon is unchanged. The bridge's audit records each such change, as it records a device's. - What would widen stays behind the touch, which a link cannot give.
The bridge refuses a link
settings.writeof anyapproval.*,policy.*orjudge.*setting (what a task does without asking, "Don't ask" and the standing approvals among them, plain HTTP for credentials, and the judge that stands between a command and its approval), however the value changes and in a call that holds other settings too;settings.docs.writeofallow, which stops asking about an upload; and, outright,settings.provider.write,settings.provider.removeandsettings.control.write, which decide where a task's content goes (a provider's address and key, the default model). The hello does not offer those three, so the page shows no control for them; the others fail with the host's reason, and the page shows the approvals, the policy and the Judge tab as the host's own console's. - A link that only answers gets none of it.
host: trueon/pairis the pairing side's own word, so whoever holds a plain invitation, the kind given to a phone that answers, could pair as a link. A vouched link exists only where a browser could use a passkey (https or alocalhostname), so a key typed into the host's Settings does not cross the network in clear. - The rest of setup stays refused to every link: workspaces, MCP
servers, credentials, the keychain's creation and passphrase, deleting a
task, a task's bindings and policy, starting at login, linking, devices
and passkeys, and
trust.set. Those are changed in the host's own console. - A link never holds a touch. A setup action a device takes renews its
touch, so that a person who has touched is not asked again within a
sitting; the first built version renewed it for a link too, and a link
that had changed one setting then passed for a device that had touched,
and was granted every setup method. A link is never touched and never
renews one (
caller.touched, the connection's renewal). - A host refuses its links the settings with
--no-link-settings, a flag ofnodwyn uiandnodwyn appbeside--listen, read when the console starts, as--read-onlyis. It is off by default. The flag is written by whoever starts the host's console: a command line, or a unit or login item the person wrote on that host. No console method sets it, and it is not a setting, so a link that changes settings cannot change it. - The window shows a linked host's Settings at
#/host/NAME/settings/TAB, read and saved over that host's socket: General and Models, with the host named in the heading. The approvals and the policy are drawn and cannot be chosen, the providers and the default model have no control, and the Judge tab is the host's own console's. Settings chooses whose settings it shows when there is a link, and each host's card on Hosts opens its Settings. When the host's hello does not offersettings.write, the page says why (this computer only answers there, or the host refuses its links the settings) and shows no control. The edits waiting in the save bar are cleared when another host's settings are shown, so none is saved to the wrong host. Keys & devices and This browser stay this computer's, and so do Connections, Workspaces and Workflows.
Review. The link's token is a bearer credential kept on the linking
computer, so a link that could change approval.dont_ask, the standing
approvals, the policy or the judge, or a provider's address, would make
that computer's keychain a way to widen what the host's tasks may do
without the touch of a passkey that a stolen token lacks. The first
version granted every settings method on the ground that a vouched
invitation "already lets whoever uses it make a passkey and change setup
after touching it"; but the touch is the part a stolen token does not have.
What stays is what a link already did (it makes the person's choices for a
task, session.model.set and the limits among them): the budgets, the
models of the list and the checks that ask.
Evidence. TestALinkChangesTheHostsSettings places every settings
method for a vouched link (granted for the five, refused for the three that
decide where content goes), for one that only answers (refused), for the
person's device before a touch (asked for one), and on a read-only console
and one started with --no-link-settings (refused to the link, still
granted to this computer and to a device that touched). TestLinkWidens
lists what settings.write and settings.docs.write may hold for a link.
TestALinkedHostIsReachedThroughThisComputersDaemon sends settings.write
of a budget through /socket?host=workshop from a vouched link: workshop's
config.toml changes and the laptop's does not; then approval.dont_ask,
policy.allow_insecure_http, judge.server, a call that mixes a budget
with approval.writes, an upload set to allow and a provider write, each
refused with workshop's file as it was; then mcp.remove, credentials.remove,
startup.write, trust.set and host.unlink, refused after the write
that worked (the touch the first version renewed: with that renewal back,
they reach the daemon). The answering link is offered no settings.write.
The hosts browser check opens workshop's Settings from Hosts with a link that
answers, and sees the refusal and no control. It links again with an
invitation made by workshop's phone, signed in with a passkey from Chromium's
virtual authenticator at localhost:PORT, and sees General and Models only,
the approval chips disabled and no Add a provider; it changes the tool-call
limit and saves, and workshop's config.toml changes and the laptop's does
not. Last, it restarts workshop's console with --no-link-settings, and the
page says workshop refuses it and shows no control.
Consequences. Whoever reads the linking computer's keychain can still
change the host's limits and models until the host revokes the link or is
started with --no-link-settings (§Consequences above), but not what its
tasks may do unasked or where their content goes. A host older than this one
offers a vouched link no settings, which the page says as it says a host's
refusal.
Amendment 2026-10-23: a link chooses the host's default model
The person could not change a linked host's default model from the window:
the amendment above refused a link settings.control.write with the
providers, as deciding where a task's content goes, so the Models tab drew
the default picker disabled. They asked to change it.
Decision. The host's bridge grants a link vouched for by the person's
passkey settings.control.write without a touch, as it grants
settings.model.write, unless the host's console was started with
--no-link-settings; the hello offers it, and the Models tab's default
picker saves through the save bar on that host. settings.provider.write
and settings.provider.remove stay refused outright, and §2's other
refusals stand.
Why the Review's argument does not hold for it. settings.control.write
takes a [models.NAME] entry or a configured server and a model on it,
never an address or a key (controlParams; a provider is saved only by
settings.provider.write). So it chooses among the providers the host's
own console set up, which is what a link already does for each task with
session.model.set, and what settings.model.write already lets it add to
the list. A stolen link token gains no new destination for a task's content
from it: it could already start every task on any of those models.
Evidence. TestALinkChangesTheHostsSettings places
settings.control.write with the granted settings (refused to a link that
only answers and with --no-link-settings, a touch for the person's device
before one), and the providers still refused.
TestALinkedHostIsReachedThroughThisComputersDaemon sees it in the vouched
link's hello, and a settings.control.write through /socket?host=workshop
changes workshop's [control] and not the laptop's. The hosts browser check
finds workshop's default picker enabled over the vouched link, chooses workshop's
other model, saves, and sees the call sent to workshop only and workshop's
config.toml changed. Each of the three fails on the code before this
change.
Amendment 2026-10-23: the console adds and removes a linked host's models
The host's bridge already offered a vouched link settings.model.write and
settings.model.remove (amendment 2026-10-23, §1), but the console's Models
tab drew its model controls only when the host also offered
settings.provider.write, which a link never has: the person saw a linked
host's models and could not add, change or remove one from the window.
The Models tab now gates each model control on the model methods alone.
On a vouched link it offers Add a model (and Add a model on NAME, on
each provider the host already has), Edit and Remove, and sends them
to the host viewed through its socket, as it does for this computer. The
default model still has no Remove. Add a provider and a provider's
Edit and Remove stay for what offers settings.provider.write, so a
link still cannot name a new address or key for a task's content to go to; a
hint says that providers are changed on the host's own computer. With
--no-link-settings the hello offers neither model method and the tab does
not open at all.
Evidence. The hosts browser check, over the vouched link to workshop,
finds no provider Edit, Remove or Add a provider, adds a model on workshop's
provider and sees one settings.model.write sent to workshop, none to the
laptop, workshop's config.toml gaining the entry and the laptop's unchanged;
changes its efforts, chooses it as workshop's default and sees no Remove on it;
removes another and sees settings.model.remove with its name sent to workshop
only and gone from workshop's config.toml. It fails on the console before this
change, which draws no Add a model on that tab.
Amendment 2026-10-27: hosts start folded
With several hosts the sidebar listed every host's folders and tasks open, so
the list of computers was lost under their tasks. A host's heading now starts
folded, showing its name and state, and opens its folders when chosen. This
browser remembers the hosts opened (nodwyn-hosts-open, where folders still
remember the ones folded in nodwyn-folded), so a host folded under the old
rule is folded again until it is opened. A search shows every match whatever is
folded, and Needs you is unchanged, so a task that asks is still in view.
Evidence. The hosts browser check sees both headings folded on first
sight, with no folders under them; opens workshop without opening this computer;
sees workshop still open after a reload; and folds it again. It fails on the
sidebar before this change ("workshop starts open").