Skip to content

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

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

V4 candidate: optional desktop workspace with safe npm upgrades#80
bvisible wants to merge 78 commits into
mainfrom
codex/v4-release-readiness

Conversation

@bvisible

@bvisible bvisible commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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.
**Nine of the file browser's fifteen context-menu actions had no handler.** The
menu component declares onOpen, onRename, onDelete, onTransfer, onRefresh,
onCreateFolder, onCreateFile, onCompress, onExtract, onPreview, onInspect,
onShowInFinder, onOpenTerminal, onOpenInEditor and onOpenInCode; FilesPage was
passing six of them. Audited rather than assumed — the list above is generated
from the two files.

The good news is that nothing was *dead*: every unhandled action is already
guarded on its own prop, so it hides rather than rendering a menu item that
does nothing. Verified by right-clicking a file for real: five entries, all of
them working.

Three are now backed:

- **New file**, which needed a route — the remote side has /api/files/write but
  the local side had read and no write. /api/local/touch opens with 'wx', so it
  fails on an existing file rather than silently emptying it.
- **Show in Finder**, local only, through the reveal route that already existed.
- **Open terminal**, remote only, opening this application's own shell rather
  than handing off to Terminal.app — and raising the existing tab for that
  server if there is one.

Compress, Extract, Quick Look, Get info and the two editor actions stay hidden.
They need work I have not done, and a menu item that lies is worse than one that
is absent.

**Options gets a rule above it.** Measured at six window heights, with and
without open shells: it is always rendered and always on screen. It was still
being missed, because a lone unlabelled grey gear at the far end of a 48px rail
reads as decoration rather than a destination. The rule is the part the eye was
missing; the layout never was the problem.
Committed the previous change with a failing typecheck — the JSDoc union on
#localOp still listed four operations after a fifth was added, and tsc caught
both the call site and the comparison. The 0-error baseline is a gate for a
reason; I should have read the exit code before pushing.
Drag and drop works, and I could not prove it until today because the way I was
testing it could not fail. A hand-made DragEvent carries its own empty
DataTransfer, so the drop handler ran, found nothing, and did nothing — while
the check looked at the wrong pane and found the word it was hoping for. Two
bugs agreeing is not a passing test.

Chrome can intercept its own native drag: `Input.setInterceptDrags` turns a
genuine mouse drag into an event carrying the real drag data, which
`Input.dispatchDragEvent` then delivers at the target. Everything the browser
would have done, it does. Kept as scripts/test-drag-and-drop.mjs, with the
filesystem as ground truth rather than the screen.

That immediately found the bug this commit fixes. Every folder row is a drop
target — FilePaneRow passes its path, FilePane resolves it into `dropDir`, and
the whole chain works — but FilesPage's `onDropFiles` took one argument and
threw the second away, then recomputed the destination as the other pane's
current directory. So a file dropped onto `backups/` landed beside it. Every
folder on screen was a drop target that quietly did the wrong thing, and it
looked like it had worked.

Verified on disk both ways: before, `/srv/runbook.md`; after,
`/srv/backups/runbook.md`.

Two limits found on the way and left alone, both honest:

- dragging a directory is refused with "Directories cannot be transferred yet",
  which is true — it needs a recursive walk on both sides
- the drag payload's mime type is still `application/x-transhub-files`, from
  where these components came
Dragging a file onto the application icon did nothing, and could not have: the
bundle declared no document types, so macOS refused every drop and `open-file`
never fired. It declares `public.item` now — this application does not open
documents, it sends them somewhere, so narrowing by extension would only make it
refuse the files people actually have.

The interesting half is the question you asked: which terminal, a new one, or a
remote already open? There is no sensible default. "The last server" is wrong
the first time and whenever two are open, and guessing silently is how a file
ends up somewhere nobody expects. So it asks — and the answers are ordered by
what is already in front of you: a machine whose files you are looking at, then
one you have a shell on, then the rest. Each row says where the file will land
before you pick it, and afterwards the file browser for that machine opens so
you can see that it did.

Three things that would each have made this half-work:

- macOS delivers `open-file` *before* `whenReady` when the drop is what launched
  the app. Paths are collected and flushed once there is something to flush them
  into, rather than dropped on the floor.
- Several files arrive as several events in quick succession, so they are
  coalesced: one question about five files, not five questions.
- The main process had no way to tell the page anything. ControlPlane.announce()
  is that door, and deliberately the only one — everything else the page needs,
  it fetches.

