Skip to content

V4 candidate: optional desktop workspace with safe npm upgrades - #80

Draft
bvisible wants to merge 99 commits into
mainfrom
codex/v4-release-readiness
Draft

bvisible wants to merge 99 commits into
mainfrom
codex/v4-release-readiness

Conversation

@bvisible

@bvisible bvisible commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Status — 2026-10-08: 4.0.0-beta.2 published (GitHub only), 3.8.6 on npm


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:

  • 44 suites pass on Node 18, 20, 22 and 24; lint, typecheck and workflow validation pass.
  • 11 Chromium end-to-end flows plus actual registry 3.8.5 → packed V4 → 3.8.5 rollback run on macOS, Windows and Linux. The rollback checks preserve all 37 MCP tool contracts and three configuration sources. All three operating systems pass, including the actual Windows upgrade/rollback.
  • Desktop builds run without publishing, verify the native terminal and interface, and finalize artifact hashes after signing. macOS requires Developer ID/notarization; Linux smoke installs the actual .deb and 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 e41b837 excludes 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 to 8c79eda. 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.md and docs/DISTRIBUTION.md.

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.
Comment thread tests/test-v4-migration.js Fixed
Comment thread tests/test-v4-migration.js Fixed
Comment thread tests/test-portability.js Fixed
Comment thread scripts/publish-desktop.mjs Fixed
Comment thread scripts/test-drag-and-drop.mjs Dismissed
Comment thread scripts/test-drag-and-drop.mjs Dismissed
Comment thread scripts/capture-screenshots.mjs Dismissed
Comment thread cli/control.js Fixed
Comment thread cli/control.js Fixed
Comment thread cli/control.js Fixed
bvisible and others added 14 commits September 7, 2026 10:49
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.
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.
Comment thread src/logger.js Dismissed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants