Repository navigation
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.
An operator reading their own server logs cannot tell a session this tool drives from one a person drives: both present the same key and sshd records the same username. This sends AI_AGENT=mcp-ssh-manager on each channel that carries a command, so the distinction is available to anything already reading those logs. SSHManager opens five channels. Three announce — execCommand, execCommandStream and requestShell — covering ssh_execute and every tool routed through it, ssh_tail --follow, and ssh_session_*. Two do not. getSFTP is excluded deliberately: ssh2 calls reqEnv with a callback there, so want_reply is 1 and a server without AcceptEnv fails the whole transfer, unlike exec and shell where the request is fire-and-forget. forwardOut cannot, as direct-tcpip channels have no env request. ssh_sync runs rsync over the system ssh binary and would need -o SetEnv instead. requestShell passes the announcement as ssh2's third argument rather than folding it into the existing options object. ssh2 reassigns opts = wndopts and drops wndopts when the first object carries env, which would discard term, cols, rows and modes — including modes.ECHO = 0, which the session marker protocol depends on. That failure would pass every existing test. announce_agent / SSH_SERVER_[NAME]_ANNOUNCE_AGENT opts out per server, defaulting to true. AcceptEnv governs what a server stores, not what a client sends, so the request reaches every host either way; since command output returns to a model, a host worth distrusting learns something actionable. The default-on flag needs its own parser, because parseBool treats anything unrecognised as false and would opt every server out. Tests: six channel checks in test-agent-announce.js, plus announceAgent coverage in test-config-field-names.js for both loaders and both export round trips. Each was verified by breaking the matching production line on its own and confirming the suite fails.
Adapts the AI_AGENT announcement contributed in the previous commit to V4. Two changes of substance, one extension, and the tests to hold all three. Off unless chosen. The contribution turned it on for every server, which would break the promise V4 makes to anyone upgrading from 3.8 — that nothing reaching their servers changes on its own. It also reaches every host whatever its AcceptEnv (sshd declines to store it, it still receives it), so a host the operator does not control would learn an agent is on the other end and could shape its output for one. The operators who benefit are the ones who configured AcceptEnv AI_AGENT; they can also flip one switch: `announce_agent` per server, or SSH_MANAGER_ANNOUNCE_AGENT for all of them. The per-server value is now three-state so an explicit false keeps one host silent under the global switch, and both explicit values survive an export round trip while an unset one is never written out as a choice. Never for a person. In V4 the control plane opens its own connections for a shell, a file browser, a health probe and a command across a group. Ported as it was, each of those clicks would reach the server labelled as an agent — the exact confusion the convention exists to end. connectServer() now takes the actor, the control plane passes `actor: 'human'` on all five calls, and a wiring test fails if a sixth is added without it. ssh_sync too. It drives rsync through the system ssh, not ssh2, so it gets the announcement via SendEnv (OpenSSH 3.9+) rather than the SetEnv the contribution suggested (7.8+): someone who opted in on an older client should lose a label, not a working sync. Verified against a real OpenSSH with AcceptEnv AI_AGENT: nothing arrives by default; exec, interactive shell (pty size intact) and ssh+SendEnv carry it once chosen; a per-server false and a human action both stay silent under the global switch. Six mutations — default on, the per-server false ignored, the human marker dropped, one control-plane call unmarked, SendEnv unwired, the env folded into the pty options — each fail the suite. test-working-directory's ssh2 stub gains the three-argument exec, as the contribution already did for test-sudo-stdin.
…iness # Conflicts: # package.json
hono reaches the engine through the MCP SDK's HTTP transport, which the stdio server never loads — but the lockfile is what the desktop build installs, so 4.13.4 would have shipped inside the application and in every scanner's report on it. Three advisories (GHSA-gqvv-2mrq-wpjv, GHSA-g6gw-c38x-mqfc, GHSA-crvj-82cr-hjcx), all fixed within the SDK's own ^4.11.4 range, so nothing but the lockfile changes. npm audit --omit=dev is now clean for the engine, the desktop shell and the interface.
Every manifest moves to 4.0.0-rc.1 (package, lock, desktop, server.json), which release-version.mjs maps to a prerelease: npm `next` if it is ever published there, a GitHub prerelease, and no Homebrew or MCP Registry update. This one is not going to npm at all — nobody on 3.8.5 receives it. The documents now say what a 3.8 user needs to know, in the order they need it: - The one behaviour that tightens, first: 3.8.5 never compared host keys, V4 does. A table of the four cases, the exact refusal, how to check a rebuilt server's fingerprint before removing its old entry, and SSH_MANAGER_KNOWN_HOSTS for anyone who would rather V4 kept its own file. - How the stable release will reach them without being asked: a routine `npm update -g` crosses major versions (checked, not assumed), and an MCP client running `npx mcp-ssh-manager` unversioned starts the latest. How to pin 3.8 if they prefer to wait. - Trying the preview without touching their installation: a side-by-side npm prefix, a second MCP entry, and the way back. - A step-by-step path to the stricter setup V4 makes possible — vault, a checked recovery backup, removing clear-text secrets only after that, modes, approval (which refuses when the application is closed), the record, and the AI_AGENT announcement. - Per platform, what the download is: macOS signed and notarized; Windows NOT code-signed in this preview, with what Windows will say; Linux packages. The September audit's local links pointed at /Users/…, broken for anyone else; they are relative now, and internal audit records no longer ship in the npm tarball. Verified on this commit: 46 suites (438 checks), lint, typecheck, the committed interface identical to a fresh build, and the registry upgrade 3.8.5 → 4.0.0-rc.1 → 3.8.5 passing all eight checks.
knip, which Code Quality runs, flagged them: both are only used inside the module. What callers need — rsyncAgentAnnouncement() — stays exported.
eslint's js-yaml 4.3.1 carries a high-severity advisory (merge keys do not bound CPU use). It is a development dependency — nothing ships it, neither the npm package nor the desktop build — but a release should not arrive with an open high alert, and the fix sits inside eslint's own range.
…pted The first publishing run of 4.0.0-rc.1 failed on all three platforms at the same step, before anything was uploaded — which is what that step is for. finalize-artifacts expected `rc-mac.yml`, `rc.yml` and `rc-linux.yml`; electron-builder wrote `latest-mac.yml`, `latest.yml` and `latest-linux.yml`. For the GitHub provider it always writes the `latest` channel, prerelease or not (app-builder-lib computeChannelNames), because a prerelease is expressed by the GitHub release flag. electron-updater agrees: a prerelease build asks for `<channel>-mac.yml` and falls back to `latest-mac.yml` on a 404 (GitHubProvider, 6.8.9). The script now expects exactly the one file each platform produces. Its test had invented `rc-mac.yml` and agreed with the script — two wrongs that passed together, as synthetic fixtures do. Reading electron-updater to settle that turned up the larger problem: `rc` would have stranded every tester. An alpha or beta build takes the newest release in the feed, stable included; any other prerelease name is a custom channel that only follows its own kind. A 4.0.0-rc.1 would have updated to rc.2 and then never to 4.0.0. So the preview is 4.0.0-beta.1, and finalize-artifacts refuses a desktop prerelease named anything but alpha or beta. Stable installations are unaffected either way: they read /releases/latest, which never lists a prerelease. Nothing had been published under rc.1: its draft release and tag were removed before this commit.
The Windows release smoke test went from 3 s on 2026-09-07 to 40 s, then to 47 s and a kill at its 45 s limit, on code that had not changed between the last two runs. The kill surfaced as EPERM from the launcher's cleanup — the application's helpers still held the scratch directory — which replaced the actual error. So first the smoke test learned to say where its time goes, and the launcher to stop hiding its reason. Timed on macOS, the application passed every check in under a second and then, in two launches out of four, never exited. Quit is intercepted by a before-quit handler that awaits ControlPlane.stop(), and stop() awaited server.close() on its three servers. close() only stops a server accepting; it then waits for every connection it already has to end. An MCP engine stays connected to the approval socket for as long as it runs, and one publishing output stays on the live-stream socket; a page's requests can outlive the page. Any of them held the application open after Quit — which is the normal situation whenever an agent is using it. Reproduced in isolation: stop() never returned with one idle engine connected. Every connection each server accepts is now tracked and ended once the servers stop accepting. An engine waiting on a decision is still answered `deny` before its socket goes, which is the fail-closed path it already expects. After the fix, eight launches of the packaged app out of eight exit in 0–3 s. - test-control-plane: stop() with engines on both sockets and a page listening. Removing the fix fails it in 4 s; it disconnects before asserting, so a regression fails rather than hanging the suite on cleanup. - smoke-test.mjs: timestamped stages, to stdout and to a file that survives a killed process; the kill path logs before calling kill(), since a ConPTY teardown is the one step that could block outright. - smoke-desktop.mjs: a 120 s launch limit (each stage keeps its own), reports whether the app hung inside a check or while quitting, kills the whole process tree on Windows, and never lets cleanup mask the failure.
PR #80's CodeQL check reported 15 new alerts, 7 of them high. Each was read in context; the real ones are fixed here with a test that fails without the fix, and the rest are dismissed with a reason on the alert itself. Fixed: - Group names in a prototype-less map (js/remote-property-injection). Names come from users and from agents through ssh_group_manage, and an agent reads whatever a server prints. In an ordinary object '__proto__', 'constructor' and 'toString' resolved to Object.prototype: reported as already existing, and an overwrite of '__proto__' would have replaced the map's prototype. The save path had the same shape — `groupsToSave['__proto__'] = group` sets a prototype and the group silently left the file. Names stay free-form, as 3.x allowed ("Web Servers" still works). - The audit tail sized a path and then opened whatever was at it (js/file-system-race); it now sizes the open descriptor. Reading it closely turned up two more: a short read left zero bytes that reached JSON.parse, and the offset moved past a line the engine was still writing, so an entry read half-written was lost — whatever the comment beside it promised. It now stops at the last complete line. - Content-Disposition for a downloaded remote file. The flagged regex (js/regex/duplicate-in-character-class) only stripped quotes and CR/LF: a backslash escaped the closing quote, and an emoji in a filename made Node throw ERR_INVALID_CHAR mid-response. An ASCII fallback name plus filename*=UTF-8'' with the exact name (RFC 6266). - The command log trim checked, read and rewrote in place; a crash mid-write truncated the record. Read directly, write beside it, rename over it. - publish-desktop.mjs sized an artifact and hashed a second read of it; one read now backs both checks. test-notify compiled into a guessable name in the shared temp directory; now a private mkdtemp directory. Three unused imports in cli/control.js.
Engine: @modelcontextprotocol/sdk 1.32.1, and npm audit fix for fast-uri, ip-address, proxy-addr (critical, via express), smol-toml and brace-expansion. knip 6 (Dependabot #72) brings its own micromatch fix; it no longer pulls @types/node in, so the typecheck now declares it, and it reports the operating-system tools the code starts, listed in knip.jsonc. Interface: source-map-js. Desktop: undici, http-cache-semantics, fast-uri and brace-expansion, with app-builder-lib aligned on electron-builder 26.17.0. Eight moderate desktop findings remain, all sprintf-js under electron-builder's download helper: there is no fixed release upstream, and none of it ships in the application. npm audit: 0 in the engine (dev included) and the interface, 0 high in the desktop build. dist/ui rebuilt byte-identical.
Hooks (GHSA-759m-wfpq-xmx3, issue #87). Context values were spliced into the text of shell commands, and the default on-error hook put the connection error, which a server chooses, inside double quotes. Placeholders now become references to shell variables that carry the values; the default on-error hook is a `log` action written in JavaScript, with no shell. Hook settings, the log and the command history move from the installed package to SSH_MANAGER_HOME (0600), nothing is written on start, and the package no longer ships the development .hooks-config.json that turned Frappe hooks on for every npm user. Command aliases, server aliases and the active profile follow the same rule; the old files are still read until the next change. Restricted mode (GHSA-rfxw-26h6-7w42). Patterns were tested against the whole line, so `^echo ` passed `echo hi; id`. Every command in a list or pipeline must now match; DENY applies to each of them; command substitution and redirections that write a file are refused. Readonly also checks the commands inside $(…) and backticks. ssh_db_query (GHSA-q37w-vhpx-q5q9, GHSA-9w6j-vg8f-hp8g). SQL: one SELECT, no INTO or server-file functions, run inside a read-only transaction, which also stops a stored function that writes; a MySQL error no longer reads as an empty result. MongoDB: the filter must be a document (JSON or shell syntax), $where/$function/$accumulator are refused, and the collection is a string literal. ssh_deploy and ssh_backup_schedule, the same class as GHSA-qwwm-vrm9-4mw8: the remote path sat in double quotes, owner and permissions were bare, the temp name carried the local file name, and the schedule script spliced the database and paths raw. A cron schedule must now be a schedule: anything after the fifth field used to become the command cron runs. Each fix has a test that fails without it; the database ones were also checked against MariaDB 11, PostgreSQL 16 and MongoDB 5.
#88. With an agent loaded, a refused password was followed by every agent key, and OpenSSH's MaxAuthTries ended it all as "Too many authentication failures". The order is unchanged, so an agent key that works still connects; each attempt is now recorded and the error says what was refused, keeping the server's own words. A method the server does not offer is no longer sent. Checked against OpenSSH 9 with six agent keys. #89. A key path only expands a leading ~ or ~/, as a shell does; `replace('~', home)` rewrote the first tilde anywhere in the path. #90. 3.8.5's engine never read SSH_MANAGER_ENV, so a profile still exporting it towards a deleted file took an upgraded setup to zero servers, silently. A missing file named there is now skipped, as 3.8.5 skipped the variable; a missing SSH_ENV_PATH stays the choice and is said at startup, in ssh_list_servers and in "server not found".
…ixes CHANGELOG entries for 4.0.0-beta.2 and for 3.8.6, which ships the same security fixes on npm the same day. Documentation follows the code: where hooks, aliases, the profile and the logs now live and how a placeholder reaches a command; how restricted mode checks a line; which .env is read. The DMG is now signed too (dmg.sign), as planned after beta.1, where Apple's syspolicy_check judged the unsigned image not distributable; verify-mac-build.sh fails if a published DMG is not signed and accepted.
CodeQL js/file-system-race: the test checked for .hooks-config.json, then wrote it. Reading it and handling ENOENT keeps the same backup without the gap between the check and the use.
…exists CodeQL still saw a check (existsSync) before a later write of the same file. Reading it and comparing with the backup asserts the same thing.
3.8.6 backported this branch's security fixes to main by hand. Recording main as merged, without taking its tree, lets main fast-forward to V4 at 4.0.0 instead of replaying the same fixes twice.
3.8.6 is what npm users run from today. The release gate already installed 3.8.5 from the registry, upgraded to the packed candidate and rolled back; it now does the same from 3.8.6 (8/8 locally against 4.0.0-beta.2).
The lockfile never ships, so an install of ^4.4.3 has resolved to the newest 4.x for weeks; CI now tests the same zod (Dependabot #74).
…looking at Design approved in conversation: ssh-manager app and a banner in the browser interface install the application of the engine's own version from its release (SHA-256 from the release manifest, Apple signature and team on macOS), then open it and hand the approval socket over; the CLI opens on a status home screen in the application's palette, every existing number kept.
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.
Status — 2026-10-08: 4.0.0-beta.2 published (GitHub only), 3.8.6 on npm
ssh_db_query,ssh_deploy/ssh_backup_schedule; host keys andcwdwere already here) and the issues found by using V4 internally: Hooks config and log file live inside the installed package: lost on upgrade, log readable by all users #87, Password servers: after a refused password every agent key is tried, and the error says only "Too many authentication failures" #88, KEYPATH: keyPath.replace('~', homedir) replaces the first tilde anywhere, not a leading ~/ #89, v4: SSH_MANAGER_ENV / SSH_ENV_PATH naming a missing file silently loads zero servers #90. Dependencies refreshed (npm auditclean, dev included).syspolicy_check distributionpasses.mainwas recorded as merged here (296dfca, tree unchanged), somaincan fast-forward to this branch at 4.0.0.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.