ADR 0030: One app, three ways to reach it — desktop, browser, phone
- Status: accepted; implemented as D1–D9, 2026-09-30 to 2026-10-01
- Date: 2026-09-30
Context
ADR 21's design canvas (2026-09-30) reorganizes the console around tasks: a home page, a task view with the approval inline, a Connections page and a phone view. Two requirements sit outside that canvas:
- Remote work stays a browser. The maintainer runs nodwyn on one machine
and answers from another (or a phone). Today
nodwyn uibinds 127.0.0.1 only, and every browser tab answers as the bridge process: a phone cannot be told apart from the desk. - A standalone app for people who will never run a daemon. nodwyn is to
be shared with family. They launch an app; they do not open a terminal,
read a pairing code, unlock a keychain at a TTY prompt or edit
config.toml. Today all four are required.
Decision
1. One process shape: daemon + bridge, owned or attached
nodwyn app is the whole product in one process:
- if a daemon already answers on the default socket, attach to it (the maintainer's setup; closing the app leaves sessions running);
- otherwise own one:
rpc.Servein-process on a private socket, so the wire contract, peer identity and every guard path are exactly those ofnodwyn daemon. Closing the app closes its sessions; their logs remain and resume later, as after any daemon shutdown.
No second code path: the app is a daemon, a bridge and a window glued
together in cmd/nodwyn/app.go.
2. The window
With the webview build tag (cgo; WebKit on macOS, WebKitGTK on Linux,
WebView2 on Windows) nodwyn app opens a native window on the bridge's
loopback URL. Without it — the default, cgo-free build — it opens the
default browser. make app builds Nodwyn.app for macOS with the tag. A
native shell is a packaging decision, not an architecture (ADR 21 U3).
3. Pairing without a terminal: a one-time launch ticket
The launcher that started the bridge mints a launch ticket — random,
single use, valid for 60 seconds — and puts it in the URL fragment
(#ticket=…), which browsers never send to a server. The page posts it to
/pair and removes it from the address bar; the bridge exchanges it for
the usual HttpOnly SameSite=Strict cookie and forgets it. The pairing code
stays for anything that did not launch the page (another browser, a
phone). Tickets and codes never enter a session log.
4. Remote devices are named, and answers name them
nodwyn ui --listen ADDR binds a non-loopback address (a tailnet address is
the intended use). It prints a warning naming what a connection can do.
Pairing a remote browser requires the code and a device name; the
bridge issues a device token (stored hashed in devices.toml, mode 0600,
revocable with nodwyn devices), so a phone stays paired across restarts.
The bridge forwards the device name in hello as via. The daemon records
the actor as the kernel identity followed by the claim:
unix:uid=501,gid=20,pid=4242 via web:phone. The claim cannot replace the
kernel identity — any same-user process could make it — but it keeps
guard.decision honest about which screen said yes.
Remote devices get answers, not policy. Over a non-loopback listener the bridge refuses settings, keychain, policy-file, binding-file and workspace-registry writes. Messages, approvals (one-time and scoped, as the core allows), aborts and reads are available. The phone view is the task view at phone width, not a separate client.
TLS is optional (--tls-cert, --tls-key); on a tailnet WireGuard already
encrypts. Without TLS on a non-loopback address the warning says so.
5. What a family member must never need
- Keychain: a daemon may start with the keychain locked. New methods
keychain.status,keychain.createandkeychain.unlock(local bridges only) open it later; sessions opened afterwards receive it. The passphrase travels like asession.secretreply: typed into a password field, never logged, never echoed. - Model configuration:
settings.read/settings.models.writeread and replace the[control]model (and[models.*]) inconfig.tomlwith the loader's own validation, atomically, local bridges only. New sessions use the new configuration; open sessions keep theirs. - Folders: workspaces (ADR 22 W1) are registered by name. In the app a native folder chooser supplies the path; a browser types it.
6. The console converges on the canvas
The browser client is rebuilt around tasks: Home, Task, Connections, Workspaces, Settings, with the design's tokens, embedded fonts (OFL) and a dark palette. Everything the U3 inspectors did remains reachable from a task's details. Views never invent state: budgets are counted from the log, plans are the recorded plan, "changes" are recorded writes, and a view that has no source says so rather than guessing.
Consequences
- The protocol grows additive methods (
attention.list,keychain.*,settings.*,workspace.*,session.notes.*,models.status) and an optionalviaonhello. The schema drift test covers them. - A second trust boundary exists: a paired remote device. Its tokens are hashed at rest and revocable; it cannot widen policy files.
- The app build needs cgo and a platform webview; the default binary does not, and CI keeps building without the tag.
- An unsigned
Nodwyn.appneeds a right-click → Open on first launch; signing is a distribution step outside this ADR.
Review amendments — 2026-09-30
- A device is remote wherever it connects from. Any request carrying a device token gets a remote device's methods, even from loopback with the console's own Host (a proxy that preserves Host could otherwise pass as local). Only this machine's pairing grants local powers.
- Task roots are limited.
session.open,run.startandworkspace.saverefuse/, the home directory itself, and any folder that is inside or contains nodwyn's config or data directory or a credential directory the guard denies. The path denylist now resolves relative paths against the root and follows links; protected paths apply inside mounted folders; the innermost mount decides access and nested mounts are refused. - "Answers, not policy" is a limit on the console, not on authority. A
device that may approve
shell-execcan run commands as the user, and security confirmations can be answered from it. Pair only devices you would hand a terminal; prefer documents workspaces for devices you share. - Without TLS the device cookie is not
Secure, and cookies are not scoped to a port: another plain-HTTP service on the same host name would receive it. Use a tailnet or--tls-cert, and no other HTTP service on that name. - A secret prompt declined elsewhere left the prompt on other screens; every
prompt now emits an ephemeral
prompt.closedwhen it stops pending. nodwyn-stableis the window build. A desktop launcher runsnodwyn-stable appwithout a terminal, where the browser fallback reads as an app that did not start.make promotebuilds with the tag (make build-webview) on Linux and macOS and checks the installed binary's build tags; only a system without WebKitGTK gets the browser build, with a note.make buildand CI stay cgo-free.
Settings — 2026-10-02
Settings changes what a person sets in config.toml without a terminal,
not only the model: settings.write {set} changes named settings together,
from a fixed list the daemon keeps (how Home starts a task, the turn
budgets and spend limit, the loop's timeouts, the window, the main model's
tuning, the judge's content level, threshold, timeout and model, and the
container runtime). The list leaves out what decides where content goes or
what nodwyn may do: the model's and the judge's addresses (the model's has
its own form, with its key and binding), credentials, the egress proxy, and
[policy]. config.Edit makes the changes line by line, keeping the rest
of the file, and only the result is validated, as the loader would, under
the policy-file lock; a refused change leaves the file as it was.
settings.read reports each listed setting's value, default and whether the
file sets it, and whether a judge is available. Like the model, changes
apply to sessions opened afterwards; a session reports the budget it runs
with, not the daemon's current one. A paired remote device reads Settings
but cannot change them.
Approvals — 2026-10-02
ADR 35 replaces "how Home starts a task" (tasks.*) on the settings list
with [approval]: whether writes in the folder ask, and who answers a
command (the judge, then the person; the person; or nobody). These decide
what a task does without asking, so they are chosen here, on the computer
nodwyn runs on, as "always allow" for uploads is: a device reads them and
cannot change them, and its tasks ask what they say. A device's console
starts only chats; run.start and run.resume with auto_approve,
judge or unattended stay refused to it.
Amendment 2026-10-03: Batch provisional transcript updates
A live CPU profile of the Linux app found roughly 37% of sampled host cycles in the GTK main loop, mostly repainting. The console rebuilt a task's heading and all its detail panels for every streamed fragment, although those fragments changed only the transcript. Each fragment also read the transcript's layout before and after changing it.
Join adjacent fragments of the same kind and draw them on the next animation frame, with one scroll check for the batch and no heading or detail-panel refresh. Keep fragments for a detached task until it is shown or another event arrives. Flush before non-stream events, including a recorded reply, a question and session closure, so a background tab whose animation frames are paused still processes them in order. Reloading or disposing a view discards its pending frame and provisional fragments.
This changes display scheduling only. It neither drops durable events nor changes the daemon, the event protocol or guard checks. The transcript browser check measures DOM mutations for a fragment burst and exercises scroll following, questions, final responses, switching, reload and disposal; energy savings still require a separate power measurement.
Amendment 2026-10-08: a device chooses no judge (ADR 47)
A task's judge is chosen as its model is, and for the same reason it stays
with this computer: the judge is shown the task's plan and commands. The
bridge lists session.judge.set only for this computer, and the daemon
refuses judge in session.open and session.judge.set from a paired
device.
Amendment 2026-10-16: this computer's cookie is the run's session, not the code
A browser on this computer was paired with a cookie whose value was the
pairing code. Cookies are not scoped to a port, so every server on
127.0.0.1 that the same browser visits (a dev server a task started, a
repository's own) received the code, and the code pairs: from there a
client could take this computer's powers, or, with --listen, pair a device
for 180 days. The cookie now holds a random token made for each run of the
console and accepted only until it stops; the code only pairs.
This narrows the exposure but does not close it: the token still reaches
other loopback servers, which could use this console until it stops. Closing
it needs a host name of the console's own, such as a random *.localhost
name, which not every webview is known to resolve; that is not built. The
desktop window does not browse other sites and is not exposed.
Amendment 2026-10-16: the console keeps its port
A browser keeps what the console remembers in it (the theme, thinking shown,
folders folded, a model chosen and not yet used) for the console's origin,
and the origin includes the port, which was new at every launch of nodwyn
app and nodwyn ui: all of it was lost each time. The loopback console now
listens on the port it had the last time when it is free, kept in the data
directory as console-port, and on a new one, kept for next time,
otherwise. A cookie from an earlier run holds that run's session token,
which the new run does not take, so reusing the port pairs nothing.
Amendment 2026-10-16: slow work is done outside the lifecycle lock
Every method runs under one lock, and some held it for what only waits: a
revocation sent to a provider (up to 15 s), the key derivation of
keychain.create, keychain.unlock and keychain.passphrase, the wait for
an OpenAI consent page (its discovery document is fetched first), a session
winding down in session.close and run.resume, and the making of a session
in run.start, run.resume and a scheduled run, though session.open and
session.resume already let go for it. While one of them waited, every other
client's session.answer and session.send waited too.
- One helper,
server.slow(id, work), lets go of the lock for the slow part, as the model probes do, and every such method uses it. A task named byidis marked as being resumed meanwhile, so a second resume or a scheduled run of it waits for the first. - Closing a session leaves it out of the list first, then winds it down outside the lock.
- Serve closes the sessions beside the wait for the methods still running, not after it, and the waits a method makes on the network (a model test, the models a server offers, an image pull) end with Serve's context. A method waiting inside a session, such as an image's confirm, ends when the session closes. A session a method makes after shutdown began is closed at once. Before, the daemon waited for the longest of them, 10 to 30 minutes.
Not changed: the settings.* methods still hold the lock while they wait for
config.toml's own lock, which a session holds only for the moment of a save.
Amendment 2026-10-16: behind a TLS proxy, a large message, the launch ticket
- A console behind a proxy that ends TLS is reached by
--name https://NAME.tailscale serveserves https and forwards to the console over http, so the browser's origin ishttps://NAMEwhile the console expectedhttp://NAME, and every pairing and socket was refused as an invalid origin. A name writtenhttps://NAMEis answered with an https origin, a Secure device cookie and awsssocket in its content policy; the console itself still speaks http, and the other names keep the scheme of the listener. A refused origin is answered with the one expected. - A message above the daemon's frame limit is answered, not fatal. The socket's read limit was the daemon's 1 MiB, and the websocket library closes the connection at the limit, so one pasted log ended the console's connection and every call waiting on it. The limit is now 4 MiB, and a request of 1 MiB or more is answered with an error that says so. Not built: a length check in the composers.
- The launch ticket stays on the browser's command line. It is single use
and good for a minute, but another user of the computer can read it in the
process list and spend it first. Handing it over through a private file (a
page that redirects with the ticket in its fragment) was considered and not
built: a sandboxed browser (a snap or flatpak one) may not read the user's
runtime directory, and the console would then fail to open without saying
why. It is documented instead, with
--no-openand the pairing code (never on a command line) as the way on a shared computer.
Amendment 2026-10-17: the console comes back by itself
A console reached from a laptop or a phone over a tailnet, with nodwyn
always on a server, loses its connection often: the device sleeps, changes
network, or a proxy (tailscale serve) closes a connection that carried
nothing for a while. A dropped socket waited on a Reconnect button, and
the bridge sent nothing to keep an idle one open. On loopback this rarely
mattered.
- The console connects again by itself, after a wait that doubles from a second up to thirty, and no sooner than every five seconds while the daemon is not running (1013). Coming back reloads the desk and the open task from the log, as the button did, and no operation is sent again: a request waiting when the connection dropped fails, saying so. Reconnect now skips the wait.
- The page shown again, or the network back, tries at once, and a
connection that looks live then is asked to answer
hellowithin ten seconds, since after a sleep a socket can look open with nobody at the other end. It is not asked while a request waits, because the bridge relays one request at a time and the answer would wait behind it. - A browser the console does not take stops trying. A revoked device's
open socket is closed with 1008, as before. A socket refused before it
opened tells a browser nothing of why, and the same refusal comes from a
device revoked while it was away, from this computer's browser after
nodwyn uirestarted (its cookie holds the stopped run's session), or from no console at all, so the page asksGET /paired, which answers whether the console takes the cookies the request carries and nothing else; the Host and fetch-site checks of every request apply to it. Not taken, the page pairs again; unreachable, or a proxy's 5xx, it tries again. - The bridge pings an open console every 30 seconds and closes one that has not answered within a minute, longer than a relayed call holds up the reading of its pong. A proxy keeps a connection that carries pings open, and a page that slept gives back its daemon connection.
Not built: a page reconnected to a console of a newer version keeps its old
code until it is reloaded; project notes being edited and a worktree
review's unsent comments are lost on a reconnect, as on a reload; the TUI's
daemon mode still reconnects only on Ctrl+R. Evidence: the reconnect
browser check, and TestPairedSaysWhetherTheConsoleTakesThisBrowser and
TestASilentConsoleIsPingedAndClosed.
Amendment 2026-10-17: a page reconnected to an upgrade says so
On an always-on computer, make promote swaps the binary and the console
and the daemon start again from it. A page that comes back by itself (the
amendment above) keeps the code it loaded, against a bridge and a daemon
that may now answer otherwise, until someone reloads it.
- The page compares the daemon's version.
helloreturns it; the page keeps the first it saw, and when a reconnect'shelloreports another, a banner says nodwyn was restarted as that version and offers Reload. A reconnect to the same version (a restart, a sleep, another network) shows nothing, and one back to the first version hides the banner. - It never reloads by itself. A reconnect keeps each task's reply draft and the New task message, which live only in this tab's memory, and a reload loses them. The banner says so, and the reader reloads once they are sent.
- Drafts are not carried across that reload. Handing them over in
sessionStorage, which is per tab and survives a reload, was weighed and not built. ADR 21 keeps drafts in tab memory andbrowser.mdsays a reload loses them; the console's storage holds choices (the theme, the last folder, folded folders), never what a task says. A draft holds whatever was pasted into it, a secret meant for a masked reply included, and Chromium keeps session storage in the profile on disk to restore a session, where removing the item later does not scrub it. Since the page waits for the reader, keeping the rule costs only that wait. - The version is the daemon's; the page's code is the console's
(
nodwyn ui).make promotestarts both from one binary, whose version is its commit, so there one changes with the other. A console restarted from a new build in front of a daemon still on the old says nothing, and a new daemon behind an old console offers a reload that brings back the same code; neither is how nodwyn is upgraded. Two builds of one dirty checkout both reportdev-COMMIT-dirtyand are not told apart.
Not built: comparing the console's own build, which the bridge would have
to add to hello (a protocol change). Evidence: the reconnect browser
check, whose daemon comes back from a build reporting check-next: the
pages say so and keep the reply being written, Reload runs the new code
and writes nothing to the browser's storage, and the restart of nodwyn ui
before it, to the same daemon, shows no banner.
Amendment 2026-10-17: the TUI talks to the daemon too
The TUI had two hosts: by default it ran the session in its own process,
and with --socket it attached to a daemon. Every one of its operations was
written twice, and the local one skipped what only the daemon does: the
refusal of a task root that holds nodwyn's own directories, the refusal of a
model whose key is locked, the scheduler and attention. It also held the
task's log, so a scheduled run of a task open in the TUI could not start and
could not even record its skip ("session is already open or cannot be
locked"), and no other client could open the task.
nodwyn tuiuses the daemon on the default socket, and starts one in its own process when none answers, asnodwyn appdoes (§1, through the sameensureDaemon): attached, quitting leaves the tasks running; owning it, quitting closes them, their logs kept. The footer says which. The local backend is deleted, with no--no-daemon: nothing needs it. The TUI needs a terminal, so no script used it, and its tests start a daemon on a private socket with the fakes.- The TUI unlocks the daemon's keychain over the socket before its
full screen opens, with a masked prompt and
keychain.unlock, a method for this computer, which a client of the Unix socket is. An empty answer leaves it locked and--no-keychaindoes not ask. Attached to a daemonnodwyn appstarted, the TUI could before only say "Unlock the keychain first". - Ctrl+Q detaches, and
:closecloses, as the console's tab and its Close do. Local mode closed the session on quitting, and with it removed a worktree with nothing unlanded; a task left open now goes on in the daemon, reachable from every client and the scheduler.:closecloses it there (session.close) and quits, waiting, as the console's Close does, for the turn to end. - Line mode keeps its own process, for now. It is the scriptable client
and carries what the daemon has no form for:
--url, a server no configuration names,--budget-*limits for one session,--no-confirm,--yolowithout the setting the console's Don't ask needs, and a passphrase read from/dev/ttyunder a script. Moving it needs those decided assession.openoptions or dropped, and its front-end,internal/frontend/plain, reading events from the socket instead ofcore.Session; the clients records the gap.
Found on the way: the TUI's history over the daemon decoded each page of a long log over the first page's events, and so showed a conversation of more than a hundred records with its second hundred twice and its first not at all. The test of a whole history ran only in local mode. ADR 34's note that the TUI's local mode opens a worktree without the task-root refusal no longer applies.
Amendment 2026-10-17: line mode talks to the daemon too
The amendment above left line mode in its own process, for the per-run flags the daemon had no form for. While line mode had a task open it held the task's log, so a scheduled run of that task could not start and could not record its skip, and no other client could open it. Line mode is now a client of the daemon like the others, and each of those flags was decided without changing the protocol:
nodwyn chatuses the daemon on the default socket, and starts one in its own process when none answers, through the sameensureDaemonasnodwyn appand the TUI, saying on its error output which it did. A daemon it started ends with it, as the TUI's does. It unlocks a locked keychain over the socket (keychain.unlock) as the TUI does: from its terminal, or under a script from the controlling terminal with--keychain-tty;--no-keychaindoes not ask.- Quitting closes the task line mode opened, as quitting always did, so
a script's run of
nodwyn chatstill ends its task, its log released. A task another client already had open (--resumeof one open in the console) is left open there. --modelnames a configured model (session.open'sname) or a model on[control]'s server (serverandmodel); with--resumeit moves the task (session.model.set).--urlis refused: a server no configuration names would be one the daemon reaches for a client, where every other model a task talks to is one a person set up (ADR 40).--budget-*and--max-tool-callsare refused, saying which[budget]key to set. A task's turn limits are the configuration's, or none (ADR 49), whichever client opened it; limits for one task would be a newsession.openoption, a mode the console and the TUI do not have.nodwyn runkeeps a run's own deadline and spend limit.--no-confirmis line mode's: it answers each confirm and budget checkpoint no as it arrives, without reading a line, so a script's next line is never taken for an answer. The task in the daemon is an ordinary one, which a scheduled run needs (scheduledApprovalrefuses a session whose confirms core answers), and the answers are recorded as this person's. A question with set answers (uploads to Google Docs) has none that only declines, each being saved for every task, so it stops the turn, as when the input ends at it.--yolois the task's Don't ask (session.approve_all), so it needs[approval] dont_ask, as the console's switch does, and is refused in the daemon's words without it. It is no longer an unattended session: security confirms and checkpoints ask, the prompts are a chat's, and nothing is put back on a hard stop;nodwyn run --yolois the unattended one. With--no-confirmtoo, what still asks is declined, which is what--yoloanswered before. The two used to be refused together. Line mode turns the switch off when it leaves a task it did not open.
Line mode's input is read in turn with the task's events, as before, since
a masked secret and a confirm read the same terminal: a turn another client
or the schedule starts while it waits for a line is shown when the next line
is read, with the message that started it. Its events are held as they come,
any number, since the connection's own queue closes when full. A turn ends
at the idle after its turn.end, so an idle of a turn that ended as line
mode sent its message does not end the wait for its own.
Found on the way: line mode and its tests now reach the daemon on the
standard socket under XDG_RUNTIME_DIR, which the selfcheck and the CLI
tests did not isolate, though they isolated configuration and data; a run
of either while nodwyn app was open would have used the person's daemon,
its configuration and its keys. The selfcheck and cmd/nodwyn's tests set
their own, and line mode refuses a daemon whose tasks are not in its data
directory, closing the task it opened there before any model is asked.
Amendment 2026-10-17: the phone view is an app on the Home Screen (ADR 52)
The phone view (§4, §6) installs: over https the console serves a manifest, icons and a service worker that caches nothing, a phone adds it to its Home Screen, and its notices are shown through the worker, the only way a phone shows them. ADR 52 records the choice of a web app over a native shell.
Amendment 2026-10-17: a computer nobody sits at
The person runs nodwyn on a server nobody sits at and reaches it from a
laptop and a phone. Everything this computer alone may do (Settings, keys,
the keychain, a task's model, judge, helpers, limits and schedule, a key the
model asks for, deleting a task) took a terminal on the server, and so did
pairing: the code was printed on the console's standard error, which under
systemd is the journal, for as long as the console ran, and it paired any
number of devices. A run built from HEAD on 2026-10-17 (nodwyn ui --name
https://NAME, paired by a probe) showed that, and that a request to the
console's own loopback address with the code paired as this computer, with
every setup method: what an ssh -L forward delivers, and what any account
on the server can send. The person asked for no hand-made tunnel, nothing
that depends on one VPN, and no sign-in that lapses while a device is used.
- A console other machines reach takes no pairing code. Started with
--listenor--name, it prints none and takes none, from another machine or from its own loopback address. This computer pairs with its launch ticket, whichnodwyn uinow mints whenever its listener is on loopback,--nameor not; a device pairs with an invitation: twenty characters, good once and for ten minutes, kept indevices.tomlas its SHA-256.nodwyn devices inviteprints one to the terminal that ran it, never to the console's log; a paired device makes one in Settings › Keys & devices, shown with a link that carries it in the fragment. It names a new device, never one whose name a device or a passkey has already, which it would lock out; only the person, signing in with their passkey, replaces a device of the same name. A console on loopback alone (nodwyn uiwithout those flags,nodwyn app's window) keeps its code: its loopback address is this computer, and a forward to it takes an account that already reaches the daemon's socket. - The person is a passkey. Over https, or at a
localhostname, an invitation from the terminal, from this computer or from a device whose person touched their passkey also makes a passkey on the device that uses it (WebAuthn, attestationnone, user verification required); any browser then signs in with that passkey, synced or through a phone's QR code, with no invitation. A device paired without one becomes the person's by signing in with one in Settings. The bridge checks each ceremony with the standard library alone (passkey.go): the challenge it issued, once; the origin, and not from a frame; the RP ID's hash; presence and verification; and an assertion's signature with the registered key, which the page sent asgetPublicKey()gives it, so nothing is CBOR. A plain-http console has no passkeys, since browsers allow them only in a secure context: its devices answer, and setup stays where keys do not cross the network in clear. - What the person's device may do. All a paired device may; the choices
this computer alone made (a task's model, judge and helpers, its turn
limits, its schedule, a key the model asks for, unlocking the keychain,
the folder last picked); and, after a touch of the passkey, setup:
Settings, providers and models and their tests, credentials and OAuth
sign-in, the keychain's creation and passphrase, MCP servers, workspaces,
forgetting an index, policy files and bindings, devices and passkeys, and
deleting a task. A touch lasts 15 minutes after the device's last setup
action, in the bridge's memory, so a sitting asks once. A setup request
before one is refused with code -32030; the console asks for the passkey
in a dialog (a browser starts a passkey prompt only from a click) and sends
the request again, which never reached the daemon. A device paired by a
plain invitation may do what a paired device always could, and invite
another such device.
session.evaland runs nobody answers stay the terminal's. - Who did it. For the person's device the bridge's daemon connection
says
hello {via, passkey}, and the actor is… via web:laptop passkey:laptop; the daemon lets it make this computer's choices. It does not check the claim: a same-user client that claims nothing has every method already, so the claim raises nobody. The bridge enforces the touch, as it enforced a device's limits before. Setup writes are recorded in no log, so the bridge writes a line to its standard error (Options.Audit) for each sign-in, invitation, touch and setup action of a device, with the method and the names, never a value. - A device stays signed in while it is used. Its cookie is set again,
for 90 days, whenever its console loads or connects, and
Devices.Checkforgets a device not seen for 90 days. It used to be set once, for 180 days from pairing however often the device was used, and nothing on the server expired it. Sign-ins are kept indevices.toml, so a restart or an upgrade signs nobody out; a restart forgets touches. - A passkey can be removed, a lost phone's: it signs nothing in again, and the devices signed in with it only answer. The last one stays, since without it only the terminal could let the person in again.
- A renewed certificate is served without a restart:
--tls-certand--tls-keyare read again when either file changes, so certbot, acme.sh or a local CA renew a console's certificate in place. - A schedule waits for the keychain instead of skipping its time (ADR 49, amended 2026-10-17), so a server that restarts at night runs the night's schedules once someone unlocks it from a phone.
Network identity was considered and not chosen: a VPN's own (Tailscale's
whois and app capabilities) is not everyone's, and it names a machine,
not a person, so any process on an enrolled laptop, a coding agent or a
package's install script, would speak for it; the Origin check binds only
browsers. A VPN stays an extra layer in front of the console.
A provider's OAuth sign-in finishes from a pasted address (ADR 24, amended 2026-10-17).
--acme NAME gets the console its own certificate. For a server the
internet reaches and no proxy in front: nodwyn ui --acme nodwyn.example.net
--acme-accept-terms serves https on :443 with a certificate from Let's
Encrypt (--acme-directory names a staging or private CA, --acme-email an
address for its notices), by autocert: TLS-ALPN-01 answered on that same
port, so nothing else listens; only that name is answered (a hello for
another asks the CA for nothing); the account's key and the certificates are
kept in acme/ of the config directory (0700, closed to a task's file tools
as the rest of it is); a certificate is renewed by itself 30 days before it
ends, and one in the cache is served with no word to the CA. The CA's terms
are the person's to accept, so the flag is required, as certbot --agree-tos
is. Port 443 needs a service allowed it
(AmbientCapabilities=CAP_NET_BIND_SERVICE, or net.ipv4.ip_unprivileged_port_start),
and a name the CA's check can reach. Cost: autocert imports
golang.org/x/net/idna, so go.mod gains x/net and x/text (indirect, the
Go team's, already in the module graph through the window library) and
x/crypto moves from v0.55.0 to v0.57.0, which x/net v0.59.0 requires.
Not verified: an issuance by a real CA, which needs a name and port 443 on the
internet; the tests cover the flags, the one-name policy, the ALPN protocol
and serving a held certificate with the CA unreachable.
The keychain can open at boot from a systemd credential holding its passphrase, opt-in (ADR 5, amended 2026-10-17), with what that does and does not protect said there. A project's startup files are reviewed and trusted from the person's device, the passkey's alone (ADR 45, amended 2026-10-17). A schedule that waits for the keychain says so on Home and, with the daemon's notices on, on the desktop (ADR 53 §11).
Checks: internal/frontend/web (TestAPasskeyPairsThePersonsDevices with a
software authenticator, the wrong origin, RP ID, unverified person,
unregistered and forged keys, a removed passkey and an address refused;
TestThePersonsDeviceTouchesItsPasskeyForSetup over the socket and a real
daemon; TestASharedConsolePairsThisComputerOnlyByTicket;
TestRemoteDevicesPairByInvitationAndCannotWidenPolicy;
TestADeviceInUseStaysSignedIn; TestAnInvitationIsGoodOnceAndExpires),
internal/frontend/rpc (TestAPasskeyDeviceMakesThisComputersChoices),
cmd/nodwyn (TestARenewedCertificateIsServed), and the remote browser
check with Chromium's virtual authenticator.
Amendment 2026-10-18: another nodwyn is a device (ADR 54)
A console now shows the tasks of the other nodwyns its computer is linked
to. A link is a device of the other host's console, paired with an
invitation made there (/pair with host: true), so the trust model above
decides what it may do: it answers, or, when the person's device vouched for
it with a passkey at an address where passkeys work, it makes the person's
choices; it is never offered setup, since it cannot touch a passkey, and it
may revoke itself (devices.leave). Settings › Keys & devices and nodwyn
devices say a device is another nodwyn. ADR 54 records the rest.
(Amended 2026-10-23: a link that makes the person's choices also changes the
host's limits and models, without a touch, unless the host's console is
started with --no-link-settings; what would widen what a task may do
(approvals, the policy, the judge, providers) and the rest
of setup stay refused to it. ADR 54's amendment of that date says why, and
a later one lets the link choose the default model among the host's providers.)
Amendment 2026-10-19: this computer's cookie makes no passkey
Point 2 of the amendment of 2026-10-17 let an invitation from this computer
make a passkey. This computer's cookie goes to every server on 127.0.0.1
that the browser visits (amended 2026-10-16), which bounded it to using this
console until it stops, and ADR 45 (amended 2026-10-17) keeps trust.set
from it for that reason. But devices.invite {passkey: true} from that
cookie made an invitation that registers a passkey, /passkey/register
asked for nothing more, and a registration counts as a touch. A security
audit reproduced it on 2026-10-19 against HEAD, on a shared console where
passkeys work (nodwyn ui --listen 127.0.0.1:PORT --name https://NAME
behind tailscale serve): with the cookie alone, a client made such an
invitation, registered a P-256 key of its own as the device evil, and as
evil called trust.set and had the project trusted, so the repository's
init.scm would run as the person. The device stayed in devices.toml
across a restart and reached the console wherever the console is reached.
- An invitation that may register a passkey is made at the terminal,
by
nodwyn devices invite, or by a device signed in with a passkey after a touch of it, as before. This computer's console makes plain invitations only: the bridge refuses itdevices.invite {passkey: true}, naming the terminal's command, before anything is written. - The console asks for what it may have. On this computer, Settings ›
Keys & devices › Invite a device asks for a plain invitation and says,
beside it, that one which makes a passkey comes from
nodwyn devices invite. The pairing page already pairs a device without a passkey when its invitation cannot make one.
What a person sees: pairing a phone from the computer's own browser, or
from nodwyn app's window started with --listen or --name, gives a
device that only answers. To make it the person's, they run nodwyn devices
invite once and open that invitation on the phone, or sign the phone in
with a passkey made on another device. A console is reached by other
machines only through --listen, --name or --acme, flags given at a
terminal or in a unit file, so whoever set it up has the terminal; a console
on loopback alone has no devices and is unchanged.
Considered and not chosen:
- The first passkey from this computer while none exists. A fresh server's console runs with no passkey until the person gets to it, and the first registration wins, so a server on loopback could still be first.
- No invitations from this computer at all. A device that only answers can do what the cookie already does while the console runs, but it lasts after the console stops; the next amendment closes that another way.
- A check in the daemon. The passkey's tier is the bridge's alone:
refuseDeviceininternal/frontend/rpc/peer.gorefuses only a client that claims a device and no passkey. A check there would not have stopped this chain, sinceevil's passkey was registered and its claim true, and the daemon checks no claim, since a same-user client that claims nothing has every method already (point 4 above). What made the escalation is enrolment, which is the bridge's state,devices.toml, alone. A refusal oftrust.setto this computer's console in the daemon would need that console to claim itself (its connection claims nothing, as a terminal's does) and would repeat the bridge'stierPasskey. Not built.
What remained, that a server on loopback the browser visited could take this computer's cookie and with it do the rest of setup and pair a device that answers, is closed by the next amendment: this computer's sign-in is no cookie.
Checks: TestThisComputersCookieMakesNoPasskey in internal/frontend/web
walks the chain over the console's socket and a real daemon (on HEAD the
registration answered 200 and the grant went through; now the invitation is
refused, a plain one registers nothing even with a challenge, and the
terminal's invitation still makes the person's device, which trusts the
project), and the remote browser check, where this computer's own page,
opened by the launch with its ticket, asks for a plain invitation and the
device that uses it at desk.localhost is paired without a passkey.
Amendment 2026-10-19: this computer's sign-in is no cookie
The amendment of 2026-10-16 made this computer's cookie the run's session,
and said that closing what remained needed a host name of the console's own,
not built. What remained was more than using this console until it stops.
The bridge lets this computer's console call every method but session.eval
and the passkey's, and the daemon refuses its choices only to a paired
device. So a server on loopback that the browser visited (a task's dev
server, a container's published port, another account's process), having
read the cookie, could open a task and answer its shell approval or turn on
"don't ask", point a task's model at a server of its own, make a schedule or
a workflow that runs after the console stops, and pair a device that answers.
Refusing or bounding this console's invitations would have closed one lasting
route of many.
- The page keeps this computer's sign-in. Pairing with the code or the
launch ticket answers with the run's session in its body (
{"session": ...}) and sets no cookie. The page keeps it inlocalStoragefor the console's origin (nodwyn.session). The origin includes the port, so a page of another port or name cannot read it, and the browser sends it nowhere by itself. The page sends it in theAuthorization: Bearerheader of/pairedand/attach, and on its socket, where a browser sets no header, as the subprotocolnodwyn.session.TOKENbesidenodwyn. The bridge choosesnodwynon every console socket, those opened only to say why they close too, since a browser that offered subprotocols refuses an answer without one. It takes the token only from this computer, as it took the cookie. A page holding an earlier run's token forgets it when/pairedsays the console does not take it, and pairs again. Where storage is unavailable the page keeps the token until it is closed. - What it gives up. HttpOnly: script running on the console's origin can
read the token. Such script could already drive the socket with the
cookie, and the console runs only its own scripts (
script-src 'self'), building session content as text, so little is lost. Safari may clear what a page stored for a site not used for seven days; the token lasts one run of the console anyway, and the page then pairs again. - Kept. Passkey invitations (the amendment above) and
trust.set(ADR 45) stay refused to this computer's console. They cost the person a terminal command, and keep any other leak of this computer's sign-in, now or later, from making a lasting person.
Considered and not chosen:
- A host name of the console's own, a random
*.localhost, keeps the cookie but needs every browser and webview to resolve the name. Chromium does, and so did this Mac's resolver when checked on 2026-10-19; older macOS WebKit and Linux WebKitGTK without systemd-resolved or nss-myhostname were not verified, and a launch there would open a page that does not load, saying nothing. It also moves the console's origin, which loses once what the browser kept for it. Storage needs no name. - Refusing or bounding this console's invitations (none at all, or devices forgotten when the run that let them in stops), for the reason above. A device it invites answers, as one a device invites does.
Not changed: a device's sign-in is still a cookie for the console's names, which another HTTP service on the same name receives (amended 2026-09-30); the bridge renews it as the device is used and a passkey sign-in sets it. Moving it is not built. The pairing code, the launch ticket and the protocol are unchanged.
Checks: TestThisComputersSignInIsNoCookie in internal/frontend/web
(pairing by code and by ticket answers with the token and sets no cookie; the
header and the subprotocol are taken, and the bridge chooses nodwyn; a
cookie holding the token, an earlier run's token, the code, and the token from
another machine are not; on HEAD pairing answered 204 with the cookie). The
reconnect browser check pairs this computer's page with the code, has the
same browser visit a server on another loopback port, and checks that the
server was sent no cookie and its page finds no session in its storage, that
the browser holds no cookie for the console, and that the console loaded again
is still paired; on HEAD the server was sent the cookie. The other console
checks pair through the token as they did through the cookie.