Verified end to end in the browser, since the Dock path only exists in the
packaged app: with SSH_MANAGER_DEMO_DROP set, the demo announces the same event
the desktop shell sends, and the dialog appears with the right files and the
right destinations. The Dock gesture itself still needs a packaged build to try.
… queue

**Somebody opening this for the first time saw an empty list and a rail of
icons**, and had to work out from that what the application is for. The screens
explain themselves once there is something in them; before that they explain
nothing.

Three panels, because there are three things worth saying: what this watches,
how to give it something to watch, and the one setting that changes what an
agent is allowed to do. It appears only when no servers are configured, and
never again once closed — an introduction that keeps introducing itself is a
nag. Ending it lands you on Servers, which is where the next thing to do is.

**The Waiting screen explained where approval is configured and then left you
there.** "It is set on the server" is an instruction with no door attached, to
somebody who is looking at an empty queue precisely because they have not found
that door. It now says what pausing does in plain terms and offers the button.

Both driven with real mouse events, kept as scripts/test-first-run.mjs. The
first run of that test reported three failures, all of them its own: it clicked
"Add a server" on the page *behind* the modal, because that one comes first in
the DOM. Clicks are scoped to the dialog now. Worth writing down — a test that
finds the wrong element reports a product bug that is not there, which costs
exactly as much as a real one.
The README had been describing a version nobody was running: the screenshots
predated the unified headers, Nora's orange, the collapsed rail and the Terminal
screen, and the video predated all of that plus the shell working at all.

Twelve screenshots regenerated — six screens in both wrappers — plus a new pair
for Terminal, and the video re-recorded with a scene for it. 24.7 seconds, nine
scenes, still under 300 KB in each format.

Two things in the rig needed fixing first, both consequences of the rail now
being collapsed by default:

- the recorder matched rail items by their visible text, which a collapsed rail
  does not have. It matches the accessible name first now, as the screenshot
  script already did.
- a stale demo process was holding the port, so the recorder was driving a page
  that had never loaded and blaming the second scene for it. Worth remembering:
  "nothing to click" from these scripts usually means the demo, not the button.

This is the fourth time today that regenerating images has been the last step
after a change, and the second time the rig needed a fix to do it. It is still
far cheaper than the alternative, which is a README that quietly stops being
true.
The application is notarized. `spctl` says `accepted / source=Notarized
Developer ID` on a copy carrying the quarantine attribute a real download from
Safari would have, which is the only test that means anything: a locally built
file has no quarantine attribute and passes whatever you do.

One step was missing and would have shipped a worse artifact. electron-builder
notarizes the `.app` *before* packaging it into the disk image, so Apple has no
ticket for the DMG and `xcrun stapler staple` on it fails with "Could not find
base64 encoded ticket in response". The app inside is notarized either way and
Gatekeeper accepts it — but with no ticket on the DMG, opening the download
offline makes Gatekeeper phone home, and on a bad connection that is a spinner
in front of somebody's first run. The DMG is submitted separately now and the
command is written down.

verify-mac-build.sh checked the app and said nothing about the DMG, which is the
file people actually download. It checks both.
TransHub does this against an update server of its own. SSH Manager does not
need one: the repository is public, the releases already live there, and
electron-updater reads `latest-mac.yml` straight out of the release assets that
electron-builder writes beside the installers. Nothing to run, nothing to pay
for, nothing else that can be down.

**Offered, never applied.** This application holds SSH connections and the
approval socket an agent may be blocked on. Pulling 120 MB down somebody's
tethered connection because a release happened, or restarting under them while a
transfer runs, are both worse than being a version behind for an afternoon. So
it checks quietly, says so in the menu bar — "Update to 4.1.0" — and downloading
is a click. Installing happens when the application is closed anyway.

A failed check says nothing at all: the network is down, or GitHub is, and
neither is news. It tries again in six hours.

release-desktop.yml builds macOS and Windows on a tag, notarizes, submits the
DMG separately for its own ticket, runs verify-mac-build.sh as a gate, and
uploads to the release. Deliberately not part of release.yml: that one needs an
OTP somebody types, this one needs Apple to be having a good day, and coupling
them means a notarization queue blocks a package release.

Checked what bit the packaging last time: electron-updater is a runtime
dependency, and `files` in electron-builder.yml lists only main.js and two
icons — electron-builder adds production dependencies regardless, verified by
extracting the asar (16 modules) and starting the packaged app.

Six secrets are needed before it can sign anything: MAC_CERT_P12,
MAC_CERT_PASSWORD, APPLE_API_KEY (base64 of the .p8), APPLE_API_KEY_ID,
APPLE_API_ISSUER. Without them the workflow still builds, and produces something
unsigned that is useful for a dry run and useless to ship.
Debris from checking that electron-updater actually made it into the package —
the check was right, the cd before it was not, so it extracted into the source
tree instead of the scratch directory. Gitignored so the next inspection cannot
do it again.
The import existed and could not be found. Seven format readers, thirteen tests,
a hundred and eleven servers readable out of Transmit on this machine — all of
it behind `ssh-manager import` in a terminal, which is not where somebody
looking at an empty server list goes. I noted the gap once in passing and then
moved on, which is worse than not having noticed: a feature nobody can find is a
feature that is not there.

It is now in three places, which is where it was always going to be needed:

- **Servers** — an Import button beside Add, always, not only when the list is
  empty. The empty state's own Import button has existed since these components
  came over from TransHub and was never given a handler.
- **The introduction** — step two said "import yours" and printed a shell
  command. It has the button now.

The dialog leads with what is already on this machine, probed and counted, and
the count is the point: "111 servers" is a reason to press it, "Transmit" on its
own is a question. A file is the other way in, for a spreadsheet somebody sent
or a tool not installed here. Nothing is written before it has been shown —
import is the one operation where finding out afterwards means forty rows under
the wrong names.

Three routes behind it: /api/import/sources probes the machine,
/api/import/preview reads without writing, /api/import/apply writes back exactly
the list that was confirmed rather than re-reading the file.

**And a real bug, found only because the test checked the vault instead of the
screen.** The dialog said "Import 111 servers", the click registered, and
nothing was written: #readJsonBody capped bodies at 8 KB and *destroyed the
socket* rather than answering, which reaches the browser as "Failed to fetch"
with no status and no message. Every other route sends a handful of fields;
confirming a hundred servers lands around 20 KB. The cap is 1 MB now and it
replies 413, so the next thing to hit it says why.
Building, signing and notarizing are the parts worth rehearsing, and there is no
way to rehearse them without a tag — which would mark a release nobody has
decided to cut. A publish input, off by default, makes electron-builder stop at
the artifacts; they are uploaded either way, so a dry run still hands back
something installable.

The ref input takes a branch as well as a tag, which is what makes a dry run
possible at all.
Two shelled-out binaries that only exist on Unix, three lines apart.

`execFileSync('npm', …)` does not consult PATHEXT, and on Windows npm is
`npm.cmd` — so every Windows build died with `spawnSync npm ENOENT` before
installing a single dependency. This is not only a CI problem: nobody could
build the app there at all.

`du -sh` for the size line was the same mistake one line further down. Counted
in Node now, which needs no coreutils and gives apparent size rather than
allocated blocks.
Both jobs failed in two minutes, for two mistakes of mine that hid each other.

A step's `if:` cannot read that same step's `env:` block, so
`env.MAC_CERT != ''` was always false and both the certificate import and the
key-writing step were skipped silently. The secrets now sit in a job-level
`env:`, which step conditions can actually see.

Then `format('{0}/private_keys/AuthKey.p8', env.HOME)` — `env.HOME` is empty
inside `${{ }}`, which reads the workflow's env and not the runner's. That
produced the absolute path `/private_keys/AuthKey.p8`, and notarytool said so.
`runner.temp` resolves in every expression context.

While here: verify every .app the build produced, not just the arm64 one. An
unnotarized x64 build ships exactly as broken and nothing else would notice.
The file no longer parsed: the `runner` context is not available in a job-level
`env:` block, and GitHub rejects the whole workflow rather than the one line —
`Unrecognized named-value: 'runner'`.

The plain environment variable is present in every `run:`, and the one place
that genuinely needs the expression, the Build step's `env:`, is step level
where `runner.temp` does resolve.

Checked with actionlint this time, which reports this exact error offline in a
second and would have caught both of the last two attempts.
…r finds

A YAML parser accepts workflows GitHub then refuses. Expression contexts are
only valid in some positions, and using one where it does not exist is rejected
at dispatch — after the push, not before it. The desktop release lost three runs
to exactly that: a step `if:` cannot read its own `env:`, `env.HOME` is empty
inside `${{ }}`, and `runner` does not exist in a job-level `env:`.

actionlint reports all three offline in about a second, so it belongs in the
lint job, which is a required check. Pinned by version and verified by sha256
rather than pulled from a floating tag, like everything else here.

Two shellcheck findings had to go first, both real if minor: an unused loop
counter in the Homebrew step (it is a retry budget, not an index) and four
appends to the same file where a heredoc says it once.
`npm.cmd` fixed the ENOENT and bought an EINVAL: Node has refused to spawn a
.bat or .cmd without a shell since the fix for CVE-2024-27980. Two failed
builds, two different errors, same wrong idea — that the way to run npm is to
find its executable.

`npm_execpath` is npm's own CLI script, set by the `npm run build:win` that got
us here. Running it with the Node we are already inside needs no shell, no file
extension and no PATH lookup, and it is the same npm that invoked us rather
than whichever one PATH happens to name. The fallback, for someone running this
file directly with `node`, is the only place a shell is used, and only on
Windows.

Both paths run here: via `npm run prepare-engine`, and with npm_execpath unset.
Tested on the packaged, notarized 4.0.0: dropping a file on the icon of a
*running* app asks where to send it; dropping one on the icon of an app that is
not running launches it and the file vanishes. The drop is the whole gesture,
and it only worked when the app was already open.

`announce()` writes to the pages currently listening, and on a cold start there
are none — the drop is what launched the application, so the event is ready a
second or two before the interface has connected its stream. The flush timer in
the shell could be lengthened, but any number picked there is a guess about
someone else's machine. So an announcement made to nobody is held instead, and
handed to the first page that connects: once, and only within 30 s, so nobody
is asked about a file they dropped in another session.

The introduction now steps aside for a drop. Someone who dropped a file asked
for something; a first-run tour is not an answer to it. It comes back when the
question is dealt with.

Also: the asar check in verify-mac-build.sh never ran. `asar` belongs to
electron-builder and lives under desktop/electron, so `npx --no-install asar`
from the repository root found nothing and the check degraded to a warning —
silently, in CI too. It is the check that catches a missing menu-bar glyph,
which would throw inside `new Tray` and take the app down at launch.
`state.subscribe()` opened its own EventSource every time, and there are eight
subscription sites. A browser allows six concurrent HTTP/1.1 connections per
origin, so with enough of the interface mounted the page runs out of sockets
and can no longer fetch anything — the failure would look like the application
freezing for no reason.

It also broke the fix one commit ago. An announcement held for a page that had
not connected yet is handed to the first connection that opens, and with eight
of them that was whichever component happened to subscribe first — never the
one listening for the drop. Verified against the packaged app: dropping two
files on the icon of an app that is not running now opens "Send 2 files where?
runbook.md, notes.txt", where before the drop vanished.

One connection, a set of handlers, closed when the last one leaves. All the
handlers of a React commit are registered before any frame can arrive over the
network, so nothing is lost in the gap.
The built interface is committed because it ships in the npm package — the
engine serves it. The two previous commits changed App.tsx and api.ts without
it, which would have published a stale bundle: no shared event stream, and the
introduction still covering a drop.
Three things that were missing from the terminal screen: you could not open a
shell here, you could not open two shells anywhere, and the sessions you had
were only in the rail.

**A local shell.** The rail's own comment admitted it — "Removed: … the local
terminal" — and the screen said "No servers yet" to somebody who just wanted a
prompt. A pseudo-terminal needs `forkpty`, which needs a native module, and the
engine has none and is not getting one: it is published on npm and installs on
machines with no compiler. So the desktop shell hands one down instead, through
`setLocalShellProvider`, the same kind of door as `announce()`. node-pty ships
N-API prebuilds that Electron loads unchanged, so nothing is compiled at build
time either. Verified on a packaged build: a real tty, the user's own zsh, 156
columns fitted to the pane. Where no host offers one — `ssh-manager control` in
a browser — the control plane answers 501 and the interface asks first, so the
option is not shown rather than shown and broken.

**Several shells.** A row still raises the shell it already opened, because
that is what a click on it means, but a `+` on the row opens another and the
local machine always opens a new one. Titles number themselves: "prod",
"prod 2" — three tabs all called "prod" is three tabs you must open to tell
apart.

**Tabs.** The rail is a place you go; tabs are what you work in. Both now, from
the same store, so clicking either moves the other.

Two defects found while testing this, both worth their own line:

- The engine wrote `.ssh-manager.log` beside its own source, which in a packaged
  app is *inside the bundle*. One file added there breaks the code signature —
  `codesign --verify` reports a sealed resource as invalid and Gatekeeper
  refuses the app. A single launch was enough. The desktop shell now points the
  log and the history at its userData directory; the npm default is untouched.
  `verify-mac-build.sh` already catches this, which is how it was found.
- node-pty's tarball ships `spawn-helper` as 644, and without the bit every
  local shell dies on `posix_spawnp failed` — in a packaged build only. Restored
  by an afterPack hook, before signing, because it is a nested Mach-O that has
  to be signed as one. Checked by verify-mac-build.sh now too.
The README said 'pick a machine' and the roadmap said node-pty is 'for
spawning a local shell, which this project never does'. Both were true this
morning. What has not changed is the part worth keeping straight: the engine
still has no native dependency and an npm install still needs no compiler.
Reported: the wizard comes back on every launch. It is worse than the wizard —
*every* preference was being forgotten, and for a reason that has nothing to do
with the wizard.

The control plane binds port 0, so the operating system picks a free port and
the page is served from `http://127.0.0.1:<a different port>` each launch.
`localStorage` is scoped to an origin. So the introduction reappeared, the rail
re-expanded, the theme reset to system, folded categories unfolded and the
migration banner came back — six preferences, all silently discarded, all
looking like six separate bugs.

They live in the control plane now, beside the vault in `preferences.json`,
behind `GET`/`PUT /api/preferences`. The PUT merges rather than replaces, so one
screen remembering something cannot erase another's. `localStorage` is still
written, demoted to a cache: it makes the first paint right while hydration is
in flight, and it is what remembers on the one origin that is stable — a browser
pointed at a long-lived `ssh-manager control`. The keys are unchanged, so an
existing browser profile carries its settings over instead of resetting once
more.

`main.tsx` hydrates before importing `App`, which is why that import is now
dynamic: the stores snapshot their initial state while their module is
evaluated, so a preference arriving afterwards arrives too late — the rail would
expand and snap shut, the theme would flash. `inlineDynamicImports` keeps the
bundle a single file, which the control plane's two-file allowlist requires.

Proved end to end rather than by inspection, in
`scripts/test-preferences-persist.mjs`: close the introduction on one plane,
start a second one **on a different port** against the same directory, and it
does not come back — including with that origin's localStorage emptied, which
leaves only one place the answer can be coming from. Restarting on the same port
would have proved nothing, since that is the case that already worked.
Comment thread tests/test-v4-migration.js Fixed
Comment thread tests/test-v4-migration.js Fixed
Comment thread tests/test-portability.js
assert.ok(!text.includes(secret),
`the ${format} export leaked "${secret}" — an export is a file people email`);
}
assert.ok(text.includes('p.example.com'), `the ${format} export must still carry the host`);
assert.equal(path.basename(item.name), item.name);
const file = path.join(directory, item.name);
assert.equal(fs.statSync(file).size, item.size);
assert.equal(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), item.sha256, `Artifact changed after validation: ${item.name}`);
let id=1; const pend=new Map(); const events=[];
ws.addEventListener('message',e=>{const m=JSON.parse(e.data);
if(m.method){events.push(m); return;}
const w=pend.get(m.id); if(w){pend.delete(m.id); w(m.result);}});
await send('Input.dispatchMouseEvent',{ type:'mouseReleased',x:from.x,y:from.y,button:'left' });
} else {
const data = intercepted.data;
console.log(` ✓ glisser natif reconnu — ${data.items.length} élément(s), types: ${data.items.map(i=>i.mimeType).join(', ')}`);
await cdp.send('Page.navigate', { url });
await sleep(1500);
const { exceptionDetails } = await cdp.send('Runtime.evaluate', {
expression: view.awaitPromise ? `(async () => { ${view.reach} })()` : view.reach,
Comment thread cli/control.js

import fs from 'fs';
import os from 'os';
import path from 'path';
Comment thread cli/control.js
// decide on. Runs in the foreground until Ctrl-C.

import fs from 'fs';
import os from 'os';
Comment thread cli/control.js
// Starts the approval socket the engine talks to and a local page to watch and
// decide on. Runs in the foreground until Ctrl-C.

import fs from 'fs';
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.

2 participants