Skip to content

cli-server-v1.0.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 09:09
25e98b9

Major Changes

  • #184 711b5fa Thanks @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 c60dfe7 Thanks @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 766d4a7 Thanks @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 9c2db01 Thanks @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 f9286b9 Thanks @theogravity! - The MCP DX wave: an agent in one pane can now act like a colleague at the keyboard. subshell mcp gains list_nodes (which machines can run subshells, online and launchable, with the harnesses each holds), a node argument on create_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) and send_to_subshell (type into a running sibling, Enter optional), while get_subshell takes a name as well as an id and restart_subshell takes 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 names list_nodes, an offline machine says so, a stopped pane says to start it with restart_subshell. The pane's tool list also lost one: channel_members, whose roster post_channel and read_channel already implied. Under the doors sit two new REST surfaces the tools ride: GET /api/nodes answers a machine token with exactly its owner's own nodes (a read; the detail route and every write stay browser-only), and POST /api/subshells/:id/input types into a running pane as an edit act, so steering a sibling is exactly as allowed as typing into it in the browser.

  • #175 e7d872c Thanks @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 f77b8a0 Thanks @theogravity! - The node ↔ control-plane link is now encrypted end to end. Every /ws/node connection negotiates a fresh key with libsodium's crypto_kx (each side authenticates against the long-term identity it pinned at pairing), and after that the socket carries only ratcheted crypto_secretstream ciphertext, never resynced. A server on plain http:// 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 b28726a Thanks @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, and auth.sign_in gains the oidc:<door> method with exactly one row per real sign-in and none per refusal.

  • #176 db5951a Thanks @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 older subshell binary rejects resumed as 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 ac9ec22 Thanks @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=1 is now the cookie-admin view (every key, each labeled with its creator), and DELETE closes any row, audited setup_key.revoke with { foreign: true, ownerUserId } metadata (ids only, never the key text). A non-admin asking for all=1 gets 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 (assertNodePathId in services/nodes/node-path-id.ts, called by the RemoteLauncher path members and the launch-side planRemoteSubshellMcp), so "a hostile ../../../../x never 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 db5951a Thanks @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 4efcc92 Thanks @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 optional presetId, validated before anything is killed, audited as subshell.preset_switch. The @internal/backend-errors INVALID_PRESET addition 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 detail role 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 db5951a Thanks @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 native title, 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 its render prop so the row stays one element (nav, drag source, context-menu subject and tooltip trigger all on the same Link); a keyboard-focused row shows the same reveal, which title gave only on hover. The styled tooltip's text went one step up the scale, 13 to 14.

  • #178 ce81149 Thanks @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 b28726a Thanks @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). The cli-node bump covers only the smaller copy icon, which the node dashboard shares.

  • #170 ac9ec22 Thanks @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 reason the node's owner account is disabled, which the agent relays into its own log), and authenticateNodeUpgrade ends 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: update cannot un-disable an owner, so a held socket would carry a command that cannot fix the reason for the hold. local is 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 counts nodesDisconnected. 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 stale ready/heartbeat row writes; result frames 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 (dropTerminalSocketsFor in ws/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 gains tokensRevoked and terminalSocketsClosed beside the disable-only nodesDisconnected, same edge-only rule as the feed drop. The row names its subject by email, the same as the user.create / role / password_reset rows beside it: every admin already sees every email (GET /api/users is 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 and open's attachConnection, where the route's sweep finds no socket yet and the fresh one would live on; handleNodeOpen now re-asks accountDisabled immediately after attaching, against the owner id the upgrade stashed with the identity, and evicts a socket it missed through the same disconnectNode + 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 the nodes row 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. disconnectNode itself now writes the row offline and re-announces the node's running panes before it answers (the holdRefusedNode shape; 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 passes authGuard, gets committed past, and inserts after dropUserTokensFor's walk used to survive for its 30 s because the token branch trusted the store alone; the redeem now re-asks accountDisabled after consuming (the handleNodeOpen post-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: disconnectNode takes 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 through attachConnection, which supersedes held first, but the walk costs nothing and the targeted form needs to see either), and applyConfig's two unreadable-file refusals share one helper so the second read cannot drift from the first.

  • #170 ac9ec22 Thanks @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's fetch follows 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 own downloadVerified write) one redirect away from anywhere on the planet; an allowlisted mirror could answer 302 → 169.254.169.254 and the pin would have waved it through, because byte-trust was never egress-trust. The module now fetches with redirect: "manual" and walks the chain itself, re-running the allowlist on every Location before 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's ready write can no longer overtake its own eviction: a forced disconnectNode (rotate, delete, disable) landing while the frame's UPDATE is in flight detached and projected offline, and the already-issued write then landed online on 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's x-subshell-breakglass header; 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 ac9ec22 Thanks @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 shared normalizeLabel instead 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). normalizeLabel itself 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-attach ws attach journal 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 same accountDisabled check every REST surface already applies.

  • #169 78fd431 Thanks @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 new pane_cursor node 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 ac9ec22 Thanks @theogravity! - Two plugin-subsystem ordering fixes, both about refusing BEFORE the refusal's moment has passed. A network plugin's install.command is the one manifest field the control plane RUNS, and the install route hands it to sh -c; but the manifest parser only refused a line BEGINNING with sudo, 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 refuses sudo, doas or pkexec running 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/sudo is refused while brew install pkexec-demo still 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 bc9d16a Thanks @theogravity! - Fix the PWA frame ending short of the viewport in a home-screen install: the shell now pins its height to window.innerHeight (iOS computes dvh there 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 ac9ec22 Thanks @theogravity! - A service definition nobody could read is now refused with the honest sentence. The destructive verbs (node stop/uninstall/restart, node update, the server's own restart and update) already fail closed when the supervision definition reports unknown or 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 answered kills earns that sentence now; unknown and 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 db4fe8e Thanks @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 ac9ec22 Thanks @theogravity! - subshell-server update keeps 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 --rollback and the boot-time revert) now probe it first (<previous> version must exit 0). The CLI refuses outright when the copy cannot run; the automatic revert records the failure in failed.json and leaves the bootable new binary in place rather than putting an unbootable file where the service manager would try to exec it.

  • #170 ac9ec22 Thanks @theogravity! - Five round-3 audit fixes to the server's update chain. The route's UPDATE_IN_PROGRESS refusal 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>.previous becomes a second name for the RUNNING binary (a hardlink, or a flushed copy where links are refused), then ONE rename lands 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/-shm sidecars 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 configured SUBSHELL_RELEASE_URL origin 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 ac9ec22 Thanks @theogravity! - Sign-in and sign-out are audited now: the known gap docs/security.md §10 had been declaring closes (audit item R1). auth.sign_in is written from better-auth's response hook when a sign-in endpoint SUCCEEDS (/sign-in/email and 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 (authAttempts and the rate-limit route's logs). auth.sign_out rides 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 own user.password_reset / user.disabled_change rows. An emergency break-glass login still leaves exactly one row: emergency_login.rewrite_credential names 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 ac9ec22 Thanks @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/node patch beside this one.) Second, config.env writes: the dashboard's config PATCH and subshell-server configure share 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 6955725 Thanks @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