cli-server-v1.0.0
Major Changes
- #184
711b5faThanks @theogravity! - Marked 1.0.0. The control plane, the node daemon, and both desktop apps
all mark their first stable release. This entry changes no behavior; it
records the milestone, and the entries below it are what the milestone is
made of.
Minor Changes
-
#171
c60dfe7Thanks @theogravity! - Presets: the row action menu gains Clone preset. It opens the create dialog prefilled from the source (same agent, its env, flags, and restart policy) with the next free "(2)" name suggested. -
#173
766d4a7Thanks @theogravity! - Notifications get quieter. A Stop hook no longer rings "Done, waiting for you" while the session is parked on background work; approval pushes fire only for the notification types that genuinely need a human; a pane pushes at most once until its owner opens it, escalation excepted; and the sidebar dot becomes a bell for exactly as long as a push sits unanswered. -
#179
9c2db01Thanks @theogravity! - Instance lockdown, and a Server that can decline to host subshells. Settings → General grows a Lockdown card: one button in each direction, and both open a dialog that lights its confirm only when the admin types the machine's name. Turning it ON stops every running subshell on every machine and refuses every new launch and restart (admins included) until it ends, which it does by the same typed ask; a pane that could not be stopped is reported to the admin rather than passed off as quiet, and everyone signed in sees the amber banner for as long as it holds. One switch up, the Server can now be switched off as a place to run subshells: running panes finish on their own, the machine's row stays visible and manageable, and every picker and refusal follows that one reading. On Nodes, "Add node" is always drawn and disabled with a tooltip naming who holds the switch (the same sentence is the empty state's line, so touch users are not left hovering for it), and the home page tells a viewer with nowhere to launch what to do about it: add a machine, or ask an admin. -
#183
f9286b9Thanks @theogravity! - The MCP DX wave: an agent in one pane can now act like a colleague at the keyboard.subshell mcpgainslist_nodes(which machines can run subshells, online and launchable, with the harnesses each holds), anodeargument oncreate_subshell(launch on a named machine, by id or display name),read_subshell_log(a sibling's captured output, so you can see what it printed and why it exited) andsend_to_subshell(type into a running sibling, Enter optional), whileget_subshelltakes a name as well as an id andrestart_subshelltakes a prompt to re-type a task into the revived pane. Refusals come back as next moves now: a launch that must pick a machine nameslist_nodes, an offline machine says so, a stopped pane says to start it withrestart_subshell. The pane's tool list also lost one:channel_members, whose rosterpost_channelandread_channelalready implied. Under the doors sit two new REST surfaces the tools ride:GET /api/nodesanswers a machine token with exactly its owner's own nodes (a read; the detail route and every write stay browser-only), andPOST /api/subshells/:id/inputtypes into a running pane as aneditact, so steering a sibling is exactly as allowed as typing into it in the browser. -
#175
e7d872cThanks @theogravity! - The subshell list has a new "Needs Attention" section, in the sidebar above the machine groups and on the home page above Running. It gathers the panes that pushed and have not been opened since: only your own, and only while a push sits unanswered. Opening the pane clears it; when nothing is unseen the section is gone entirely. -
#177
f77b8a0Thanks @theogravity! - The node ↔ control-plane link is now encrypted end to end. Every/ws/nodeconnection negotiates a fresh key with libsodium'scrypto_kx(each side authenticates against the long-term identity it pinned at pairing), and after that the socket carries only ratchetedcrypto_secretstreamciphertext, never resynced. A server on plainhttp://no longer puts launch commands, pane bytes, or bearer tokens on the network in the clear; whoever can see the node's network can no longer read it.This is a hard protocol cutover (13 → 14, minimum node version 0.17.0) with no plaintext fallback. Deploy order: server first, then nodes. Nodes enrolled before the link existed pair themselves on their first connect after updating (the agent mints its keypair and registers it over the socket its bearer key already authenticates), and rotating a node's key re-provisions the link identity through the same self-heal. A node that skips or fails the handshake is refused with close code 4410 and retries with its backoff, never silently degraded to plaintext.
-
#180
b28726aThanks @theogravity! - OIDC sign-in with approval (spec 2026-09-24). Sign in through external identity providers under an admin-managed list: a new Server Settings → Auth page adds doors (Google preset or generic OIDC) with per-door sign-in, registration, approval and domain rules, and the login page paints a button per open door (labeled by the door's name; many doors of one kind are legal). An OIDC identity links to the existing account when the provider asserts the email as verified, and a door can require approval: first arrivals queue on a pending screen that admins Approve or Reject from the Users page's new Pending tab, stale pendings expire on an admin-set window, and rejections look identical from the visitor's side. E-mail becomes a provider row too, so its toggles replace the global registration switch (Settings → General loses it). Guards that hold the ground: adding a provider is an admin-only trust decision priced in the security docs, the last open sign-in door cannot be closed (409), an OIDC arrival can never become first admin, andauth.sign_ingains theoidc:<door>method with exactly one row per real sign-in and none per refusal. -
#176
db5951aThanks @theogravity! - "Waiting for you" now clears on agent nodes. The only alive-path clearer was the plane's idle watcher, which can only observe a log on the plane's own disk, so a pane running on a node stayed amber from its last Stop or approval until the process died, however hard it worked. Claude Code's hooks now report a third attention kind,resumed(prompt submitted, or a tool starting after an approval), and the pane's own report clears the stamp from wherever it runs; it never reads the hook payload and never rings. Rollout: update the nodes FIRST, then the Server. The Server ships the new hook, and an oldersubshellbinary rejectsresumedas an unknown argument with exit 2, which Claude Code reads as a blocking error on every prompt and tool, so a node left behind stalls its panes until it updates. An updated node against an older Server is harmless: the report is a silent no-op there. -
#170
ac9ec22Thanks @theogravity! - Three security fixes from the 2026-09 documentation sweep, operator-approved.Admins can now see and revoke every setup key on the instance (audit item 4). An outstanding key that its creator never used used to be an enrollment door no admin could close before its 24 h expiry: the list was owner-scoped and the delete owner-filtered.
GET /api/nodes/setup-keys?all=1is now the cookie-admin view (every key, each labeled with its creator), andDELETEcloses any row, auditedsetup_key.revokewith{ foreign: true, ownerUserId }metadata (ids only, never the key text). A non-admin asking forall=1gets a 403 rather than a silently-narrowed list. The Setup keys card on the Nodes page gained the matching switch, and each foreign row shows who created it.The backend gates subshell ids before interpolating them into node-side paths (audit item 7). The agent has always checked
isNodeSubshellId; the plane now checks the same guard at every composition site (assertNodePathIdinservices/nodes/node-path-id.ts, called by theRemoteLauncherpath members and the launch-sideplanRemoteSubshellMcp), so "a hostile../../../../xnever reaches path interpolation" is true on both sides of the link instead of true by the accident that every id today is a server-minted uuid.The WebSocket attach-token store is capped (audit item 8). Mints became machine-reachable in #159 and the in-memory store had no limit; past
MAX_PENDING_WS_TOKENS(10 000) the mint sweeps expired tokens first and then answers a named 503 rather than growing memory on a script-rate caller's schedule. -
#176
db5951aThanks @theogravity! - Subshells: state reads as one dot everywhere. The cards' corner chips and the list's STATUS column are gone; both draw the status dot (with the unseen-notification bell) beside the title. Green is now the ALIVE family: bright for printing, dim for quiet-but-running, with the printing dot blinking like Claude Code's in-progress work (still under reduced-motion), and gray means not-running only. An unreachable node is red, not amber. The tile view segments by machine on the sidebar's own grouping and labels, the card's machine pill retired with the chip, and the page grew a machine filter that narrows both views (default All). The Server assistant's setup wizard joins the same motion rule: its in-progress checklist spinner now sits behind the reduced-motion gate, like every other animation. -
#174
4efcc92Thanks @theogravity! - Subshells: the row action menu gains Switch preset. The dialog picks another preset of the pane's own harness (or none) and restarts with it; a running pane is revived from the new one. The restart route takes an optionalpresetId, validated before anything is killed, audited assubshell.preset_switch. The@internal/backend-errorsINVALID_PRESETaddition rides this changeset.Menus (dropdown and the sidebar right-click, one shared primitive): rows tighten from a 44px floor to 36px (a seven-item menu no longer spends half a phone screen), and the menu box stops drawing the browser's focus ring when it opens; the highlighted item stays the keyboard cursor. Every inline failure line across the SPA and the node cards moves to the
detailrole the design system prescribes for a control's own explanation.Follow-ups from the feature's final review ride here: a swap arriving while a restart for that subshell is in flight is refused (409
RESTART_IN_FLIGHT) instead of joining another caller's revival it could not direct; a restart that fails mid-flight announces its rolled-back dead state on the live feed; and both audit rows of a restart name the ACTING viewer, so a grantee's restart and preset switch are no longer attributed to the owner. -
#176
db5951aThanks @theogravity! - The sidebar's subshell-row tooltip is now a real in-page popup, so it scales with browser zoom. It used to ride the element's nativetitle, which the browser paints at the SYSTEM font size; ctrl +/- grew the rail and left the reveal behind. The tooltip is the shadcn Base UI component, attached through itsrenderprop so the row stays one element (nav, drag source, context-menu subject and tooltip trigger all on the sameLink); a keyboard-focused row shows the same reveal, whichtitlegave only on hover. The styled tooltip's text went one step up the scale, 13 to 14. -
#178
ce81149Thanks @theogravity! - Users page: the per-row admin controls (role flip, password reset, disable/re-enable) fold into one ⋯ action menu like every other roster in the app, the "Manage" column header is gone, and the confirm dialogs say what they do in two sentences.
Patch Changes
-
#180
b28726aThanks @theogravity! - Dialog and tab-strip polish, and the provider admin's sharper edges. Dialog actions stay in one right-aligned row and dialog headers stay left, always: shadcn's viewport breakpoints (stack under 640px, center until 640px) tested the WINDOW, so page zoom or a narrow shell re-stacked buttons and centered text inside a comfortably wide dialog. Page tab strips (Users, Logs, Nodes) are content-sized now instead of stretching two labels across the page; the equal-share switch stays for in-row controls. The copy icon shrank to sit inside value rows. On Settings → Auth: the provider form shows its slug id live under the name, the remove confirmation lists its effects and names the exact row (slug id and issuer) and notes that re-adding the same slug id restores the accounts, close-capable toggles are DISABLED with a tooltip when a provider is the last open one (the 409 remains the enforcement, it just stops being the introduction). Thecli-nodebump covers only the smaller copy icon, which the node dashboard shares. -
#170
ac9ec22Thanks @theogravity! - Disabling an account now takes its enrolled nodes offline (operator ruling 2026-09-24, "Honor" chosen over infrastructure-preservation). Two halves: the disable route closes every live or held socket of a node the target owns (close 4403 (OWNER_DISABLED_CLOSE_CODE) with the reasonthe node's owner account is disabled, which the agent relays into its own log), andauthenticateNodeUpgradeends its chain with the owner's account state, refusing every further dial pre-socket with HTTP 403 while the flag stands. The node key itself is untouched (a disable freezes the account, it does not revoke a credential; that stays rotate/delete's 4401 business), so re-enabling is the whole recovery: the agent's backoff loop never stopped dialing (full-jitter exponential, capped at 60 s) and lands on the same key within about a minute. The refusal is deliberately NOT the version-floor hold:updatecannot un-disable an owner, so a held socket would carry a command that cannot fix the reason for the hold.localis outside all of it, refused by kind before the owner check. The SPA's disable confirmation now states the fallout in its two sentences (sessions and feeds end, credentials die, nodes disconnect until re-enable), and the audit row countsnodesDisconnected. Also in the same file: the live dispatch path grows the superseded-identity probe the held path already had (a just-superseded socket can no longer queue staleready/heartbeatrow writes;resultframes stay exempt, settling only their own record). The disable itself now reaches every live surface the account holds, not just the dashboard feed: outstanding ws-tokens are destroyed (dropUserTokensFor: redemption consults the token store ALONE, never the account, so a token minted inside its 30-second life moments before a disable re-created exactly the socket the sweep had just ended; after the drop its redemption fails in the same shape as any unknown token, and SCOPED Bearer attaches bound to the target's own panes die too, since a scoped entry records the pane's owner), and open terminal attaches are closed (dropTerminalSocketsForinws/viewers.ts, a walk of the viewer registry by the identity the attach stashed; keyed by WHO attached, so it reaches panes merely shared into the account and leaves bystanders on the target's own panes attached). The audit row gainstokensRevokedandterminalSocketsClosedbeside the disable-onlynodesDisconnected, same edge-only rule as the feed drop. The row names its subject by email, the same as theuser.create/role/password_resetrows beside it: every admin already sees every email (GET /api/usersis the instance-wide read), so the trail's audience gains nothing new, and the never-values rule belongs to the credential and auth-event family, not the management one. And the node door's check-then-attach race closes: a disable can land between the upgrade-time owner check andopen'sattachConnection, where the route's sweep finds no socket yet and the fresh one would live on;handleNodeOpennow re-asksaccountDisabledimmediately after attaching, against the owner id the upgrade stashed with the identity, and evicts a socket it missed through the samedisconnectNode+ 4403 the disable route uses.
Two review follow-ups ride with it. The eviction now OWNS its status projection: the registry entry leaves the map before the socket's close event lands, so the close handler's identity guard skips, and until now every forced eviction (the disable sweep, key rotation, node deletion, the post-attach eviction) closed the socket while thenodesrow went on projecting online and the live feed went on claiming its panes healthy until the ~105 s stale sweep, contradicting the dialog's own copy.disconnectNodeitself now writes the row offline and re-announces the node's running panes before it answers (theholdRefusedNodeshape; awaited, so the page is honest by the time the route returns), and because it evicts first, the late close correctly finds the entry gone, single projection, not doubled. And the mint-vs-disable race on attach tokens closes at redemption, not at the store: a ws-token mint that passesauthGuard, gets committed past, and inserts afterdropUserTokensFor's walk used to survive for its 30 s because the token branch trusted the store alone; the redeem now re-asksaccountDisabledafter consuming (thehandleNodeOpenpost-attach doctrine; the sweep cannot reach what lands after it, the re-ask can) and refuses with the uniform 4001 an unknown token gets, still burning the token.
Five more review-round fixes ride the same wave. The post-attach eviction is now TARGETED:disconnectNodetakes the evicting socket's own connection record and identity-gates every branch, so a re-ask whose await outlived its own live slot evicts nothing rather than condemning the newer socket that already asked the same question about itself, and the log line tells the two outcomes apart instead of claiming an eviction that did not happen. The dashboard feed (/ws/live) grows the redemption re-ask the terminal attach already had; it was the one credential path where a token minted moments before a disable could survive the sweep and stream a whole visible list for the tab's life, and it fails CLOSED on a throwing re-ask, because unlike a node socket a feed is never re-checked after it opens. The disable's own node-disconnect sweep can no longer 500: the flag commit is the durable act, and a transient in the sweep now logs loudly with the count reached and answers 200 with the honest partial number. The eviction walks both maps (a held and a live record for one node is unreachable throughattachConnection, which supersedes held first, but the walk costs nothing and the targeted form needs to see either), andapplyConfig's two unreadable-file refusals share one helper so the second read cannot drift from the first. -
#170
ac9ec22Thanks @theogravity! - Four fixes from the 2026-09-24 code review, each closing a window that stayed open after the round-3 sweeps. Release egress is now pinned hop for hop: Bun'sfetchfollows 302s by default, so pinning the URL the release source named left every one of those fetches (the binary, the manifest, the signature, the server's owndownloadVerifiedwrite) one redirect away from anywhere on the planet; an allowlisted mirror could answer302 → 169.254.169.254and the pin would have waved it through, because byte-trust was never egress-trust. The module now fetches withredirect: "manual"and walks the chain itself, re-running the allowlist on everyLocationbefore connecting, bounded at five hops (MAX_REDIRECT_HOPS) with an unresolvable or looping chain refused rather than followed; the releases-LIST read stays the one exemption, its URL being the string the operator configured rather than a field a source publishes. The node'sreadywrite can no longer overtake its own eviction: a forceddisconnectNode(rotate, delete, disable) landing while the frame's UPDATE is in flight detached and projectedoffline, and the already-issued write then landedonlineon top of it; the row read online for a machine with no socket until the ~45 s stale sweep. The handler now re-checks its connection after the write with the dispatch probe's own identity idiom, and by direction: gone re-projects offline through the eviction's seam, replaced stops the frame without touching the newer socket's truth. A restart completes even when its socket teardown throws:performRestart's timer callback is async with no observer, so the awaited node-closing half growing an offline-projection turned a rejecting seam into an unhandled rejection that skipped the network-child reap and the exit; the process sat alive forever after the route's 202 flushed. The closes are wrapped so the stop-and-exit chain always runs, and the rule "a failure to stop one is not a reason to stay up" now holds of a synchronously-throwing stopper too. The break-glass dedupe mark binds to its act: the mark used to be a userId-keyed "the next password sign-in ≤ 60 s is the rewrite" flag, so a genuine sign-in that interleaved with an armed mark (the wrapper's forward still mid-flight, or a crash-before-clear) had its real act suppressed by bookkeeping for a different one. The wrapper now mints a one-time nonce and sets it on the forwarded request'sx-subshell-breakglassheader; the after-hook consumes the mark only when the user AND that header match, a miss never consumes, and the clear-on-failed-forward stays. -
#170
ac9ec22Thanks @theogravity! - Every name is normalized the same way, and the attach journal line can no longer be forged. Manual subshell renames, named creates and both workspace name doors now run the human-typed name through the sharednormalizeLabelinstead of a bare trim; a hand-typed name no longer carries escape bytes into the restart journal line or another user's sidebar, and a name that is nothing but invisible characters is refused (subshells, workspaces) or becomes an unnamed create (a create never 400ed on a blank name and still does not).normalizeLabelitself hardened: NFC first (so two spellings of one name compose to one string and the cap counts characters of the canonical form), Unicode format characters dropped (bidi overrides, zero-width joiners and spaces, soft hyphen, BOM, emoji tag characters: the invisible half of what CR/LF do to a log line), the emoji presentation selectors dropped, and unpaired surrogates dropped, which nothing downstream could render or compare anyway. Node names and device labels inherit all of it (the node patch is that inheritance: a name typed on the machine is now stored the same rule the plane applies). Separately, the per-attachws attachjournal line clamps its User-Agent to the characters real UAs actually contain BEFORE slicing to 90 (a client can no longer close the quoted field, break the line, or mint a second record in the forensics the operator greps), and a disabled account's session cookie is refused on the no-token WS attach path, the sameaccountDisabledcheck every REST surface already applies. -
#169
78fd431Thanks @theogravity! - Fix the fresh-terminal replay so typing lands on the visible prompt.The attach replay ended with the client's cursor at the bottom of the grid
while the pane's cursor sat at the prompt near the top, so every live byte
(echo included) painted below the visible prompt (the "prompt at the top,
typing off-screen" report). The replay now ends with an absolute move to the
pane's real cursor: a newpane_cursornode command (protocol 12 → 13) feeds
it, and an older agent that cannot answer simply gets the previous behavior.
A booting pane's first frame now waits for the shell's first paint instead of
shipping the blank grid, the log file is waited for (the whole-grid poll
fallback stays for panes that never get one), and bytes already queued at a
booting viewer are dropped rather than replayed on top of the capture, which
double-painted prompt sequences into ghost prompts. -
#170
ac9ec22Thanks @theogravity! - Two plugin-subsystem ordering fixes, both about refusing BEFORE the refusal's moment has passed. A network plugin'sinstall.commandis the one manifest field the control plane RUNS, and the install route hands it tosh -c; but the manifest parser only refused a line BEGINNING withsudo, so"apt-get update && sudo apt-get install -y x"loaded, and the admin's button ran a privileged installer (root-without-prompt on a NOPASSWD host, else the ten-minute hang the sudo rule exists to prevent). The parser now refusessudo,doasorpkexecrunning as a command at ANY shell boundary (line start, or after&&,||,;,|,&or a newline), with leading env-assignments stripped and the first word compared by basename, so/usr/bin/sudois refused whilebrew install pkexec-demostill installs. Second, same lesson: a registry package claiming a plugin id another package owns is refused; but the refusal used to fire after the pre-swap load-check had already imported the squatter's module and called its factory in the control plane's own process, so the disk stayed byte-identical while the payload ran. The claim check now runs BEFORE the load, a refused squatter is never imported at all, and the same check stays immediately before the swap as the last gate against a concurrent install changing the target in between. -
#172
bc9d16aThanks @theogravity! - Fix the PWA frame ending short of the viewport in a home-screen install: the shell now pins its height towindow.innerHeight(iOS computesdvhthere as if Safari's collapsed toolbar existed, wrong until a rotation), and the shell's bottom safe-area padding, meant for the scrolling pages, no longer stacks under the terminal's own bottom-bar padding on the pages that own their bottom edge, which left a dead band beneath it. -
#170
ac9ec22Thanks @theogravity! - A service definition nobody could read is now refused with the honest sentence. The destructive verbs (nodestop/uninstall/restart, nodeupdate, the server's own restart and update) already fail closed when the supervision definition reportsunknownor nothing at all: one refusal code, because an unreadable definition is not evidence of safety. But the WORDING promised, with certainty, that every pane would die. Only a definition that actually answeredkillsearns that sentence now;unknownand no-report-at-all say the definition could not be read, on the plane's node surfaces, the node's own loopback dashboard, and the server's own restart and update routes alike. -
#167
db4fe8eThanks @theogravity! - Stop every reopen of a terminal from adding another stray prompt line at the top. Reattaching at the size the pane already has changes no geometry, so tmux re-wraps nothing and there is no stale frame to correct; yet the attach still probed with a SIGWINCH and a ±1-column nudge whenever nothing answered. Prompts that redraw on resize (powerlevel10k, ble.sh) answer each of those geometry events by inserting a line and repainting INTO their own history: one orphan prompt at the top per reopen, growing on every one. A same-size reopen now does exactly nothing beyond fitting the queue's view: no resize, no wait, no winch, no step. The provocation stays where it earns its keep: a resize that actually changes the grid on a pane that then refuses to repaint. (The fresh-terminal case was already fixed; this was the reopen half, and the half that kept stacking copies onto a settled terminal's scrollback.) -
#170
ac9ec22Thanks @theogravity! -subshell-server updatekeeps its rollback copy atomically (temp + fsync + one rename where hardlinks are refused, so an interrupted copy can never leave a truncated<binary>.previous), and both paths that install that copy back (update --rollbackand the boot-time revert) now probe it first (<previous> versionmust exit 0). The CLI refuses outright when the copy cannot run; the automatic revert records the failure infailed.jsonand leaves the bootable new binary in place rather than putting an unbootable file where the service manager would try to exec it. -
#170
ac9ec22Thanks @theogravity! - Five round-3 audit fixes to the server's update chain. The route'sUPDATE_IN_PROGRESSrefusal and the job's start used to sit two awaits apart (the release lookup, the audit), so two concurrent POSTs could both begin: they shared one<name>.download-<pid>temp file and the loser's cleanup deleted the winner's in-flight bytes. The job slot is now taken by a single synchronous claim that re-reads the on-disk marker as it sets; a second press is refused the moment the first one lands, and only one job ever runs. Every swapper (the dashboard job and the CLI verb alike) replaced its two-rename swap with a link-then-single-rename:<binary>.previousbecomes a second name for the RUNNING binary (a hardlink, or a flushed copy where links are refused), then ONErenamelands the new bytes on the live path; the old shape had a window where the file the service unit names held nothing, and the boot-revert lives inside that file, so a power loss there meant a hand at a keyboard. A crash anywhere now leaves a bootable binary at the unit's path. Restoring a database backup folds the replaced database's WAL into its main file before dropping the-wal/-shmsidecars and renaming the snapshot in; a stale sidecar beside a restored main file replays its frames onto the snapshot, and the previous unlink-first order could leave the pre-replace database missing its uncheckpointed tail while the next open succeeded silently; a staging file left by a crashed restore is swept on the next entry. And every fetch of a release-named URL (binary, manifest, signature, the server's own update download included) is pinned to the GitHub release hosts or the configuredSUBSHELL_RELEASE_URLorigin before anything is connected to, so a mirror the operator points the plane at cannot use it to probe link-local internals; the refusal names the rejected host and reaches the log. -
#170
ac9ec22Thanks @theogravity! - Sign-in and sign-out are audited now: the known gapdocs/security.md§10 had been declaring closes (audit item R1).auth.sign_inis written from better-auth's response hook when a sign-in endpoint SUCCEEDS (/sign-in/emailand the passkey plugin's/passkey/verify-authentication), naming the user from the endpoint's result rather than its request body, with metadata carrying only{ method: "password" | "passkey" }. A failed sign-in writes nothing: credential-stuffing would otherwise spam the trail the audit log exists to reconstruct incidents from, and failures already belong to the login-backoff domain (authAttemptsand the rate-limit route's logs).auth.sign_outrides the session-delete database hook, so it fires exactly once per session actually ended (sign-out, session revocation, a self-service password change evicting the account's sessions), while an anonymous sign-out that deletes nothing writes nothing, and admin-side revocations stay under their ownuser.password_reset/user.disabled_changerows. An emergency break-glass login still leaves exactly one row:emergency_login.rewrite_credentialnames the act and the matching sign-in event suppresses itself; and the suppression covers EXACTLY that act: when the wrapper's forwarded sign-in comes back unsuccessful (a disabled account, for instance, refuses the session downstream) the wrapper disarms the dedupe mark, so the account's next genuine sign-in inside the mark's 60-second TTL writes its own row instead of being mistaken for the emergency act that never completed. No token, cookie, email, address or User-Agent is ever in these rows: ids and the method, and the audit write itself can never break authentication. The emergency marks themselves are now a per-user LIST of per-act nonces, not one slot per user: two break-glass acts for the same admin (two clients, or a second attempt while the first wrapper's forward is still running) bind independently; each act's forward consumes exactly its own nonce, and the failed-forward clear removes only the nonce that wrapper minted, so act #1's failure can never disarm act #2's in-flight mark. -
#170
ac9ec22Thanks @theogravity! - Two config-and-cleanup races in the control plane. First, the hourly pane-log retention sweep: it took its liveness snapshot once before walking the files, and a restart landing in that window reuses the same log path append-only with the old mtime intact, so the sweep unlinked a live transcript out from under the capture child; the pane kept writing the dead inode and the live view read an empty path until a relaunch. Every eligible file now gets a fresh per-row liveness check immediately before its own unlink; a check that cannot answer keeps the file, the sweep's unknown-is-not-dead rule. (The same race, fixed the same way per file on the node, rides the@internal/nodepatch beside this one.) Second,config.envwrites: the dashboard's config PATCH andsubshell-server configureshare one writer, but two of them interleaving meant the later merge ran against a stale read and silently reverted the earlier writer's changes (foreign keys like the auth secret included), while both audit rows claimed success. The shared writer now re-reads the file immediately before its atomic rename and re-merges against the fresh content: each writer keeps the keys it named (only a key both writers touched still takes the last rename), and every key it did not name (including any a hand-edit or the other surface added) is carried from what is actually on disk. -
#182
6955725Thanks @theogravity! - The no-em-dash voice rule applied to shipped copy: every string a person reads on a screen or in a terminal now carries its breath with a comma, colon, parentheses, or a full stop. The tray update item reads "Update available: Subshell Server 0.8.0" (both apps), the node window title "Subshell Client: Node", network plugin hints, both CLIs' refusals and prompts, and the browser-rendered error messages lose their dashes, and so do the /docs endpoint descriptions, the shared MCP tool descriptions, and the desktop apps' permission prose. No wire name, error code, id, or log line changed. -
Updated dependencies []:
- @internal/pane-runtime@1.0.0