V4 candidate: optional desktop workspace with safe npm upgrades - #80
Draft
bvisible wants to merge 78 commits into
Draft
V4 candidate: optional desktop workspace with safe npm upgrades#80bvisible wants to merge 78 commits into
bvisible wants to merge 78 commits into
Conversation
Opens the v4 branch: the control plane. This first piece stands on its own and needs no interface. Until now the only way to give a server a password was clear text in a .env — a file that sits in a project directory, lands in backups, and shows up in `cat`. Competing MCP SSH servers put credentials in the OS keychain, and it was the last substantive gap against them. - src/secret-store.js — AES-256-GCM, master key in the OS keychain (security on macOS, secret-tool on Linux) with a 0600 file fallback for Windows, CI and containers. No new npm dependency: Node's crypto plus the tools already on each platform. - cli/vault.js + `ssh-manager vault` — list, add, remove, import, status. The CLI and the MCP server drive the same SecretStore, so they cannot disagree about what is stored. - config-loader.js — the vault sits above the config files and below the process environment: a credential deliberately stored wins over one left in a .env, but an operator overriding for one run still wins over both. Design choices worth keeping: only secret values are encrypted (hiding hosts and ports buys nothing and makes the file unreadable); GCM rather than CBC so a tampered vault throws instead of returning a wrong password that would be sent to a production server; listing never unlocks, so asking "which servers exist" does not trigger a keychain prompt; and a corrupt vault is reported but never stops .env and TOML from loading. `SSH_MANAGER_KEY_SOURCE=file` came out of writing the tests: there was no way to exercise the vault without touching the developer's real keychain, which is the same problem CI and containers have. 15 tests cover the round-trip, tamper detection, on-disk secrecy, 0600 permissions, CRUD, loader precedence, and that a broken vault degrades instead of breaking. Verified end to end: import a .env, delete it, and the MCP server still resolves both servers with their passwords decrypted. ROADMAP-V4.md records what is done, what is next (the approval broker hanging off applyServerPolicy, which is already async) and what the first UI must not be.
A long-lived development branch with no CI is a branch where regressions pile up until the merge. test, quality and codeql now watch v4 as well as main. Required status checks on main are unchanged: the job names and the 18.x/20.x matrix entries are untouched.
The second v4 piece, and the one the control plane exists for: the engine can pause an action and ask a person. `readonly` is a blunt yes/no decided in advance; approval is a decision taken with the actual command in view. It went in exactly where the roadmap predicted — inside applyServerPolicy(), the choke point all sixteen handlers already call, already async. No handler signature changed. SSH_SERVER_PROD_APPROVAL=destructive # never (default) | destructive | always Protocol is newline-delimited JSON over a local stream socket, so a control plane can be written in any language and debugged with nc. Failure modes decide this design, not the happy path. Timeout, unreachable socket, connection dropped mid-review, unreadable reply, reply carrying the wrong id — every one of them denies. The single exception is "approval configured but nothing listening": that allows and records it loudly, because failing shut would break every agent the moment the UI is closed, which would make the whole feature something people turn off. The destructive classification reuses policy.js, so a tool blocked by readonly is the same tool that prompts under destructive. The command list is short on purpose: a prompt that cries wolf gets clicked through without being read. Two things only the end-to-end test could find, both now guarded: - The `approval` field was never parsed by config-loader. All sixteen unit tests passed because they build the config object directly; driving the real MCP server over stdio is what exposed it. Now parsed and exported in both the .env and TOML paths. - A Unix socket path over 104 bytes fails bind() with EADDRINUSE on a path where nothing is listening. isControlPlaneListening now checks the length and explains it instead of letting someone chase a phantom conflict. Verified end to end against the real server: `rm -rf /var/www` is submitted and refused (exit -3, operator's reason preserved), `uptime` is never submitted, and the request the control plane receives carries the host and command but no password.
The third v4 piece, and the one that makes the other two visible. ssh-manager control # tokenised localhost URL, foreground Two screens and no more: what is waiting for you, and what your agents did. No terminal, no SFTP browser — Netcatty owns that ground with nine months' head start, and a control plane that opens on a terminal is just a late SSH client. Deliberately not Electron, and no new dependency: Node's http and net plus one HTML file. It runs anywhere the engine runs — including on a server, through the tunnels this project already manages — and can be wrapped in a desktop shell later without rewriting any of it. Access control is most of the work, because this process approves root commands. An unauthenticated HTTP server on localhost is reachable by every process on the machine and by any web page the user has open: a page can POST to 127.0.0.1, so without a secret a visited website could approve an agent's rm -rf. Therefore a random token on every request compared in constant time, a Host allowlist that blocks DNS rebinding, an explicit 127.0.0.1 bind, and the page served no-store under default-src 'none' with nothing loaded from the network. All of it tested, including a same-length impostor token and a hand-built request carrying a foreign Host (fetch refuses to set that header). Behaviours the tests pin down: shutting down refuses pending requests instead of stranding agents; deciding twice returns 409 rather than writing to a closed socket; the timeline follows the audit log so it shows actions that needed no approval, and survives a malformed line. One real bug the tests caught: an audit file that is empty when the control plane starts never had its offset recorded, so every subsequent line was skipped as history. Verified in Chrome against the real engine: a destructive request appears with its command and a "destructive" badge, clicking Refuse unblocks the waiting requestDecision in a separate process with decision=deny, the queue empties, and the refusal lands in the timeline.
brew tap bvisible/mcp-ssh-manager https://github.com/bvisible/mcp-ssh-manager brew install ssh-manager Installs the same npm package, so brew and `npm install -g` give identical binaries — the vault, approval broker and control plane are all in the engine, so there is no separate desktop build to keep in step. The formula lives here rather than in a separate tap so it is updated in the same commit as the release that changes it, and the release workflow now rewrites its url and sha256 after publishing. A formula pinning a stale version is worse than no formula: brew install would quietly hand people the previous release. Its test block runs a real MCP stdio handshake instead of checking --version, which would prove the binary exists and nothing else. Verified by reproducing what the formula does: the pinned sha256 matches the published tarball, `npm install --global --prefix` produces both binaries, `ssh-manager --help` exits 0 with the string the test asserts, and the MCP handshake answers with serverInfo.version 3.8.5. The sed expressions in the workflow were run against the formula to confirm they rewrite both fields and leave valid Ruby — not something to discover mid-release. `brew audit` could not run here: this machine's Xcode is too old for Homebrew's developer mode. The formula parses (`ruby -c`) and follows the standard npm formula shape.
The vault, approval and control plane are usable on the v4 branch and were documented only in ROADMAP-V4.md, which nobody reads before the README. States plainly that all of it is opt-in and unreleased.
brew audit cannot run reliably on a developer machine: Homebrew refuses its developer commands when any installed Xcode is older than it expects, and it scrubs DEVELOPER_DIR from subprocesses, so pointing at an Xcode beta does not help either. A runner has a current toolchain. Audits, installs, and runs the formula's own test block — which does a real MCP stdio handshake rather than checking --version. Triggered only by changes under Formula/ or on demand, since macOS minutes cost ten times Linux ones.
brew audit no longer accepts a path — it wants a formula name, which means the formula must live in a tap. brew tap-new creates a disposable one in the runner, which is the standard way to exercise a formula before it is published.
brew audit --strict rejects it: shell_output already expects a successful exit. Caught by the macOS CI job, which is why it exists.
The interface shipped with the approval queue and the timeline but not the thing that was asked for first: adding, editing and deleting servers. Doing that still meant dropping to a terminal, which rather defeats having a page at all. Adds a Servers tab over the same SecretStore the CLI uses, so the two cannot disagree about what is stored, and three API endpoints behind the same token as everything else — managing credentials is as dangerous as approving a command. Decisions worth keeping: - **A secret never travels to the page.** Listing returns hasPassword: true, never the value. The form therefore cannot display one, so it does not demand one back: editing a port keeps the stored password instead of wiping it. That round-trip is tested, because silently losing a credential while changing a port would be the worst kind of bug here. - **Deleting takes two clicks on the same button**, not a confirm() dialog: a browser modal freezes the automation this page is tested with, and a second click is enough friction. Armed for 10s — 4s was not long enough to read which row it belonged to. - Names are validated server-side against [a-z0-9_]+, since a name is the vault key: a bad one silently creates a second entry rather than failing. Verified in Chrome against a running control plane: filling the form creates `staging`, the password is ciphertext on disk, the engine's ConfigLoader reads it back decrypted, and deleting it from the page removes it from the vault and from what the engine sees.
The interface existed but only as a page you opened in a browser after running a command in a terminal. This is the window: dock icon, double-click, no terminal. ./desktop/build.sh -> desktop/build/SSH Manager.app (100 KB) It starts `ssh-manager control` as a child, reads the tokenised URL from its output, and shows that page in a WKWebView. Not Electron, deliberately. What needs displaying is one HTML page the engine already serves over localhost; Electron's runtime alone is 19 MB before any application code. One Swift file, swiftc, and a hand-assembled bundle — no Xcode project, no package manager. The honest trade-off is macOS-only: elsewhere `ssh-manager control` opens the same interface in a browser. Two things learned by running it rather than reasoning about it: - A GUI app launched from Finder does NOT inherit the shell's PATH, so node and ssh-manager are invisible. It now searches the usual locations and falls back to asking the login shell, which is how it finds nvm/asdf/volta installs. - SSH_MANAGER_CLI overrides which CLI is launched. This machine had an older global install at ~/bin/ssh-manager predating the `control` command, so the app started the wrong binary and sat there. The error message now mentions the override. Quitting terminates the child: it holds the approval socket, and leaving it alive would keep agents blocked on a UI nobody can see. Verified by launching it: the app starts the control plane (separate node process) and holds two established TCP connections to it — the page and the SSE stream. The macOS CI job now compiles the app too, since a Swift file with no project is otherwise unguarded.
Killing the desktop app left `ssh-manager control` running and holding the approval socket — agents would then block on a UI nobody can see, which is worse than having no control plane at all. AppKit only runs applicationWillTerminate on a clean quit; a SIGTERM (a crash, a supervisor, pkill) skips it entirely. Fixed on both sides: - The app installs handlers for SIGTERM/SIGINT/SIGHUP that route into NSApp.terminate, so the existing cleanup runs. - The control plane watches its own ppid: when the parent dies the process is reparented to init, and a changed ppid is the signal to stop. Only when it was launched by another program — interactively, nohup reparents on purpose and the user means it. The first attempt used stdin closing as the signal and was wrong: resume() on an empty pipe fires 'end' immediately, so the control plane died the instant the app started it. Caught because the retest checked the child was actually running before killing the parent — the first run had "passed" only because nothing was there to survive.
A fourth screen showing what agents are running while they run it, output included. This is what no command line can offer, and the reason to have a window at all. The scrollback design comes from TransHub's PtyService: a bounded circular buffer per stream, so a window opened mid-command shows what came before rather than starting blank. Same author, relicensed here under MIT with the rest of the engine, noted in the file. Two rules the module may never break, both under test: - Nobody watching costs nothing. No socket, openStream() returns null, every call site optional-chains it away, and the command runs exactly as before. A stat() per command is the entire overhead. - A watcher can never break or slow a command. Fire-and-forget writes, a throwing subscriber is contained, and the control plane vanishing mid-command does not throw into the execution path. Uses a second socket next to the approval one. Approval is a request/response that blocks a command; streaming is a one-way firehose. On a shared socket, a slow reader of the firehose would delay a decision. No PTY and no xterm.js: watching needs the exec stream ssh2 already provides. Interactive control would need both, and they belong in the desktop app rather than the engine. Also fixes the parent-watch introduced with the desktop app, which was wrong twice. Watching stdin killed the control plane instantly (resume() on an empty pipe fires 'end'); keying off a non-TTY stdin then killed any control plane started as `ssh-manager control > log 2>&1 &`, because the launching shell exits immediately. It is now explicit: the desktop app sets SSH_MANAGER_PARENT_WATCH, nothing is inferred. Both regressions were caught by running it, not by reading. Verified in Chrome: three streams appear with their command lines and exit codes, opening one shows fourteen lines of output plus a stderr warning, and the running one auto-scrolls to its newest output.
A fifth screen: CPU, memory, disk and uptime per machine, with gauges that turn amber past 80% and red past 90%. Almost no new logic — buildComprehensiveHealthCheckCommand() and parseComprehensiveHealthCheck() already existed for the ssh_health_check tool. The control plane opens its own SSH connection (it holds the vault, so it has the credentials) and disposes of it straight after. Nothing is probed in the background. Each probe is an SSH handshake, and a control plane connecting to every production box on a timer would be worse than no dashboard at all — a machine quietly opening sessions nobody asked for. The button is the entire scheduling policy. Two properties the tests hold: - Unreachable is a result, not an error: HTTP 200 with the reason and the elapsed time, because "prod did not answer" is what the operator opened the screen to learn. - Probes run in parallel, asserted by comparing two servers against one. Ten machines would otherwise cost ten timeouts in series. The test also forced a real fix: readyTimeout was hard-coded at 60s, so checking two unreachable servers took a full minute — unusable for a dashboard. Callers can now pass their own, and the probe uses 8s. Verified in Chrome against two deliberately unreachable hosts: one TEST-NET address timing out at 8001 ms and a closed local port refusing in 1 ms, each shown with its own cause rather than a generic failure.
…n found doing it
A sixth screen showing the two pieces of state that live in files this process
can read: server groups (union of .server-groups.json and each server's `group`
field) and known host keys.
Forgetting a host key earns its place: when a machine is rebuilt its key
changes, every connection fails, and the fix is deleting a line from
~/.ssh/known_hosts identified by a number in an error message. Two clicks, like
deleting a server — silencing a machine-in-the-middle warning should not be one
slip.
Tunnels are deliberately absent. tunnel-manager keeps them in a Map inside the
MCP server's process, which this one cannot see; an empty or stale tunnel list
would be worse than none, so the screen says where to look instead. Sharing that
state across processes is a design question, not a screen.
**Security fix found while wiring this up.** removeHostKey() ran
`ssh-keygen -R "${hostEntry}"` through a shell, with `host` coming from a server
config — which an operator, or the control plane's own form, can set to
anything. That is the same class as the three advisories fixed yesterday, in a
file the fix never touched: I had grepped the builders, not every execSync.
Now execFileSync with arguments passed directly, and guarded in
test-backup-monitor-injection.js so it cannot come back.
It also always returned true, because `ssh-keygen -R` succeeds whether or not
the host was there — so the control plane would have reported forgetting a key
that is still in the file. It checks first now. Caught by a test asserting a 404
for an unknown host, which is exactly the kind of "obvious" assertion that turns
out not to hold.
`ssh-keygen -R` fails outright where no known_hosts file exists at all — a fresh CI runner, a container — so removeHostKey threw instead of reporting "nothing to remove", and the control plane answered 500 where 404 was right. It now returns before touching ssh-keygen when the host is not known, which is also simply correct: there is no reason to run a removal command for a key that is not there. Passed locally and failed in CI, because this machine has a known_hosts file and the runner does not.
Packaged instructions so an agent uses these tools well — the part it cannot infer from a tool description, because it is judgement rather than syntax. - ssh-operations: look before you change, back up before you overwrite, prefer the specific tool over a raw command, never put a password on a command line, and say which servers you touched. - ssh-incident: a diagnosis order — health check, service status, logs, processes — before restarting anything, because a restart that fixes the symptom destroys the evidence. - ssh-restricted: what to do when a tool is refused. Do not retry through ssh_execute, do not hop to a less constrained server, do not ask the operator to disable the mode. Working around a control someone configured deliberately is worse than failing the task, because they will believe it held. Short on purpose: a skill that reads like a manual gets skimmed. tests/test-skills.js guards them, since prose type-checks against nothing: front matter loadable and matching its directory, descriptions that say *when* rather than *what*, every ssh_* tool named actually present in the registry, a length ceiling, and that .npmignore does not exclude them. Verified by mutation — renaming a tool inside a skill, and breaking the name/directory match, are both caught.
Closes the last architectural gap in the options screen. tunnel-manager keeps tunnels in a Map inside the MCP server's process, which the control plane cannot see; the engine now publishes them to ~/.ssh-manager/tunnels.json whenever one opens or closes. The file carries the writing process's pid, and the reader checks that pid is alive before believing it. An engine that crashed would otherwise leave a file claiming ports are forwarded when nothing is listening — and telling an operator a tunnel is open when it is not is worse than showing no tunnels at all. A stale file is reported as stale and never rendered as open, which is tested with a pid that cannot exist. Publishing is best-effort and swallows its own errors: it is a convenience for a window and must never take down a working tunnel. No file is written when there are no tunnels, since an empty file and a missing one mean the same thing and removing it stops a dead pid lingering on disk. Also fixes the host key rows, which showed "unknown" for every algorithm: listKnownHosts returns one entry per host with a keys[] array — a host commonly has several — and the UI was reading a `type` off the entry itself. It now lists each algorithm and fingerprint. Caught by looking at the screen with 105 real entries on it, not by a test.
Watching an agent work answers "what is it doing"; it does not answer "let me fix this myself". The Terminal screen closes that gap: pick a server, open a real shell, type. Colours, `top`, `vim`, Ctrl-C and window resizing all work. No native module. `ssh2` allocates the remote pseudo-terminal itself via `client.shell()`, so there is nothing to compile at install time — the engine keeps its zero runtime dependencies. Only the control plane gains anything, and what it gains is a vendored xterm.js (MIT, 477 KB) with its licence and a README saying why it is checked in rather than depended on. Deliberately not subject to readonly/restricted mode: those modes constrain an *agent*, and whoever holds the control-plane token is the operator who set them and already has the credentials. Constraining them there would be theatre. Five defects found by driving it rather than reading it: - **Output before anyone watched was lost.** The login banner and first prompt land between the shell opening and the browser subscribing, so the screen opened blank. Now kept in a bounded 64 KB backlog and replayed on subscribe. - **The asset tags carried no token**, so xterm.js and its stylesheet were refused 401 and the terminal rendered as a blank box. A <link> cannot add a header before the browser fetches it, so the token is stamped into the URLs when the page is served. - **The server picker was never populated** — the Terminal screen could not be used at all. Filled from renderServers so it follows every add and delete. - **An over-long socket path reported EADDRINUSE**, sending you to look for a process that does not exist on a socket file that is not there. macOS caps sun_path at 104 bytes and a repo under a long path reaches it easily; now checked before binding, with the byte count in the message. - **/favicon.ico logged a 401 on every load** — the browser requests it without the token. Inlined as a data URI: no route, no request. Tested against a real SSH server (ssh2 can be one) rather than a mock, so the test proves a PTY is actually requested, keystrokes reach the far side, a resize is forwarded, and closing releases the connection instead of leaking it.
The roadmap said a PTY was a different code path and a dependency. It is a different code path; it was never a dependency — ssh2 allocates the remote pseudo-terminal itself. Corrected rather than deleted, because the reasoning behind a wrong call is worth more than a clean page.
A sidebar, a real file browser, per-server sessions, and the design system that
came with them — carried over from TransHub AI Desktop, same authors, MIT here.
**Why this was small rather than a rewrite.** TransHub's renderer is not coupled
to Electron: across the 56 components worth taking, not one calls `ipcRenderer`.
Everything goes through `window.api`, which Electron fills with IPC and the
relay fills with WebSocket RPC. This is the third filling — `ui/src/lib/api.ts`,
plain HTTP and SSE against the control plane. Nothing in a component changed to
make that work.
Of the API surface those components need, the engine already had three quarters:
shells (the terminal shipped this morning), the vault behind `/api/servers`, and
command execution. What was missing was SFTP, so the control plane gains it:
list, read, write, mkdir, rename, delete, over a **pooled** connection — a file
browser makes tens of calls to walk one tree, and an SSH handshake per `ls` would
make the screen unusable.
**The published package got smaller, not bigger.** 4.1 MB → 729 KB, because the
same pass dropped 3.7 MB of documentation images and a compiled desktop binary
that every install was downloading and nothing was reading. The interface adds
872 KB: 664 KB of JavaScript and 132 KB of fonts, subset and converted to WOFF2
(919 KB of TTF, −86%). `ui/` keeps its own package.json so React never appears
in the engine's dependency tree, and `dist/ui` is committed — `npm install`
still compiles nothing, the same bargain as the vendored xterm.js.
Two things were fixed rather than copied:
- `FileInspector` interpolated a filename into `chmod ... "${file.path}"`. A
filename is remote input; that is the class of bug 3.8.5 closed. Reported
upstream, quoted here.
- The dual local/remote panes became a single remote pane. A page in a browser
cannot reach your local filesystem, so the local half would have been a lie.
Four defects found by driving the screen, none of which a test had caught:
- **A stylesheet cannot authenticate itself either.** The fonts 401'd and the
page silently fell back to the system stack — same shape as the `<script>` tag
fix this morning, one level deeper. The token is stamped into `url()` too.
- **A silent server hung the screen forever.** ssh2 has no deadline of its own,
and a machine dropping off the network answers nothing at all — which the
operator cannot tell from a slow directory. `dispose()` in a test fails
instantly, so the test could not see it. Now timed out and replaced.
- **`.` was never resolved**, so the breadcrumb had nothing to draw and every
path built from it was relative to a directory the browser could not name.
- **`/favicon.ico` 401'd on the new page** as it had on the old one.
`npm run test:files` drives all of it against a real SFTP server — ssh2 can be
one — so the POSIX mode decoding, the path joining at the root, the pooling and
the recovery are exercised for real rather than mocked.
Panes were unmounted when you switched away. That lost the directory you had navigated to — annoying — and it *closed the shell*, because ShellPage's cleanup disposes the connection. A tab you cannot click away from and come back to is not a tab. They now stay mounted and hidden. Two consequences worth being explicit about: - A hidden element measures zero, and fitting a terminal to zero corrupts the layout it will be restored into, so the observer skips panes that are not displayed and a refit runs when one comes back. - Hidden panes are marked `inert` and `aria-hidden`: three overlapping shells would otherwise all be reachable by keyboard, and a screen reader would read every one of them. Verified in the browser: navigate to /apps in one session, run `uptime` in a shell on another, switch between them — both come back exactly as they were.
…side by side Two corrections to yesterday's port, both of them fair. **The design was not TransHub's.** I had copied the primitives and the tokens but rewritten the sidebar, the server cards and the file browser — so the structure rhymed and the style did not. The real components are in now: `Sidebar`, `ServerGrid`, `ServerCard`, `CategoryGroup`, `FilePane`, `FilePaneRow`, `FilePaneContextMenu`, `Breadcrumb`. What comes with them is what a rewrite loses: the accent bar on the active rail item, rename-on-double- click, collapsible categories with counts, hidden-file toggles, keyboard shortcuts in the tooltips, "44 items (192 hidden)". Making them drop in unmodified meant adapting around them rather than editing them: the workspace store keeps TransHub's `tabs`/`addTab`/`activateTab` shape instead of my own, `servers.store` keeps its selectors and grouping, and i18next is wired with TransHub's own English strings for the 54 keys these components use. A component nobody had to touch is a component that still looks like the original. **The file browser is now dual-pane** — this machine on the left, the server on the right, in a draggable split. I had argued a browser cannot reach the local filesystem. True, and beside the point: the control plane is a Node process on the operator's own machine, and it is what reads the disk. Six routes for the local side, one for transfers. Transfers stream directly between this machine and the server through the control plane. The browser never holds the bytes, which is both faster and the only way a multi-gigabyte file works; progress arrives on the existing event stream instead of by polling. Verified end to end in Chrome: selected a file on the server, pressed Download, watched it land in the home directory with its content. `npm run test:files` covers the new surface against a real SFTP server: local listing with lstat (so a symlink reports as itself and a broken one does not throw), transfers in both directions, local mkdir/rename/delete, and the token required on every local route — reading this machine without it would be worse than reading the server without it. Also fixed, carried over from TransHub: ServerCard's action buttons had no accessible name. A tooltip is not read by a screen reader and not reachable by keyboard, so they announced as nothing at all. Package is 777 KB.
Waiting, Health, Live, Activity and Options now live in the React shell. With
that, `src/control-plane-ui.html` and the vendored xterm.js are gone: 1487 lines
and 496 KB of a second implementation of every screen. Two of those never stay
in step, and the one that drifts is always the one nobody looks at. What is left
at `/` when the build is missing is a page naming the command to run.
Wiring them against the real engine — rather than against what I assumed it
sent — turned up five mismatches, every one of which made a screen quietly
wrong rather than broken:
- **`/api/decide` reads `decision`, not `approved`.** The wrong key meant every
answer became a refusal. Safe direction, wrong behaviour, and silent.
- **The queue only listened for `pending`.** There are three events — a request
appears, is answered (`resolved`), or vanishes because the agent that asked
went away (`expired`) — so the badge kept showing a number that was no longer
true.
- **The audit trail records `allowed`, not `decision`**, so every entry rendered
with the neutral icon and no verdict.
- **Health is `disks`, `cpu.percent`, `memory.used_mb`** — the disk gauges were
simply absent, and `uptime` already reads "up 42 days", so prefixing it gave
"up up 42 days".
- The health probe takes its server in the query; sending it in the body probed
every machine while appearing to probe one.
I also nearly broke the queue while testing it. My fake agent wrapped the
request in `{type, request}`; the engine writes the object itself
(src/approval.js:203). Reading the wrapper made `/api/decide` answer "no longer
pending" — and rather than adapt the product to my test's mistake, the test was
fixed. That failure was the only reason the mismatch surfaced.
Verified in the browser against a real blocked agent: the request appears with
its full command and how long it has been waiting, Refuse sends
`{"id":"req-1","decision":"deny"}` back down the socket, the card disappears,
the badge clears, and the refusal lands in Activity as "refused · control-plane".
Package is 645 KB, down from 4.1 MB before this work started.
…dable Migrating from a .env means the vault becomes the only copy of your credentials. Two things were missing before that is a safe thing to do, and the second is the serious one. **The vault lied when its key was gone.** Played out end to end — import a .env on one machine, copy the vault to another, leave the key behind — `vault list` printed "3 server(s) in the vault" and "encrypted: password" for a file whose secrets were unreadable, because listing reads the file and never decrypts anything. The store, meanwhile, minted a fresh key and carried on: new secrets would encrypt under the new key while the old ones stayed lost, and nothing looked wrong until a connection failed weeks later. `unlock()` now refuses that pairing and says what to do; `list` and `status` verify by actually decrypting, because something that looks like confirmation has to be confirmation. **Recovery files** (`vault backup` / `vault restore`): one copy encrypted under a passphrase the operator chooses, independent of the machine keychain. Deliberately a file and not a printed key — what needs recovering is thirty servers with hosts, ports, users, key paths and modes, and nobody retypes that from paper. A file goes in a password manager as an attachment. scrypt N=2^17, AES-256-GCM, and the plaintext includes the hostnames: unlike the vault, where they stay readable on purpose so it can be inspected and diffed, this file is meant to be stored somewhere less trusted. **The advice was in the wrong order.** Import used to end with "once you have checked everything works, you can remove the secrets from that file" — which, with no recovery copy, reads as "delete your only copy". It now says: take a recovery file, confirm the vault decrypts, run something real, and only then. It also states plainly that the .env keeps working and nothing obliges you to delete it. Two fixes to the prompt found while testing it: a passphrase was echoed in clear because I forgot the flag that masks it, and a fresh readline interface per question meant the second half of a confirm-your-passphrase pair never received an answer on anything but a keyboard. `npm run test:recovery` covers the round trip, tamper detection, the refusal of a passphrase too short to protect anything, and the whole new-machine scenario. Verified against a real global install (`npm install -g`), driven through a pseudo-terminal so the masked prompts are exercised as typed.
Two things were missing between "the vault exists" and "people use it". **A test that the upgrade changes nothing.** Played out for real first: install 3.8.5 from npm, capture what the loader returns for a .env of three servers, install v4 over it, compare. Byte-identical. `npm run test:upgrade` now keeps that true — a .env-only install loads exactly as before, no vault is ever created by merely loading, importing leaves the file untouched, a half-migrated setup works (vault per server, .env for the rest), the process environment still outranks everything, and a corrupt vault degrades to the .env rather than taking the whole load down. That last one is the failure that would hurt most, and it is why the vault is a layer above the files rather than a replacement: a machine whose keychain is gone still has working servers. **An offer, in the interface.** Nobody reads a changelog, so someone upgrading from 3.8 would use v4 for months without learning the vault is there. The Servers screen now says so when servers are configured in a file: which file, which servers, how many secrets each holds — counts, never values, since that answer goes to a browser. It offers and does not act. Their setup works; the vault has to earn the move by being better, not by happening while they are not looking. Import takes named servers only — a button that moves everything is a button somebody presses by accident — and the .env is never written to, here or anywhere. The banner also says, before it becomes relevant rather than after, that the vault key belongs to this machine and a recovery file is what survives a new one. Verified end to end in Chrome against a real .env: banner names the file, importing encrypts the three secrets, the .env comes out with the same md5.
A page for the question someone actually asks — "what happens to my .env?" — answered first with "nothing", then with what the vault offers, then with the one thing that bites: the key is in this machine's keychain, so a vault copied to a new laptop opens for nobody. Ordered so the recovery file comes before the sentence about cleaning up a .env, rather than after it.
… tier The last screen that only read. The engine could create, edit, delete and execute on groups since 3.x; the interface showed them and nothing else. Now: create a group by picking servers, choose whether they run all at once or one after another, run one command across the tier and watch the exit code and output arrive per server on the event stream. Answered immediately and reported as it goes, like transfers — a command across twenty machines takes as long as the slowest one, and a request held open that long times out in between. Two things the screen has to say rather than let you discover: - **A group derived from the servers' own `group` field cannot be edited here.** Saving one would write a shadow copy into .server-groups.json that stops following the config, and the two would drift. It is refused with the reason, and its edit and delete buttons are not shown. - **Deleting a group leaves the servers alone** — said in the confirmation, because "delete production" is a sentence that deserves to be unambiguous. Two bugs found by driving it: - `getGroup()` throws for an unknown name, which is right for a tool call and wrong as an existence test — using it as one made every create report the group missing. - The config provider was wired inside the options handler, so the write routes could not see config-derived groups at all and cheerfully created shadow copies of them. Wired once at startup now, where every route sees it.
Electron, and the app *is* the control plane rather than a window pointed at one. The 104 KB Swift shell that came before searched the system for an installed `ssh-manager`, which disqualifies it as a product: somebody who downloads a .dmg has neither Node nor the npm package, and telling them to install Node first is telling them to use the CLI. Electron already ships a Node runtime, so importing the control plane in-process makes the app genuinely self-contained — no second process to supervise, no port handshake, no orphan left when the window closes. Verified the way it will actually be used: the packaged app launched with `env -i` and a PATH holding no node and no npm. It starts, opens its keychain, loads the vault and serves the interface. There is deliberately no separate desktop UI. The window loads the same page `ssh-manager control` serves, byte for byte — two implementations of the same screens is the trap this project climbed out of when the single-file page was deleted. **Two fixes from the first run.** The window buttons sat on top of the sidebar's collapse control, because a hidden title bar draws over the content. The page cannot know that on its own — the same page is served to an ordinary browser tab, where there is nothing to avoid — so the app appends `&shell=macos` and the rail reserves the space. **Notifications**, which turn out to be load-bearing rather than a nicety. The approval feature's premise is that an agent pauses and a human decides; if that human is in another window — which they are, because they delegated the work to be elsewhere — the request sits unseen until it times out and is denied. A queue nobody is told about only ever produces refusals. So a waiting request raises a desktop notification naming the machine and showing the command. A destructive one says so in the title, makes a sound, and asks to stay on screen; an ordinary one is silent and may auto-dismiss, because notifying everything identically trains an operator to dismiss all of them including the one that mattered. Answering closes it, clicking opens the queue, and a request already showing is not notified again on every stream event. Permission is requested once, when there is something to show — never at page load, which is the prompt everybody denies. `npm run test:notify` covers all of that with a stubbed Notification. The packaged app is 288 MB, 121 MB as a DMG. It was 353 MB until `prepare-engine.mjs`: pointing electron-builder at the repository's node_modules had put 55 MB of eslint, acorn, ajv and a Rust resolver binary inside the application. devDependencies have no business shipping to a user.
The design system carried over from TransHub defines a complete dark palette — 32 tokens under `.dark` — and nothing ever toggled that class, so not one of them had been seen. This is the switch, in the bottom block of the rail where TransHub keeps its settings. Three states rather than a toggle. "Follow the system" is a real preference and the default one; a two-way switch silently opts you out of it the first time you touch it. Collapsed, the rail cycles instead — there is no room for three targets, and an icon showing the current state is more useful than one showing a choice. Resolved with matchMedia rather than by asking the desktop shell, so the same code path serves a browser tab, and it follows the system as it changes rather than only at load — which is to say, at sunset, when someone would notice it had not. Terminal output needed its own answer. A terminal is dark in both themes, but a `#111418` panel in a light window reads as a hole punched in the page rather than part of the application, so the shade moves with the theme while staying dark: two tokens, and xterm's theme swapped in place. In place matters — rebuilding the terminal would drop the connection and everything on screen. Verified: switching themes with a live shell keeps the session and repaints it. The desktop window paints its own ground from the system theme too, or the launch flashes white for a beat before a dark page arrives — the one moment of a launch anybody actually watches. Also fixed, surfaced by having two shells open at once: hidden panes carried both `inert` and `aria-hidden`. The terminal keeps focus in a hidden textarea, and aria-hidden over a focused element hides it from assistive technology while it is still the focus — which the browser rightly complains about. `inert` alone does the whole job.
**Nine of the file browser's fifteen context-menu actions had no handler.** The menu component declares onOpen, onRename, onDelete, onTransfer, onRefresh, onCreateFolder, onCreateFile, onCompress, onExtract, onPreview, onInspect, onShowInFinder, onOpenTerminal, onOpenInEditor and onOpenInCode; FilesPage was passing six of them. Audited rather than assumed — the list above is generated from the two files. The good news is that nothing was *dead*: every unhandled action is already guarded on its own prop, so it hides rather than rendering a menu item that does nothing. Verified by right-clicking a file for real: five entries, all of them working. Three are now backed: - **New file**, which needed a route — the remote side has /api/files/write but the local side had read and no write. /api/local/touch opens with 'wx', so it fails on an existing file rather than silently emptying it. - **Show in Finder**, local only, through the reveal route that already existed. - **Open terminal**, remote only, opening this application's own shell rather than handing off to Terminal.app — and raising the existing tab for that server if there is one. Compress, Extract, Quick Look, Get info and the two editor actions stay hidden. They need work I have not done, and a menu item that lies is worse than one that is absent. **Options gets a rule above it.** Measured at six window heights, with and without open shells: it is always rendered and always on screen. It was still being missed, because a lone unlabelled grey gear at the far end of a 48px rail reads as decoration rather than a destination. The rule is the part the eye was missing; the layout never was the problem.
Committed the previous change with a failing typecheck — the JSDoc union on #localOp still listed four operations after a fifth was added, and tsc caught both the call site and the comparison. The 0-error baseline is a gate for a reason; I should have read the exit code before pushing.
Drag and drop works, and I could not prove it until today because the way I was testing it could not fail. A hand-made DragEvent carries its own empty DataTransfer, so the drop handler ran, found nothing, and did nothing — while the check looked at the wrong pane and found the word it was hoping for. Two bugs agreeing is not a passing test. Chrome can intercept its own native drag: `Input.setInterceptDrags` turns a genuine mouse drag into an event carrying the real drag data, which `Input.dispatchDragEvent` then delivers at the target. Everything the browser would have done, it does. Kept as scripts/test-drag-and-drop.mjs, with the filesystem as ground truth rather than the screen. That immediately found the bug this commit fixes. Every folder row is a drop target — FilePaneRow passes its path, FilePane resolves it into `dropDir`, and the whole chain works — but FilesPage's `onDropFiles` took one argument and threw the second away, then recomputed the destination as the other pane's current directory. So a file dropped onto `backups/` landed beside it. Every folder on screen was a drop target that quietly did the wrong thing, and it looked like it had worked. Verified on disk both ways: before, `/srv/runbook.md`; after, `/srv/backups/runbook.md`. Two limits found on the way and left alone, both honest: - dragging a directory is refused with "Directories cannot be transferred yet", which is true — it needs a recursive walk on both sides - the drag payload's mime type is still `application/x-transhub-files`, from where these components came
Dragging a file onto the application icon did nothing, and could not have: the bundle declared no document types, so macOS refused every drop and `open-file` never fired. It declares `public.item` now — this application does not open documents, it sends them somewhere, so narrowing by extension would only make it refuse the files people actually have. The interesting half is the question you asked: which terminal, a new one, or a remote already open? There is no sensible default. "The last server" is wrong the first time and whenever two are open, and guessing silently is how a file ends up somewhere nobody expects. So it asks — and the answers are ordered by what is already in front of you: a machine whose files you are looking at, then one you have a shell on, then the rest. Each row says where the file will land before you pick it, and afterwards the file browser for that machine opens so you can see that it did. Three things that would each have made this half-work: - macOS delivers `open-file` *before* `whenReady` when the drop is what launched the app. Paths are collected and flushed once there is something to flush them into, rather than dropped on the floor. - Several files arrive as several events in quick succession, so they are coalesced: one question about five files, not five questions. - The main process had no way to tell the page anything. ControlPlane.announce() is that door, and deliberately the only one — everything else the page needs, it fetches. Verified end to end in the browser, since the Dock path only exists in the packaged app: with SSH_MANAGER_DEMO_DROP set, the demo announces the same event the desktop shell sends, and the dialog appears with the right files and the right destinations. The Dock gesture itself still needs a packaged build to try.
… queue **Somebody opening this for the first time saw an empty list and a rail of icons**, and had to work out from that what the application is for. The screens explain themselves once there is something in them; before that they explain nothing. Three panels, because there are three things worth saying: what this watches, how to give it something to watch, and the one setting that changes what an agent is allowed to do. It appears only when no servers are configured, and never again once closed — an introduction that keeps introducing itself is a nag. Ending it lands you on Servers, which is where the next thing to do is. **The Waiting screen explained where approval is configured and then left you there.** "It is set on the server" is an instruction with no door attached, to somebody who is looking at an empty queue precisely because they have not found that door. It now says what pausing does in plain terms and offers the button. Both driven with real mouse events, kept as scripts/test-first-run.mjs. The first run of that test reported three failures, all of them its own: it clicked "Add a server" on the page *behind* the modal, because that one comes first in the DOM. Clicks are scoped to the dialog now. Worth writing down — a test that finds the wrong element reports a product bug that is not there, which costs exactly as much as a real one.
The README had been describing a version nobody was running: the screenshots predated the unified headers, Nora's orange, the collapsed rail and the Terminal screen, and the video predated all of that plus the shell working at all. Twelve screenshots regenerated — six screens in both wrappers — plus a new pair for Terminal, and the video re-recorded with a scene for it. 24.7 seconds, nine scenes, still under 300 KB in each format. Two things in the rig needed fixing first, both consequences of the rail now being collapsed by default: - the recorder matched rail items by their visible text, which a collapsed rail does not have. It matches the accessible name first now, as the screenshot script already did. - a stale demo process was holding the port, so the recorder was driving a page that had never loaded and blaming the second scene for it. Worth remembering: "nothing to click" from these scripts usually means the demo, not the button. This is the fourth time today that regenerating images has been the last step after a change, and the second time the rig needed a fix to do it. It is still far cheaper than the alternative, which is a README that quietly stops being true.
The application is notarized. `spctl` says `accepted / source=Notarized Developer ID` on a copy carrying the quarantine attribute a real download from Safari would have, which is the only test that means anything: a locally built file has no quarantine attribute and passes whatever you do. One step was missing and would have shipped a worse artifact. electron-builder notarizes the `.app` *before* packaging it into the disk image, so Apple has no ticket for the DMG and `xcrun stapler staple` on it fails with "Could not find base64 encoded ticket in response". The app inside is notarized either way and Gatekeeper accepts it — but with no ticket on the DMG, opening the download offline makes Gatekeeper phone home, and on a bad connection that is a spinner in front of somebody's first run. The DMG is submitted separately now and the command is written down. verify-mac-build.sh checked the app and said nothing about the DMG, which is the file people actually download. It checks both.
TransHub does this against an update server of its own. SSH Manager does not need one: the repository is public, the releases already live there, and electron-updater reads `latest-mac.yml` straight out of the release assets that electron-builder writes beside the installers. Nothing to run, nothing to pay for, nothing else that can be down. **Offered, never applied.** This application holds SSH connections and the approval socket an agent may be blocked on. Pulling 120 MB down somebody's tethered connection because a release happened, or restarting under them while a transfer runs, are both worse than being a version behind for an afternoon. So it checks quietly, says so in the menu bar — "Update to 4.1.0" — and downloading is a click. Installing happens when the application is closed anyway. A failed check says nothing at all: the network is down, or GitHub is, and neither is news. It tries again in six hours. release-desktop.yml builds macOS and Windows on a tag, notarizes, submits the DMG separately for its own ticket, runs verify-mac-build.sh as a gate, and uploads to the release. Deliberately not part of release.yml: that one needs an OTP somebody types, this one needs Apple to be having a good day, and coupling them means a notarization queue blocks a package release. Checked what bit the packaging last time: electron-updater is a runtime dependency, and `files` in electron-builder.yml lists only main.js and two icons — electron-builder adds production dependencies regardless, verified by extracting the asar (16 modules) and starting the packaged app. Six secrets are needed before it can sign anything: MAC_CERT_P12, MAC_CERT_PASSWORD, APPLE_API_KEY (base64 of the .p8), APPLE_API_KEY_ID, APPLE_API_ISSUER. Without them the workflow still builds, and produces something unsigned that is useful for a dry run and useless to ship.
Debris from checking that electron-updater actually made it into the package — the check was right, the cd before it was not, so it extracted into the source tree instead of the scratch directory. Gitignored so the next inspection cannot do it again.
The import existed and could not be found. Seven format readers, thirteen tests, a hundred and eleven servers readable out of Transmit on this machine — all of it behind `ssh-manager import` in a terminal, which is not where somebody looking at an empty server list goes. I noted the gap once in passing and then moved on, which is worse than not having noticed: a feature nobody can find is a feature that is not there. It is now in three places, which is where it was always going to be needed: - **Servers** — an Import button beside Add, always, not only when the list is empty. The empty state's own Import button has existed since these components came over from TransHub and was never given a handler. - **The introduction** — step two said "import yours" and printed a shell command. It has the button now. The dialog leads with what is already on this machine, probed and counted, and the count is the point: "111 servers" is a reason to press it, "Transmit" on its own is a question. A file is the other way in, for a spreadsheet somebody sent or a tool not installed here. Nothing is written before it has been shown — import is the one operation where finding out afterwards means forty rows under the wrong names. Three routes behind it: /api/import/sources probes the machine, /api/import/preview reads without writing, /api/import/apply writes back exactly the list that was confirmed rather than re-reading the file. **And a real bug, found only because the test checked the vault instead of the screen.** The dialog said "Import 111 servers", the click registered, and nothing was written: #readJsonBody capped bodies at 8 KB and *destroyed the socket* rather than answering, which reaches the browser as "Failed to fetch" with no status and no message. Every other route sends a handful of fields; confirming a hundred servers lands around 20 KB. The cap is 1 MB now and it replies 413, so the next thing to hit it says why.
Building, signing and notarizing are the parts worth rehearsing, and there is no way to rehearse them without a tag — which would mark a release nobody has decided to cut. A publish input, off by default, makes electron-builder stop at the artifacts; they are uploaded either way, so a dry run still hands back something installable. The ref input takes a branch as well as a tag, which is what makes a dry run possible at all.
Two shelled-out binaries that only exist on Unix, three lines apart.
`execFileSync('npm', …)` does not consult PATHEXT, and on Windows npm is
`npm.cmd` — so every Windows build died with `spawnSync npm ENOENT` before
installing a single dependency. This is not only a CI problem: nobody could
build the app there at all.
`du -sh` for the size line was the same mistake one line further down. Counted
in Node now, which needs no coreutils and gives apparent size rather than
allocated blocks.
Both jobs failed in two minutes, for two mistakes of mine that hid each other.
A step's `if:` cannot read that same step's `env:` block, so
`env.MAC_CERT != ''` was always false and both the certificate import and the
key-writing step were skipped silently. The secrets now sit in a job-level
`env:`, which step conditions can actually see.
Then `format('{0}/private_keys/AuthKey.p8', env.HOME)` — `env.HOME` is empty
inside `${{ }}`, which reads the workflow's env and not the runner's. That
produced the absolute path `/private_keys/AuthKey.p8`, and notarytool said so.
`runner.temp` resolves in every expression context.
While here: verify every .app the build produced, not just the arm64 one. An
unnotarized x64 build ships exactly as broken and nothing else would notice.
The file no longer parsed: the `runner` context is not available in a job-level `env:` block, and GitHub rejects the whole workflow rather than the one line — `Unrecognized named-value: 'runner'`. The plain environment variable is present in every `run:`, and the one place that genuinely needs the expression, the Build step's `env:`, is step level where `runner.temp` does resolve. Checked with actionlint this time, which reports this exact error offline in a second and would have caught both of the last two attempts.
…r finds
A YAML parser accepts workflows GitHub then refuses. Expression contexts are
only valid in some positions, and using one where it does not exist is rejected
at dispatch — after the push, not before it. The desktop release lost three runs
to exactly that: a step `if:` cannot read its own `env:`, `env.HOME` is empty
inside `${{ }}`, and `runner` does not exist in a job-level `env:`.
actionlint reports all three offline in about a second, so it belongs in the
lint job, which is a required check. Pinned by version and verified by sha256
rather than pulled from a floating tag, like everything else here.
Two shellcheck findings had to go first, both real if minor: an unused loop
counter in the Homebrew step (it is a retry budget, not an index) and four
appends to the same file where a heredoc says it once.
`npm.cmd` fixed the ENOENT and bought an EINVAL: Node has refused to spawn a .bat or .cmd without a shell since the fix for CVE-2024-27980. Two failed builds, two different errors, same wrong idea — that the way to run npm is to find its executable. `npm_execpath` is npm's own CLI script, set by the `npm run build:win` that got us here. Running it with the Node we are already inside needs no shell, no file extension and no PATH lookup, and it is the same npm that invoked us rather than whichever one PATH happens to name. The fallback, for someone running this file directly with `node`, is the only place a shell is used, and only on Windows. Both paths run here: via `npm run prepare-engine`, and with npm_execpath unset.
Tested on the packaged, notarized 4.0.0: dropping a file on the icon of a *running* app asks where to send it; dropping one on the icon of an app that is not running launches it and the file vanishes. The drop is the whole gesture, and it only worked when the app was already open. `announce()` writes to the pages currently listening, and on a cold start there are none — the drop is what launched the application, so the event is ready a second or two before the interface has connected its stream. The flush timer in the shell could be lengthened, but any number picked there is a guess about someone else's machine. So an announcement made to nobody is held instead, and handed to the first page that connects: once, and only within 30 s, so nobody is asked about a file they dropped in another session. The introduction now steps aside for a drop. Someone who dropped a file asked for something; a first-run tour is not an answer to it. It comes back when the question is dealt with. Also: the asar check in verify-mac-build.sh never ran. `asar` belongs to electron-builder and lives under desktop/electron, so `npx --no-install asar` from the repository root found nothing and the check degraded to a warning — silently, in CI too. It is the check that catches a missing menu-bar glyph, which would throw inside `new Tray` and take the app down at launch.
`state.subscribe()` opened its own EventSource every time, and there are eight subscription sites. A browser allows six concurrent HTTP/1.1 connections per origin, so with enough of the interface mounted the page runs out of sockets and can no longer fetch anything — the failure would look like the application freezing for no reason. It also broke the fix one commit ago. An announcement held for a page that had not connected yet is handed to the first connection that opens, and with eight of them that was whichever component happened to subscribe first — never the one listening for the drop. Verified against the packaged app: dropping two files on the icon of an app that is not running now opens "Send 2 files where? runbook.md, notes.txt", where before the drop vanished. One connection, a set of handlers, closed when the last one leaves. All the handlers of a React commit are registered before any frame can arrive over the network, so nothing is lost in the gap.
The built interface is committed because it ships in the npm package — the engine serves it. The two previous commits changed App.tsx and api.ts without it, which would have published a stale bundle: no shared event stream, and the introduction still covering a drop.
Three things that were missing from the terminal screen: you could not open a shell here, you could not open two shells anywhere, and the sessions you had were only in the rail. **A local shell.** The rail's own comment admitted it — "Removed: … the local terminal" — and the screen said "No servers yet" to somebody who just wanted a prompt. A pseudo-terminal needs `forkpty`, which needs a native module, and the engine has none and is not getting one: it is published on npm and installs on machines with no compiler. So the desktop shell hands one down instead, through `setLocalShellProvider`, the same kind of door as `announce()`. node-pty ships N-API prebuilds that Electron loads unchanged, so nothing is compiled at build time either. Verified on a packaged build: a real tty, the user's own zsh, 156 columns fitted to the pane. Where no host offers one — `ssh-manager control` in a browser — the control plane answers 501 and the interface asks first, so the option is not shown rather than shown and broken. **Several shells.** A row still raises the shell it already opened, because that is what a click on it means, but a `+` on the row opens another and the local machine always opens a new one. Titles number themselves: "prod", "prod 2" — three tabs all called "prod" is three tabs you must open to tell apart. **Tabs.** The rail is a place you go; tabs are what you work in. Both now, from the same store, so clicking either moves the other. Two defects found while testing this, both worth their own line: - The engine wrote `.ssh-manager.log` beside its own source, which in a packaged app is *inside the bundle*. One file added there breaks the code signature — `codesign --verify` reports a sealed resource as invalid and Gatekeeper refuses the app. A single launch was enough. The desktop shell now points the log and the history at its userData directory; the npm default is untouched. `verify-mac-build.sh` already catches this, which is how it was found. - node-pty's tarball ships `spawn-helper` as 644, and without the bit every local shell dies on `posix_spawnp failed` — in a packaged build only. Restored by an afterPack hook, before signing, because it is a nested Mach-O that has to be signed as one. Checked by verify-mac-build.sh now too.
The README said 'pick a machine' and the roadmap said node-pty is 'for spawning a local shell, which this project never does'. Both were true this morning. What has not changed is the part worth keeping straight: the engine still has no native dependency and an npm install still needs no compiler.
Reported: the wizard comes back on every launch. It is worse than the wizard — *every* preference was being forgotten, and for a reason that has nothing to do with the wizard. The control plane binds port 0, so the operating system picks a free port and the page is served from `http://127.0.0.1:<a different port>` each launch. `localStorage` is scoped to an origin. So the introduction reappeared, the rail re-expanded, the theme reset to system, folded categories unfolded and the migration banner came back — six preferences, all silently discarded, all looking like six separate bugs. They live in the control plane now, beside the vault in `preferences.json`, behind `GET`/`PUT /api/preferences`. The PUT merges rather than replaces, so one screen remembering something cannot erase another's. `localStorage` is still written, demoted to a cache: it makes the first paint right while hydration is in flight, and it is what remembers on the one origin that is stable — a browser pointed at a long-lived `ssh-manager control`. The keys are unchanged, so an existing browser profile carries its settings over instead of resetting once more. `main.tsx` hydrates before importing `App`, which is why that import is now dynamic: the stores snapshot their initial state while their module is evaluated, so a preference arriving afterwards arrives too late — the rail would expand and snap shut, the theme would flash. `inlineDynamicImports` keeps the bundle a single file, which the control plane's two-file allowlist requires. Proved end to end rather than by inspection, in `scripts/test-preferences-persist.mjs`: close the introduction on one plane, start a second one **on a different port** against the same directory, and it does not come back — including with that origin's localStorage emptied, which leaves only one place the answer can be coming from. Restarting on the same port would have proved nothing, since that is the case that already worked.
| assert.ok(!text.includes(secret), | ||
| `the ${format} export leaked "${secret}" — an export is a file people email`); | ||
| } | ||
| assert.ok(text.includes('p.example.com'), `the ${format} export must still carry the host`); |
| assert.equal(path.basename(item.name), item.name); | ||
| const file = path.join(directory, item.name); | ||
| assert.equal(fs.statSync(file).size, item.size); | ||
| assert.equal(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), item.sha256, `Artifact changed after validation: ${item.name}`); |
| let id=1; const pend=new Map(); const events=[]; | ||
| ws.addEventListener('message',e=>{const m=JSON.parse(e.data); | ||
| if(m.method){events.push(m); return;} | ||
| const w=pend.get(m.id); if(w){pend.delete(m.id); w(m.result);}}); |
| await send('Input.dispatchMouseEvent',{ type:'mouseReleased',x:from.x,y:from.y,button:'left' }); | ||
| } else { | ||
| const data = intercepted.data; | ||
| console.log(` ✓ glisser natif reconnu — ${data.items.length} élément(s), types: ${data.items.map(i=>i.mimeType).join(', ')}`); |
| await cdp.send('Page.navigate', { url }); | ||
| await sleep(1500); | ||
| const { exceptionDetails } = await cdp.send('Runtime.evaluate', { | ||
| expression: view.awaitPromise ? `(async () => { ${view.reach} })()` : view.reach, |
|
|
||
| import fs from 'fs'; | ||
| import os from 'os'; | ||
| import path from 'path'; |
| // decide on. Runs in the foreground until Ctrl-C. | ||
|
|
||
| import fs from 'fs'; | ||
| import os from 'os'; |
| // Starts the approval socket the engine talks to and a local page to watch and | ||
| // decide on. Runs in the foreground until Ctrl-C. | ||
|
|
||
| import fs from 'fs'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
V4 adds an optional desktop/browser workspace while keeping existing npm and CLI installations usable without an interface. Existing
.env, TOML and process-environment configurations remain supported; vault import is voluntary and preserves the original files for rollback.The shared engine now reloads configuration safely, preserves fields on partial edits, centralizes remote approval checks, verifies actual SSH host keys and stores groups outside signed application bundles. Vault recovery is encrypted and atomic, can repair a damaged key file after confirmation, and leaves local connection cleanup available during recovery. The interface adds illustrated onboarding, keyboard accessibility, persistent preferences and clear connection status; file transfers preserve the currently displayed folders.
Validation on candidate
8c79eda9b458dd1375f8782e316fd44dfce68c0d:.deband preserves Chromium sandboxing. The disposable preview supports real local SSH/SFTP fixtures and restart persistence.Final test matrix, including the workflow follow-up · Desktop rehearsal, publication disabled.
The desktop rehearsal passes on all three platforms. Downloaded manifest files and artifact hashes are verified; the signed/notarized macOS candidate also passes UI/native-terminal startup and signature verification before and after local use.
Follow-up
e41b837excludes colliding builder diagnostics from artifact uploads and clarifies older downloads. It changes only the workflow and final-test guide; the packaged runtime tree is identical to8c79eda. The follow-up CI is fully green.This draft prepares the maintainer’s final test. Windows stable publication still requires Authenticode credentials. AppImage startup and a true desktop auto-update are separate checks; there was no desktop installer/feed in public 3.8.5. No npm release, tag, Homebrew promotion or MCP Registry publication is part of this PR.
Review instructions:
docs/FINAL-TEST-V4.md,docs/MIGRATION.md,docs/TESTING-V4.mdanddocs/DISTRIBUTION.md.