sshelf documentation
sshelf is a TUI for managing and connecting to SSH hosts. It keeps its own host database
and generates the correct ssh command for each node — it never edits ~/.ssh/config.
Docs-in-sync rule: every code/behavior change updates the relevant doc here in the same change, and appends to
progress.md. See../CONTRIBUTING.md.
Contents
| Doc | What it covers |
|---|---|
| progress.md | Living log — current milestone, what changed, what’s next. Start here. |
| architecture.md | How the pieces fit: launcher/exec() model, askpass flow, secret store, data flow. |
| structure.md | Module/file map and responsibilities. |
| data-model.md | Host schema, config/state files, on-disk locations & formats. |
| ssh-command.md | Flag mapping (-i/-p/-J/extra-args) and the SSH_ASKPASS password mechanism. |
| ux.md | Screens, keybindings, wizard flow, theming. |
| decisions.md | Decision log (ADR-style) with rationale. |
| security.md | Threat model for stored secrets (mirrors the shipped SECURITY.md). |
| packaging.md | Shipping to Homebrew, Debian/Ubuntu (.deb/apt), and crates.io — multi-arch (x86 + arm). |
Quick orientation
- What it is: a fast, atuin-style fuzzy launcher for SSH. Save a host once, connect with
Enter. - What it is NOT: it does not edit
~/.ssh/config, is not a terminal emulator, and does not proxy/tunnel traffic itself — it builds thesshinvocation and hands the terminal tossh. - Platforms: macOS + Linux (v1). Toolchain: Rust 1.88+.
- Status: see progress.md.
UX: screens, keys, wizard, theming
Visual model is atuin.sh: slim chrome, an inline filter-as-you-type list, and a contextual keybind hint bar at the bottom.
Main screen (host list)
┌ sshelf 3/14 ───────────────────────────────────────┐
│ > prod │ ← live fuzzy filter input (top)
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ ▸ prod-web deploy@web1:2222 [prod] │ ← selected row, matched chars bold
│ prod-db mike@10.25.25.25 [prod,db] │
│ prod-cache mike@10.0.0.9 [prod] │
└──────────────────────────────────────────────────────┘
↵ connect ^a add ^e edit ^d delete ^y yank ^o import F1 help esc quit
Layout: Length(3) search · Min(0) list · Length(1) hint bar. Each row shows
name · user@host[:port] · [tags]. The matched/total count lives in the search-box
title (so it’s never truncated by a narrow terminal).
Sorting / ranking
- No query (idle): sort by frecency desc (most-used-recently first).
score = use_count * exp(-decay_rate * days_since_last_used),decay_ratedefault0.2. - Typing a query: fuzzy-filter via
nucleo-matcher; sort by match score; frecency breaks ties. Matched characters are highlighted (bold/accent) using the matcher’s match indices, rendered withunicode-widthso wide/combining chars don’t misalign. - v1 ships fuzzy search only (prefix/substring modes can come later).
Keybindings (list screen)
The search box is always active (atuin-style single mode), so plain letters filter the list. Actions therefore use Ctrl (or function keys) to avoid being typed into the query.
| Key | Action |
|---|---|
| type | filter the list (fuzzy) |
tag:NAME | filter by tag; combine with text and repeat (tag:prod tag:db, AND) |
site:NAME | filter by site (one site per host) |
↑ / ↓, Ctrl-p / Ctrl-n | move selection |
Enter | connect to selected host (tears down TUI, exec()s ssh) — M3 |
Ctrl-a | add host (wizard) — M4 |
Ctrl-e | edit selected host — M4 |
Ctrl-d | delete selected host (confirm) — M4 |
Ctrl-y | yank — copy/print the generated ssh command without connecting — M3 |
Ctrl-t | open the dual-pane file-transfer screen for the selected host |
Ctrl-f | open the port-forward popup for the selected host (runs in the background) |
Ctrl-o | import from ~/.ssh/config (read-only) — M7 |
F1 | help overlay |
F2 | settings (config & hosts-file locations) |
F3 | manage sites (create/edit/delete groups + their shared defaults) |
F4 | manage port forwards (list all active, stop any) |
Esc | clear the query if non-empty, otherwise quit |
Ctrl-c | quit |
Implemented in M2: type-to-filter, navigation, Enter (returns a Connect outcome), F1
help, Esc/Ctrl-c. The action keys show a “coming in Mx” status until their milestone.
Tag filtering and config.toml keybinding overrides land in M6.
Add / Edit form
A single full-screen form (Ctrl-a add, Ctrl-e edit selected). Every field shows a dim
placeholder explaining it. The form is auth-aware — it shows only the fields relevant
to the chosen Auth method, so the rest don’t clutter the screen.
Always shown: Name (required), Hostname (required), User (defaults $USER),
Port (defaults 22), Auth, Jump hosts, Tags, Site, 2FA (←/→ yes/no —
prompt for a verification code on connect), Extra args (raw flags escape hatch).
Auth-specific fields:
| Auth | Extra fields |
|---|---|
agent | none |
key | Key — ←/→ cycles private keys found in ~/.ssh, Enter opens a file browser to pick a key anywhere (e.g. a .pem in ~/Downloads); Key passphrase — optional, only if the key is encrypted |
password | Password |
Key discovery finds keypairs (<name>.pub sibling) and standalone private keys including
.pem (detected by a PRIVATE KEY header), so AWS-style keys show up too. Every field shows a
dim placeholder, prefixed optional · when the field can be left blank (required · for Name/Hostname).
File browser (opened from the Key field with Enter): a modal listing the current
directory with a fuzzy filter — type to filter, ↑/↓ move, Enter opens a directory or
selects a file, ← goes up, Backspace edits the filter (or goes up when empty), Esc clears
the filter (or cancels when empty). It starts in ~/.ssh (or near the current key) and a picked
path is stored as the host’s identity, even if it’s outside ~/.ssh. Key discovery finds .pem
and other private keys by their header, not just .pub pairs.
Settings (F2)
A screen for configuring sshelf itself. v1:
- Config file — shown read-only (it’s chosen before the config is read, via
--config/$SSHELF_CONFIG, so it can’t be a setting in the file itself). - Hosts file — editable; blank means the default under the config dir.
~is expanded. On save, an existing file at the new path is adopted (loaded, never overwritten); a new path is created from the current hosts so they follow. More settings will be added here.
Navigation: Tab / ↑ / ↓ move between fields; ← / → (or space) change the choosers
(Auth, Key); Enter advances and saves on the last field; Ctrl-s saves anywhere; Esc
cancels. Validation errors (missing name/hostname, non-numeric port) show inline and focus
jumps to the offending field.
Implemented as a single-screen, auth-aware field form (not a paged wizard) — simpler to navigate/edit and “guided” by placeholders + inline validation. The Key field is a picker (single key); a host configured with multiple identity files keeps them on edit, but entering several keys is done by editing
hosts.toml.
Quick-add: the form opens with defaults, so typing a Name + Hostname and Ctrl-s is enough.
Secrets (Password / Key passphrase): the masked value is stored in the OS keyring (or the
age vault) keyed by host id — never in hosts.toml. On edit, leaving it blank keeps the
existing secret. It’s auto-supplied at connect time (see ssh-command.md). Deleting a host
(Ctrl-d) removes the host, its frecency entry, and its stored secret.
Sites (F3)
A site groups hosts (one per host — a data center / project) and, unlike tags, may carry optional shared SSH defaults — user, port, ProxyJump (bastion), identity — that member hosts inherit at connect time. A bare site (name only) is pure grouping; per-host fields always override the site’s; auth stays per-host.
- In the list: with an empty search box, hosts are grouped under
── site (n) ──headers (a(no site)group last); while filtering, the list is flat with a dim·site·column and asite:NAMEfilter token. - Assigning a site: the add/edit form has a Site chooser (
←/→over the defined sites(none)).
- Managing sites (
F3): a list withaadd,e/Enteredit,ddelete,Ctrl-ssave,Esccancel. Each site’s form is name + optional user/port/jump/identity. Renaming a site updates its member hosts; deleting one clears members’ site (nothing dangles). A host that names an undefined site still groups under that name but inherits nothing.
Inherited defaults appear in the generated command (yank, print-command, connect, transfers).
Import (Ctrl-o / sshelf import)
Parses ~/.ssh/config read-only via ssh2-config and adds every host whose name isn’t
already present, warning about unsupported Match / Include / ProxyJump. v1 imports all
new hosts at once (no per-host selection screen) — curate afterwards with Ctrl-e / Ctrl-d.
The CLI form supports --dry-run to preview. Never writes back to ~/.ssh/config.
File transfer (Ctrl-t)
Ctrl-t on a host opens a dual-pane transfer screen: local files on the left, the host’s
files on the right. sshelf authenticates once by opening an ssh ControlMaster that reuses
the host’s auth (keys/agent/ProxyJump, or the stored keyring/vault secret via SSH_ASKPASS),
then runs sftp (ls/get/put) over it. Remote listing and transfers run on a background
thread, so the UI stays responsive on slow links.
Both panes fuzzy-filter as you type:
| Key | Action |
|---|---|
| type | filter the focused pane |
Tab | switch the focused pane (local ↔ remote) |
↑ / ↓, Ctrl-p / Ctrl-n | move the selection |
→ / Enter | open the selected directory (or send a file) |
Ctrl-s | send the selected file or folder (recursive) into the other pane’s directory |
← | go up a directory |
Backspace | edit the filter, or go up when it’s empty |
Esc | cancel a running transfer, else clear the filter, else close the screen |
A progress bar shows bytes and percent for single-file downloads; folder and upload transfers
show as in-flight (cancelable with Esc). Directories are marked name/ and symlinks name@;
symlinks are skipped in this version. Filenames are shell-quoted and control characters stripped
from display. The connection uses StrictHostKeyChecking=accept-new, like connect — so a
first-time host key is trusted on use (see security.md). Only one transfer runs
at a time in v1, and a same-named file or folder already present in the destination is skipped
(with a message) rather than overwritten.
On failure the status line shows the underlying sftp error. For more detail, run with
sshelf --transfer-log <FILE> (or $SSHELF_TRANSFER_LOG=<FILE>): the worker appends every
ssh/sftp command and its full stderr to that file. The log holds no secrets — the password
reaches ssh via SSH_ASKPASS, never the command line.
Port forwarding (Ctrl-f / F4)
Ctrl-f on a host opens a small popup to start an SSH port forward that keeps running in the
background even after you quit sshelf. Pick a kind (cycle with ←/→):
- Local (
-L, the default) — open a local port that tunnels to a host reachable from the server (e.g. expose a remote DB at127.0.0.1:8080). - Remote (
-R) — open a port on the server that tunnels back to a host reachable from here. - Dynamic (
-D) — a local SOCKS proxy that routes through the server.
Fill in the ports/host (defaults: bind 127.0.0.1, target host localhost) and Ctrl-s to
start. sshelf spawns a detached ssh -N …, reusing the host’s auth exactly as connect does
(keys/agent/ProxyJump or the stored password via SSH_ASKPASS, plus any site defaults), and
waits briefly to confirm it bound — a failure (port already in use, a privileged <1024 port,
the server refusing a remote bind, or an auth failure) is shown in the popup so you can fix a
field and retry. On success the TUI returns to the list and the tunnel runs on its own.
F4 opens the forwards manager: every active forward across all hosts, with its host, an
L/R/D summary (L 127.0.0.1:8080 → db:3306), pid and age. d (or k) → y stops the
selected one. The list refreshes live, so a forward that ends — stopped here, killed from
another terminal, or dropped on its own (sleep / network) — disappears within a moment. Forwards
survive sshelf exiting (each is a detached process in its own process group) and the ledger
(forwards.json) is reconciled against the running processes on every launch, so you only ever
see forwards that are still actually running. See decisions.md D-021.
Two-factor (2FA) hosts
Some servers want an interactive verification code (TOTP / keyboard-interactive) on top of your
key or password. Mark such a host with 2FA = yes in the add/edit form (or sshelf add … --2fa). On connect, sshelf shows a small popup to enter the current code before handing off
to ssh, and supplies it to the verification prompt through the same askpass helper that supplies
your stored password — sshelf never proxies the live session.
The flag is needed because a connect that auto-supplies a stored secret runs ssh with
SSH_ASKPASS_REQUIRE=force, which routes the code prompt to the helper with no terminal fallback —
so without it the connect would simply fail. (A host with no stored secret already prompts for
the code inline after handoff, so the flag mainly matters for stored-secret hosts; a host using an
encrypted key with no stored passphrase should use an agent.) sshelf <host> from the CLI (no TUI)
prompts for the code on the terminal instead. v1 is manual entry — sshelf does not store TOTP
seeds. See decisions.md D-022.
CLI (outside the TUI)
| Command | What it does |
|---|---|
sshelf | Launch the interactive TUI. |
sshelf <host> | Connect straight to a saved host by name or id, skipping the TUI — same connect path as Enter (frecency recorded before the exec, secret auto-supplied). A miss suggests close names; a name that collides with a subcommand (list, import, …) is reached via the TUI instead. |
sshelf - | Reconnect to the most-recently-used host (max last_used in the frecency state). Errors (without connecting) if there’s no history yet. |
sshelf add | With no args, opens the TUI add form. With args, adds a host non-interactively: NAME + -H/--hostname required; -u/--user, -p/--port, -a/--auth, -i/--identity (repeatable, implies key auth), -J/--jump, -t/--tag, -s/--site, --extra "<flags>", --password-stdin (reads the secret from stdin). Duplicate names are refused. |
sshelf sites [--json] | List defined sites with member counts + their shared defaults. |
sshelf sites add NAME [-u/-p/-J/-i] | Define a site (settings optional; edit later with F3). |
sshelf print-command <host> | Print the generated, shell-quoted ssh … command for a saved host by name or id (reflecting any inherited site defaults), without connecting or changing frecency. CLI equivalent of the TUI’s Ctrl-y yank. |
sshelf list [query] | List hosts (with a ·site· column). query filters with the TUI’s syntax — fuzzy text and/or tag:NAME / site:NAME (e.g. sshelf list site:prod-dc). |
sshelf list --json [query] | Same selection, emitted as JSON (each host’s fields + its generated command). Always valid JSON, even when empty — the stable surface for scripts/integrations. |
sshelf import [--dry-run] | Read-only import from ~/.ssh/config. |
sshelf set-password <host> | Store a password (read from stdin) for a host. |
sshelf completions <shell> · sshelf man | Emit static completions / the man page. |
--config FILE (global) | Use a specific config file (also $SSHELF_CONFIG). |
--transfer-log FILE (global) | Append transfer-screen diagnostics — the ssh/sftp commands and their errors (no secrets) — to FILE. Also $SSHELF_TRANSFER_LOG. |
Dynamic completion (host names): static completions cover subcommands/flags only. Sourcing
COMPLETE=<shell> sshelf (e.g. source <(COMPLETE=zsh sshelf)) enables host-name completion via
clap_complete’s engine — host_name_candidates reads hosts.toml (side-effect-free) and is
attached to the <host> arguments of direct-connect, print-command, and set-password.
Confirmations & overlays
- Delete pops a confirm modal (
y= delete, any other key = cancel). - Help (
F1) is an overlay listing all keys; any key closes it.
Theming
atuin-inspired defaults: dim chrome, a single accent color for selection + match highlights. Terminal resize is handled automatically by ratatui’s layout — no manual recompute.
Configuration (config.toml)
A commented default is written on first run. Keys:
| Key | Default | Meaning |
|---|---|---|
decay_rate | 0.2 | Frecency decay per day (higher = recency matters more). |
default_sort | "frecency" | Idle list order: "frecency" or "name". |
accent | "cyan" | Accent color: black/red/green/yellow/blue/magenta/cyan/white/gray. |
Location: ~/.config/sshelf/config.toml (honors XDG_CONFIG_HOME).
SSH command generation & the askpass mechanism
This is the heart of sshelf and its trickiest part. Read carefully before touching ssh.rs
or askpass.rs.
1. Building the ssh argv
From a Host, build (in order):
ssh
[-i <identity_file>]... # one -i per entry in identity_files (auth = "key")
[-p <port>] # only if port present and != 22
[-J <jump1,jump2,…>] # ProxyJump chain (jump_hosts), comma-joined
-o StrictHostKeyChecking=accept-new # see §3 — keeps host-key prompt away from askpass
<extra_args…> # raw, split with `shlex`, appended verbatim
<user>@<hostname> # user defaults to $USER if unset
- Pure flags only — no temporary
ssh -Fconfig files (keeps the “never touch SSH config” promise literal and avoids cleanup). extra_argsis the escape hatch for anything the wizard doesn’t model (-X,-L …,-o …). Split withshlex::splitso quoted args survive.- Example: stored host
mike@10.25.25.25with key~/.ssh/infra-key→ssh -i /home/mike/.ssh/infra-key -o StrictHostKeyChecking=accept-new mike@10.25.25.25in the printed/yanked command (the exec path expands~internally as well).
The same builder backs the Ctrl-y yank action and sshelf print-command <host>
(copy/print the exact command without connecting). For copy/paste safety, identity-file ~
is expanded before shell-quoting; quoted ~ would not expand in the user’s shell.
2. Launch handoff (exec)
On connect:
- Persist frecency first (
exec()never returns — nothing runs after it). - Set environment for the child:
SSH_ASKPASS = <path to our own binary>(std::env::current_exe())SSH_ASKPASS_REQUIRE = forceSSHELF_ASKPASS = 1← how the re-exec’d binary knows it’s in askpass modeSSHELF_HOST_ID = <id>← which secret to fetchenv_remove("SSH_ASKPASS")of any inherited value first, then set ours (avoid pollution).
- Tear down the TUI:
disable_raw_mode()→LeaveAlternateScreen→ show cursor → flush. std::os::unix::process::CommandExt::exec()intossh. If it returns, it errored → restore terminal, show the error.
A RAII guard + panic hook guarantees step 3’s teardown also runs on panic/early-exit.
3. Secret auto-supply — the sharp edges
Applies whenever a stored secret exists for the host — a login password (password
auth) or a key passphrase (key auth with an encrypted key). exec_connect wires the
askpass env only when such a secret exists (wire_askpass); otherwise ssh prompts / uses the
agent normally — and in that no-secret case configure_askpass also strips
SSHELF_VAULT_PASSPHRASE from the child env (ssh has no reason to inherit the vault master
passphrase). In the wired case the variable must stay: the helper runs as ssh’s child and
reads it to unlock the vault (see docs/security.md).
ssh decides it needs a secret → because SSH_ASKPASS_REQUIRE=force, it executes the helper
as sshelf "<prompt text>" (the prompt is argv[1]; there is no --askpass flag).
The helper:
- Confirms it’s in askpass mode via
SSHELF_ASKPASS=1. - Inspects
argv[1]by OpenSSH prompt shape and branches:- Ends with
password:(classicuser@host's password:/ PAMPassword:) or containspassphrase for(Enter passphrase for key '<path>':) → fetch the secret forSSHELF_HOST_IDfromsecrets(keyring or age vault), print it, exit0. - Anything else (host-key
yes/no, OTP/verification codes, arbitrary server text) → exit non-zero to decline, sosshhandles it. Never blindly print the secret.
- Ends with
A host uses one auth method, so answering both password and passphrase prompts with its one stored secret is correct.
Why inspection (by shape) is mandatory
SSH_ASKPASS_REQUIRE=force makes ssh route every read_passphrase() call to the
helper — including the first-connect “Are you sure you want to continue connecting
(yes/no/fingerprint)?”. If the helper answered that with the stored secret, the connection
breaks. Worse, a malicious/compromised server could use keyboard-interactive auth to send
a prompt that merely mentions “password” to phish the secret. Three defenses:
- The helper matches the shape of real prompts (ends-with
password:/ containspassphrase for), not just the substring — so “Type your password to continue:” is declined. - We pass
-o StrictHostKeyChecking=accept-new, so the host-key prompt normally never fires for new hosts (known hosts are still verified; changed keys still hard-fail). - The secret is host-scoped, limiting blast radius even if a prompt is mis-answered.
Validated by the M0 spike ✅ (2026-06-05, macOS, OpenSSH 10.2)
Ran against a real password-auth sshd (lscr.io/linuxserver/openssh-server):
- Success path —
SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force,PreferredAuthentications=password,StrictHostKeyChecking=accept-new→ logged in (exit 0). ConfirmsSSH_ASKPASSsatisfies interactivePasswordAuthentication, not just key passphrases. The helper was called withargv[1] = "tester@127.0.0.1's password: ". - Host-key routing — with
StrictHostKeyChecking=askand a freshknown_hosts, ssh sent the helper the"…continue connecting (yes/no/[fingerprint])?"prompt; a naive helper that always returns the password caused an infinite loop on"Please type 'yes', 'no'…". This is the empirical proof that §3’s two rules are mandatory, not optional.
Linux verification is deferred to CI (M8); the mechanism is OpenSSH behavior and is expected to be identical.
4. Known v1 limitations
- Password-auth jump hosts are unsupported. The helper only has the target’s secret and can’t tell which hop is prompting. Jump hosts must use key/agent auth in v1.
- macOS unsigned builds: the re-exec’d askpass child reading Keychain may trigger an OS approval prompt every connect (Keychain ACLs are keyed to code signature). Ad-hoc sign for dev; document for users building from source.
- Windows: out of scope for v1 (
exec()replacement is Unix-only).
References
- OpenSSH
ssh(1),ssh_config(5)(ProxyJump,StrictHostKeyChecking). SSH_ASKPASS_REQUIRE— added in OpenSSH 8.4 (2020). This machine runs 10.2.std::os::unix::process::CommandExt::exec.
Data model & on-disk layout
File locations
Paths resolve via the etcetera base strategy (XDG everywhere — ~/.config/sshelf on
both macOS and Linux, honoring XDG_CONFIG_HOME/XDG_DATA_HOME when set). This keeps
config hand-editable instead of buried in macOS ~/Library.
| File | Location (default) | Owner | Purpose |
|---|---|---|---|
hosts.toml | ~/.config/sshelf/hosts.toml | user | The host database. Human-editable. |
config.toml | ~/.config/sshelf/config.toml | user | Preferences (theme, decay_rate, sort, keybinds). |
state.json | ~/.local/share/sshelf/state.json | app | Frecency counters, keyed by host id. Churns; not for hand-editing. |
forwards.json | ~/.local/share/sshelf/forwards.json | app | Ledger of active background port-forwards (PIDs). Reconciled against the OS on launch. Mode 0600. |
vault.age | ~/.local/share/sshelf/vault.age | app | Fallback encrypted secret store (only when no OS keyring). Mode 0600. |
Directories are created on first run (0700). Secrets are never written to hosts.toml.
Host / Site schema (hosts.toml)
format_version = 1 # top-level scalar; for future migrations
[[site]] # optional; sites are listed before hosts (scalars-before-AoT)
name = "prod-dc" # the name hosts reference (see host.site)
user = "deploy" # optional default login for member hosts
port = 22 # optional default port
jump_hosts = ["bastion.prod"] # optional default ProxyJump (the site's bastion)
identity_files = ["~/.ssh/prod"] # optional default key(s) (applied to key-auth members)
[[host]]
id = "01J…" # stable unique id (e.g. ULID/UUID); keys secrets & frecency
name = "prod-db" # display alias (what you search/see)
hostname = "10.25.25.25" # IP or DNS name (required)
user = "mike" # optional; default = $USER at connect time
port = 22 # optional; default 22
auth = "key" # "key" | "password" | "agent"
identity_files = ["~/.ssh/infra-key"] # for auth="key"; repeatable (-i per entry)
jump_hosts = ["bastion.example.com"] # ProxyJump chain; key/agent auth only in v1
tags = ["prod", "db"] # many-valued, free-form; for filtering/grouping
site = "prod-dc" # optional; one site (by name); groups + inherits its defaults
requires_2fa = true # optional (default false); connect prompts for a verification code
extra_args = "-o ServerAliveInterval=30" # raw, shlex-split, appended verbatim
# NOTE: no password field — ever. auth="password" means "look up the secret by id".
Notes:
- Optional fields use
Option<T>in Rust with#[serde(skip_serializing_if = "Option::is_none")]so the TOML stays clean; new fields use#[serde(default)]for backward compatibility. identity_files/jump_hosts/tagsareVec<String>(empty = absent).format_versionlets us migrate the schema later without breaking older files. Adding[[site]]andhost.siteneeded no bump — old files load withsites = []/site = None.requires_2famarks a host whose login needs an interactive verification code; connect collects it and passes it tosshvia the transientSSHELF_2FA_CODEenv var (never stored on disk). Seedecisions.mdD-022.
Sites vs tags, and inheritance
A Site is one per host and may carry optional shared SSH defaults; tags are
many-valued free-form labels. At connect time a host is resolved into an effective host
(Host::with_site_defaults): for user, port, jump_hosts, identity_files, the site’s
value fills in only where the host leaves that field unset — the host always wins. Auth is
not inheritable. A host that names an undefined site still groups under that name but
inherits nothing (graceful degradation). Renaming a site (F3 manager) cascades to member hosts;
deleting one clears members’ site. See decisions.md D-020.
Frecency state (state.json)
{
"01J…": { "use_count": 12, "last_used": "2026-06-05T09:30:00Z" }
}
- Keyed by host
id(so renaming a host inhosts.tomlkeeps its history). - Updated before
exec()on connect:use_count += 1,last_used = now. - Kept separate from
hosts.tomlso the user-owned host file stays stable and diff-friendly. - Score:
use_count * exp(-decay_rate * days_since_last_used)(decay_ratedefault0.2). Seeux.mdfor how it combines with fuzzy ranking.
Port-forward ledger (forwards.json)
[
{
"id": "01J…", // ULID; also names the forward's stderr log
"host_id": "01J…", // originating host id
"host_name": "prod-db", // snapshot for display
"kind": "local", // "local" | "remote" | "dynamic"
"spec": { "listen_port": 8080, "target_host": "db", "target_port": 3306 },
"display": "L 127.0.0.1:8080 → db:3306",
"pid": 41234, // the detached `ssh -N` process
"started_at": 1718900000
}
]
- App-owned; written atomically (
0600). The runningsshprocesses are authoritative — this file is only a remembered list of PIDs, reconciled against the OS (ps) on startup, on opening the manager, and each tick while it’s open. A forward leaves the ledger the moment its process is gone, however it ended. Seedecisions.mdD-021. specomits empty fields (binddefaults to127.0.0.1,target_hosttolocalhost); Dynamic forwards carry onlylisten_port.
Secrets
Stored in the OS keyring (service sshelf, account = host id) or, as a fallback on
headless systems, in vault.age. Either way the key is the host id. Full model and threat
analysis in security.md.
Atomic writes
All persistent writes use temp-file + rename() (atomic on Unix) so a crash mid-write never
corrupts hosts.toml / config.toml / state.json. Single-process tool → no file locking needed.
Security & threat model
sshelf stores SSH passwords so it can auto-supply them. This document states exactly what
that protects against and what it does not. The shipped root SECURITY.md (M8) is a
user-facing summary of this.
Strongly prefer SSH keys / agent over stored passwords. Password storage exists for hosts you can’t use keys with; it is the least secure option
sshelfoffers.
Where secrets live
- Primary — OS keyring: macOS Keychain (
security-framework) or Linux Secret Service over D-Bus (keyringcrate). Servicesshelf, account = hostid. agevault (opt-in / headless): ifSSHELF_VAULT_PASSPHRASEis set, secrets go in the XDG data dir asvault.age, encrypted with that passphrase (agepassphrase mode = scrypt KDF + ChaCha20-Poly1305). This is the path for headless Linux with no Secret Service daemon, and for automation/CI. v1 reads the passphrase from the env var (deterministic, scriptable); an interactive prompt + auto-detection of a missing keyring are future enhancements. Env-inheritance tradeoff: the askpass helper runs as ssh’s child and reads the env var to unlock the vault, so for hosts with a stored secret the passphrase is necessarily in the ssh process tree’s environment (/proc/<pid>/environ, same-user readable). For hosts with no stored secret,ssh.rs::configure_askpassstrips the variable before the exec.- Provisioning:
sshelf set-password <name|id>stores a secret from stdin (so it can be piped in headless setups) without going through the TUI. - Never in
hosts.toml,state.json, logs, shell history, or process arguments.
The host-key id is the lookup key in both stores, so renaming a host keeps its secret.
How the password reaches ssh
Via SSH_ASKPASS — ssh calls our helper, which prints the secret on stdout. The password
is never passed as a CLI argument (no sshpass -p), so it never appears in ps/argv. See
ssh-command.md. The helper matches the shape of OpenSSH’s standard
prompts — a login password (…password:) or a key passphrase (Enter passphrase for key …) —
and declines host-key confirmations, OTP/verification codes, and arbitrary server text, so a
keyboard-interactive server can’t phish the stored secret by merely mentioning “password”.
Threat model
Protected against
- On-disk plaintext exposure — secrets are in the OS keyring or encrypted at rest in the vault.
- Process-listing / argv leakage — password is delivered via stdin/stdout to
ssh, not argv. - Shell-history leakage —
sshelfnever echoes the command containing a password. - Casual file snooping — the vault requires the master passphrase (memory-hard KDF).
hosts.tomlsharing — it contains no secrets, so it’s safe to commit/share/back up.- Config-file corruption — atomic writes; a crash mid-write leaves the prior file intact.
NOT protected against (out of scope)
- A root/admin attacker or malware on the machine — can read process memory, the keyring,
or keystrokes.
sshelfassumes you trust your own machine. - Keyloggers — can capture the master passphrase as you type it.
- A compromised OS keyring daemon — we trust the platform’s secret service.
- Physical theft without full-disk encryption — use FDE; that’s an OS-level control.
- Unencrypted backups / cloud sync of
vault.age— the vault is encrypted, but treat it as sensitive; don’t rely on it as your only protection in an untrusted backup.
Assumption: sshelf targets a developer/operator’s own (trusted) machine, not shared or
hostile hosts.
Operational notes
- No password recovery. Forgetting the vault master passphrase means losing vault secrets. Use a passphrase you can recover (e.g. from another password manager).
- macOS unsigned builds: the re-exec’d askpass child reading Keychain can trigger an OS approval prompt on each connect (Keychain ACLs are keyed to code signature). Ad-hoc sign dev builds; release builds should be signed.
StrictHostKeyChecking=accept-newtrusts a new host’s key on first connect but still hard-fails if a known host’s key changes (MITM protection retained).- Network:
sshelfmakes no network connections of its own and has no telemetry; it only ever launches the OpenSSH tools —sshto connect, andssh/sftpfor the file-transfer screen. Transfers authenticate exactly as connect does (keys/agent, or the stored secret viaSSH_ASKPASS) by opening one multiplexedsshControlMaster and runningsftpover it — so no extra secret handling, and the secret still never reaches argv. Remote paths are quoted forsftp’s parser, control characters are stripped from displayed names, andStrictHostKeyChecking=accept-newapplies there too. The optional transfer log (--transfer-log/$SSHELF_TRANSFER_LOG) records thessh/sftpcommands and their stderr for troubleshooting — it contains no secrets (same reason: the password goes via askpass).
Reporting
(M8) Add a SECURITY.md at the repo root with a disclosure contact before public release.
Architecture
sshelf is a single binary that runs in one of two modes:
- Interactive TUI (default, and subcommands like
import) — the fuzzy launcher. - Askpass helper (
SSHELF_ASKPASS=1in the environment) — a headless, non-interactive mode thatsshinvokes to obtain a stored password. Never run directly by the user.
High-level flow
┌─────────────────────────────────────────────┐
│ sshelf (TUI) │
│ │
hosts.toml ──▶│ store ──▶ model(Host) ──▶ search (fuzzy + │
state.json ──▶│ frecency) ──▶ ui (list/wizard) │
config.toml ─▶│ │
│ user presses Enter on a host ──┐ │
└────────────────────────────────────┼──────────┘
│
1. update frecency (use_count, last_used) & save
2. set env: SSH_ASKPASS=self, SSH_ASKPASS_REQUIRE=force,
SSHELF_ASKPASS=1, SSHELF_HOST_ID=<id>
3. tear down TUI (raw mode off, leave alt screen, show cursor)
4. exec("ssh", argv…) ← process is REPLACED; sshelf is gone
│
▼
┌─────────────────────────────────────────────┐
│ ssh │
│ needs a password? ─▶ runs SSH_ASKPASS: │
│ `sshelf "<prompt>"` (SSHELF_ASKPASS=1) │
│ │ │
│ ▼ │
│ askpass mode: inspect argv[1]; │
│ if password prompt ─▶ secrets.get(host_id) │
│ (keyring → age vault) ─▶ print, exit 0 │
│ else ─▶ exit non-zero (decline) │
└─────────────────────────────────────────────┘
│
interactive ssh session
│
session ends → back at shell
Why this shape
-
exec()(process replacement), not spawn+wait. The user chose exit-to-shell: when the SSH session ends, they’re back at their normal prompt.exec()givessshthe real TTY with zero indirection — the cleanest possible handoff. Consequence: no code runs afterexec(), so anything that must persist (frecency) happens before it. -
Password auto-supply via
SSH_ASKPASS, notsshpass.sshnever accepts a password on the command line;sshpasswould expose it inps/argv and is an extra dependency.SSH_ASKPASS(OpenSSH 8.4+) letssshcall a helper program for the password. We point it at our own binary. WithSSH_ASKPASS_REQUIRE=force, ssh uses the helper even though a TTY is present. Seessh-command.mdfor the full mechanism and its sharp edges (the helper must inspect the prompt; host-key prompts must be neutralized). -
Two-tier secrets. OS keyring (macOS Keychain / Linux Secret Service) is the primary store. Headless/minimal Linux often has no Secret Service daemon, so an
age-encrypted vault (unlocked by a master passphrase, cached in-memory per session) is the fallback. Seesecurity.md. -
Own database, never
~/.ssh/config. Hosts live inhosts.toml(human-readable, atomic writes). Import from~/.ssh/configis read-only. Seedata-model.md. -
Synchronous event loop, component pattern. No background work needs multiplexing (the one long-running thing, the SSH session, happens after the TUI is gone). A simple
crossterm::event::read()loop with a component-per-screen structure (HostList, Wizard, Help, Confirm) keeps it small and tokio-free.
Component map
See structure.md for the file-by-file breakdown. At runtime:
Appowns top-level state (current screen, query, selection, loaded hosts + state) and routes events to the active component.searchturns(hosts, state, query)into a ranked, highlight-annotated view.sshis the only place that builds argv and performs the teardown +exec().secretsis the only place that talks to the keyring/vault; both the TUI (to store on add/edit) and the askpass mode (to retrieve) go through it.
Failure handling
- If
exec()returns, it failed (e.g.sshnot found) → restore the TUI and surface the error. - If the askpass helper can’t get the secret → exit non-zero so
sshfalls back to prompting the user, rather than hanging or sending a wrong answer. - A panic mid-TUI restores the terminal via a guard + panic hook before unwinding.
Project structure
Keep this in sync with the actual tree (the docs-in-sync rule).
All modules present:
main,app,askpass,config,forwards,import,model,paths,search,secrets,ssh,state,store,transfer/{mod,worker,pane,screen,e2e},testsupport(test-only),vault,ui/{mod,list,help,widgets,wizard,browse,settings,sites,transfer,forward_popup,forwards,two_factor}. (error.rswas removed — the codebase usesanyhowthroughout.)
Repository
ssh-tui/ (crate/binary name: `sshelf`)
├── Cargo.toml
├── CONTRIBUTING.md contributor guide + docs-in-sync rule
├── README.md (M8) user-facing intro + positioning
├── SECURITY.md (M8) threat model for OSS users (mirrors docs/security.md)
├── LICENSE-MIT (M1)
├── LICENSE-APACHE (M1)
├── docs/ living documentation (this directory)
└── src/ see below
src/ modules
| File | Responsibility |
|---|---|
main.rs | Entry/dispatch. If SSHELF_ASKPASS is set → askpass mode (read argv[1]). Else clap parses: default TUI, or subcommands (import, list, add). |
app.rs | App state + synchronous event loop + screen routing (component orchestration). |
model.rs | Host + Site structs (+ AuthMethod); Host::with_site_defaults/find_site (site inheritance); serde derives. |
store.rs | Load/save hosts.toml with atomic write (temp + rename); load config.toml. |
state.rs | Frecency state (use_count, last_used) load/save (state.json); score computation. |
forwards.rs | Background port-forwards: the ForwardSpec/ForwardEntry model, the -L/-R/-D argv builder, spawn (detached ssh -N + readiness/error mapping), PID liveness/kill via ps/kill, reconcile, and forwards.json load/save. |
secrets.rs | SecretStore trait → keyring backend + age-vault fallback; zeroize on secrets. |
ssh.rs | Build ssh argv from a Host; terminal teardown + exec() handoff; askpass env wiring. |
askpass.rs | Headless askpass entry: inspect argv[1]; answer password prompts via secrets, or a queued 2FA code (SSHELF_2FA_CODE) for the verification prompt; else decline. |
search.rs | Fuzzy filter (nucleo-matcher) + frecency ranking + per-row match indices for highlight. |
import.rs | ssh2-config parse of ~/.ssh/config → Host mapping; warn on unsupported Match/Include. |
paths.rs | etcetera path resolution (config/data dirs); file paths; dir/file perms (0700/0600). |
config.rs | Preferences: decay_rate, default_sort, accent color; writes a commented default on first run. |
transfer/mod.rs | File-transfer core: ssh-ControlMaster + sftp argv builders, the worker↔UI message protocol (WorkerCmd/WorkerEvent), and progress math. |
transfer/worker.rs | Background worker thread: owns the ControlMaster (open/readiness/teardown), lists remote dirs (sftp ls -l), runs sftp get/put transfers with progress + cancel. |
transfer/pane.rs | One side’s browsing state (fuzzy filter + selection + nav, reusing search); read_local_dir for the local side; RemoteEntry→PaneEntry. |
transfer/screen.rs | The dual-pane TransferScreen: two panes over one session, key handling, draining worker events. |
ui/list.rs | Host list rendering + match highlighting + selection. |
ui/transfer.rs | Renders the transfer screen (two panes + progress/status + hint bar) from a borrowed view. |
ui/wizard.rs | Auth-aware add/edit form: fields, validation, key picker, opens the file browser. |
ui/browse.rs | File-browser modal (fuzzy-filtered) for picking a key file anywhere on disk. |
ui/settings.rs | Settings screen (F2): config-file display + editable hosts-file location. |
ui/sites.rs | Sites manager (F3): list + add/edit/delete sites and their optional defaults; emits renames for the app to cascade. |
ui/forward_popup.rs | New-port-forward popup (Ctrl-f): kind chooser (Local/Remote/Dynamic) + ports/host fields + validation; emits a ForwardSpec for the app to spawn. |
ui/forwards.rs | Port-forwards manager (F4): lists all active forwards from a live snapshot; emits a kill request for the app to act on. |
ui/two_factor.rs | 2FA code popup shown before connecting to a requires_2fa host; emits the entered code for the app to queue + supply via askpass. |
ui/help.rs | Help overlay. |
ui/widgets.rs | Shared widgets: single-line text input (hand-rolled), keybind hint bar, confirm modal. |
Data flow at a glance
paths ──▶ store ──▶ model(Host[]) ┐
paths ──▶ state ──▶ frecency ├─▶ search ──▶ ui ──▶ (Enter) ──▶ ssh ──▶ exec
┘ │
secrets ◀── ui (store on add/edit) askpass ◀── ssh (via SSH_ASKPASS)
└────────────────────────────────────────────────────▶ secrets (retrieve)
Conventions
- One responsibility per module;
ssh.rsis the only place that callsexec();secrets.rsis the only place that touches the keyring/vault. - No
unwrap()/expect()on fallible I/O in non-test code — return errors, surface them in the UI. - Every new/moved module updates this file.
Decision log
ADR-style. Newest on top. Each entry: the decision, why, and what we rejected. Add an entry whenever you make a non-trivial design choice.
D-022 · Interactive 2FA: collect the code before connect, inject it via the askpass helper
A connect that auto-supplies a stored secret runs ssh with SSH_ASKPASS_REQUIRE=force, which
routes every interactive prompt — including a server’s keyboard-interactive verification-code
step — to our askpass helper; the helper declined it, and a spike confirmed force gives no
terminal fallback, so the code prompt was answered empty and auth failed (a real user hit this).
A during-session popup is impossible (connect exec()s into ssh), and a PTY screen-scraper was
already rejected (D-019). So 2FA is handled the same way the password is: a per-host
requires_2fa flag makes connect show a small code popup before the exec() (while the TUI
is alive); the entered one-time code is passed to ssh via SSHELF_2FA_CODE (like the vault
passphrase already rides env), and the helper answers the non-secret prompt with it. The
helper’s routing: a password/passphrase-shaped prompt → the stored secret (unchanged, anti-phish
guard intact); any other prompt → the queued code; else decline. configure_askpass therefore
force-wires the helper when a secret exists or a code is queued (so key+2FA hosts work too).
The CLI direct-connect path (sshelf <host> / -), which has no TUI, prompts for the code on the
terminal before handoff. Rejected: storing the TOTP seed and generating the code ourselves
(puts the second factor in the same vault as the first, and needs a TOTP dep the project avoids);
auto-detecting 2FA with no flag (we can’t probe the server’s auth methods without a separate
non-exec connection). Note: a host with no stored secret already prompts for the code inline
after handoff (no askpass forced), so the flag/popup mainly fixes the stored-secret case; an
encrypted key with no stored passphrase + 2FA should use an agent (else force askpass can’t
answer the passphrase prompt either). v1 is manual entry only.
D-021 · Port forwards are detached ssh -N processes tracked by PID
Background port forwards (Ctrl-f popup, F4 manager) must keep running after sshelf exits.
Each forward is one detached ssh -N -L|-R|-D <spec> process, reusing ssh::build_args +
ssh::configure_askpass (so keys/agent/ProxyJump/stored-password and site defaults all work as
connect does). It is spawned with std::os::unix::process::CommandExt::process_group(0) (std,
no new dep) and null stdin/stdout, which makes it survive both sshelf exiting (orphaned →
reparented to init) and the terminal closing (its own process group never receives the shell’s
SIGHUP). Nothing kills a forward on Drop or app shutdown — that is what keeps it alive.
Validated by an M0 spike: a process_group(0) child with null stdio outlives its spawner
(PPID→1) in its own process group, and kill -TERM stops it.
There is no daemon. The running processes are the source of truth; forwards.json (mirrors
state.json: #[serde(transparent)] over a Vec, atomic_write 0600) is just a remembered
list of PIDs. reconcile re-validates each PID via ps -ww -o state=,command=: a forward stays
only if the process exists, isn’t a zombie (state != Z — so a dead-but-unreaped child we
spawned this session is correctly seen as gone), and its command line still matches our
ssh … <spec> (a PID-reuse guard — a recycled pid is never counted alive or signalled).
Reconcile runs on startup, on opening the manager, and on the ~100ms event-loop tick while it’s
open. Readiness/errors: -o ExitOnForwardFailure=yes makes ssh exit non-zero on a bind failure;
spawn polls try_wait for ~2.5s and, on an early exit, maps the stderr (captured to a temp file,
not a pipe, so a long-lived ssh never gets SIGPIPE) to a friendly message (port in use,
privileged port, server refused, auth failed). A third kind, Dynamic (-D SOCKS), was added
alongside Local/Remote. Rejected: a worker thread per forward (the transfer model — unneeded, a
forward has no ongoing protocol to service, just liveness); holding the Child for try_wait
(can’t track forwards from a previous session, and splits liveness into two code paths); ssh -f
(clean daemonize but hides the real PID, breaking the reuse guard and individual kill);
libc::setsid/nix::kill (a new dep the project avoids — process_group(0) + shelling to
ps/kill, as we already shell to ssh/sftp, is dep-free); kill-only for v1 (restart of a
dropped forward is deferred — the spec is persisted, so it’s an easy fast-follow).
D-020 · Sites: one-per-host grouping with optional inherited SSH defaults
Hosts can belong to a Site (a data center / project), distinct from many-valued free-form
tags. A site is one per host and may carry optional shared SSH defaults — user,
port, jump_hosts (the bastion), identity_files — that members inherit at connect time
only where the host leaves that field unset (the host always wins). A bare site (name only)
is pure grouping. Auth is not inheritable (it stays per-host; inheriting it would change
which fields apply and surprise users — a site can still carry a default identity that only
takes effect for key-auth members). Inheritance is computed by resolving a host into an
“effective host” (Host::with_site_defaults) at every Host→ssh-args boundary (connect, yank,
transfer master, CLI print/list-json), leaving ssh::build_args untouched — chosen over
threading &[Site] through build_args and its many callers/tests. Hosts reference a site by
name; an undefined name degrades gracefully (pure grouping, no inheritance, no error).
Stored in hosts.toml as [[site]] (sites before hosts; format_version unchanged — old files
load with sites = []). The list groups by site when idle and shows a flat ·site· column
site:NAMEfilter while typing. Renames in the F3 manager cascade to member hosts; deleting a site clears members’site(self-healing) rather than leaving a dangling name. Rejected: a single special tag (too weak — no inherited config); a separate sites file (one atomichosts.tomlis simpler and keeps the reference local).
D-019 · File transfer rides an ssh ControlMaster; sftp/scp as subprocesses
The dual-pane transfer screen moves files over the system sftp/scp binaries, not a Rust
SSH library: every pure-Rust option either pulls C deps (libssh2) or forces tokio and can’t
reuse sshelf’s SSH_ASKPASS/ProxyJump auth. To support password hosts without a fragile PTY,
sshelf authenticates once by opening a backgrounded ssh ControlMaster (reusing
build_args + the askpass env exactly as connect does); sftp/scp then ride it with only
-o ControlPath, so there is no re-auth and no per-file prompt. A spike against a local sshd
confirmed that (a) SSH_ASKPASS supplies the secret to open the master and (b) sftp/scp
ride it for put/get and recursive copies. The ride commands deliberately omit -p/-i/-J
(the master already carries them) — which also avoids the ssh -p vs sftp/scp -P port-flag
clash. Rejected: ssh2/wezterm-ssh (C deps), russh/openssh-sftp-client (tokio + no askpass
reuse), and a PTY password screen-scraper (brittle, locale/version-dependent).
Update (transfers use sftp, not scp): listing and copying both run through sftp
(ls/get/put). scp was dropped after a filename with spaces failed in testing — OpenSSH 9+
scp speaks the SFTP protocol and takes the remote path literally, so shell-quoting it (needed
by legacy scp) injects literal quotes. sftp quotes via its own command parser consistently
across OpenSSH versions, so one quoting rule (shell_quote) is correct everywhere.
D-018 · Configurable hosts file in config; config file via flag/env only
A hosts_file key in config.toml relocates the host DB (editable via the F2 settings screen,
default under the config dir). The config file’s own location can’t be a config key
(bootstrap/circular), so it’s set with --config / $SSHELF_CONFIG only and shown read-only in
settings. The --config flag is plumbed by setting $SSHELF_CONFIG once at startup so every
Paths::resolve() (incl. subcommands) sees it uniformly. Vault/state stay in the XDG data dir,
so askpass is unaffected by a custom config. On hosts-file change, an existing target is adopted
(never overwritten) and config is committed only after the hosts step succeeds (so a bad path
can’t brick startup). Designed to grow (more settings fields later).
D-017 · Pick keys via a file browser; detect keys by header
The Key field cycles ~/.ssh keys with ←/→ and opens an in-TUI file browser on Enter
so users can pick a key anywhere (e.g. an AWS .pem in ~/Downloads) without typing a
path. Key discovery detects private keys by a PRIVATE KEY header (not just a .pub sibling),
so .pem/keyless keys are found. Chosen over a path text field (the user explicitly didn’t
want to paste paths) and over scanning many fixed locations (a browser is more general).
D-016 · Auth-aware wizard with a single-key picker
The add/edit form shows only the fields relevant to the chosen auth method, and key auth uses
a picker over ~/.ssh keys (files with a .pub sibling) rather than a freeform path field.
Matches the user’s request and reduces clutter. Trade-off: the picker selects one key; a host
with multiple identity files keeps them on edit, but adding several is done via hosts.toml
(the model still supports Vec). Discovery uses OsString (no lossy UTF-8) so keys aren’t missed.
D-015 · askpass answers password + passphrase, matched by prompt shape
The helper now supplies the host’s stored secret for both login-password and key-passphrase
prompts (a host uses one auth method, so one secret suffices), enabling auto-supply for
encrypted keys. To prevent a keyboard-interactive server from phishing the secret, matching is
by OpenSSH prompt shape (ends-with password: / contains passphrase for), not a bare
substring. Connect wires SSH_ASKPASS only when a stored secret exists (wire_askpass).
D-014 · age vault uses scrypt (passphrase recipient), not Argon2id
The earlier plan said Argon2id; age’s passphrase mode actually uses scrypt + ChaCha20-Poly1305.
We use age’s built-in passphrase encryptor rather than composing a KDF/AEAD by hand (avoids
nonce-reuse/parameter footguns). Docs corrected to say scrypt.
D-013 · Secret backend chosen by SSHELF_VAULT_PASSPHRASE (v1)
OS keyring by default; if SSHELF_VAULT_PASSPHRASE is set, use the age vault instead. Chosen
over runtime keyring-availability detection + an interactive passphrase modal because it’s
deterministic, scriptable (headless/CI), and avoids a TUI passphrase prompt plus askpass-side
unlock in v1. Trade-off: headless users set the env var (shell profile / systemd). Auto-detect
fallback + interactive prompt are future enhancements. A set-password CLI provisions secrets
without the TUI.
D-012 · Project name: sshelf
Chosen over ssh-tui (generic), sssh (one keystroke from ssh, typo-prone), hopp (low
discoverability). sshelf = “a shelf for your SSH hosts”: brandable, memorable, still
contains “ssh” for search discoverability. Confirmed available on crates.io.
D-011 · Docs-in-sync rule
Every code/behavior change updates docs/ + docs/progress.md in the same change; the rule
lives in CONTRIBUTING.md. Rationale: keep a publishable, never-stale knowledge base for an
open-source project and its contributors.
D-010 · License: dual MIT OR Apache-2.0
Rust ecosystem norm (ratatui, ripgrep, crossterm). Maximizes downstream compatibility vs. single MIT or AGPL. AGPL rejected (limits commercial adoption for a CLI tool).
D-009 · Platforms: macOS + Linux only (v1)
exec() process replacement is Unix-only and the secret backends differ on Windows. Windows
would need a separate spawn+wait path + Credential Manager — deferred to a later version.
D-008 · Frecency = use_count * exp(-decay_rate * days_since_last_used)
Mozilla Places–style. Simple, explainable, self-adjusting. Idle list sorts by frecency;
while typing, fuzzy score dominates and frecency breaks ties. decay_rate (default 0.2) is
configurable. Rejected: pure recency (ignores frequency), pure alphabetical (ignores usage).
D-007 · Read-only import via ssh2-config
Best-maintained Rust SSH-config parser. It intentionally skips Match/Include, so import
must warn and degrade, not silently drop. We never write back to ~/.ssh/config.
D-006 · Config/data paths: etcetera base strategy (XDG everywhere)
~/.config/sshelf on both macOS and Linux (honoring XDG env vars). Rejected directories
crate’s native strategy, which buries macOS files in ~/Library/Application Support — worse
for a hand-editable CLI tool. State/vault go in the XDG data dir.
D-005 · Host DB format: TOML (hosts.toml), not SQLite
Human-readable and hand-editable — matches the “my own transparent store” intent; host counts are small (tens–hundreds). Atomic writes (temp+rename) prevent corruption. One research stream suggested SQLite for indexed frecency queries; rejected for v1 as overkill, but it’s a clean future migration if scale demands.
D-004 · Frecency state separate from hosts.toml (state.json)
Mutable counters churn on every connect; keeping them out of the user-owned host file keeps
that file stable and diff-friendly. Keyed by stable host id so renames preserve history.
D-003 · Two-tier secrets: OS keyring primary + age vault fallback
keyring (Keychain / Secret Service) for desktops; an age-encrypted vault (master passphrase,
in-memory per session) for headless/minimal Linux with no Secret Service daemon — exactly the
boxes this tool targets. age (used by atuin) chosen over hand-rolled Argon2+ChaCha to avoid
error-prone crypto. Secrets are never stored in hosts.toml.
D-002 · Password auto-supply: SSH_ASKPASS (+ REQUIRE=force), not sshpass
Our own binary is the askpass helper (detected via SSHELF_ASKPASS=1; ssh calls it as
sshelf "<prompt>"). No external dependency; secret never appears in ps/argv. Mandatory
consequence: the helper must inspect argv[1] and only answer password prompts, and we set
-o StrictHostKeyChecking=accept-new to keep host-key prompts away from it. Validated by the
M0 spike before anything builds on it. Rejected sshpass: not installed by default, exposes
the password in the process table.
D-001 · Connect = tear down TUI then exec() into ssh (exit-to-shell)
User chose exit-to-shell over return-to-list. exec() (process replacement) gives ssh the
real TTY cleanly. Consequence: nothing runs after exec(), so frecency is persisted before
the handoff. Rejected spawn+wait (would be needed only for return-to-list).
D-000 · Stack: Rust + ratatui + crossterm, sync event loop, component pattern
Matches atuin’s look/feel (user preference). ratatui 0.30 requires Rust 1.88+
(rustup update mandatory). Synchronous crossterm::event::read() loop — no tokio, since the
only long-running task (the SSH session) happens after the TUI exits. Component-per-screen
structure over the Elm pattern for this app’s modal UI.
Packaging & distribution
How sshelf ships to Homebrew (macOS + Linux), Debian/Ubuntu (.deb), RedHat/Fedora
(.rpm), and crates.io, for x86_64 and arm64. Releases are driven from GitHub Actions on
a vX.Y.Z tag.
Chosen stack:
dist(cargo-dist) builds the binaries for all targets, makes the GitHub Release (tarballs + checksums), generates the Homebrew formula and pushes it to your tap, and emits a curl-able shell installer.- Hand-written companion workflows attach what dist doesn’t build to the same Release, each
triggered via
workflow_runafter dist’s “Release” completes (so they never race to create it):release-deb.yml(.deb, viacargo deb),release-rpm.yml(.rpm, viacargo generate-rpm, built as a static musl binary), andrelease-crates.yml(cargo publishto crates.io). - clap generates shell completions + a man page (via
sshelf completions/sshelf man). - crates.io: cargo-dist has no built-in crates.io publish job, so
release-crates.ymlrunscargo publish(needs aCARGO_REGISTRY_TOKENrepo secret; skips cleanly if it’s unset).
GitHub user is max-rh; the repo is github.com/max-rh/sshelf; the Homebrew tap is
max-rh/homebrew-tap.
Contents
- Prerequisites
- Target matrix (x86 + arm)
- Homebrew + tarballs via dist
- Debian/Ubuntu
.deb - Shell completions & man page (clap)
- macOS code signing & notarization
- Cross-compilation reference
- Release checklist
- Appendix: manual Homebrew formula & APT repo
1. Prerequisites
Set in Cargo.toml before the first release:
[package]
# ...existing fields...
repository = "https://github.com/max-rh/sshelf"
homepage = "https://max-rh.github.io/sshelf" # the GitHub Pages docs site
readme = "README.md"
# `exclude` keeps the published crate lean (drops docs/, .github/, examples/, the gif, etc.).
# `authors` is optional (cargo no longer auto-fills it) — omit it, or use a project ALIAS,
# never your personal email: this file is public on GitHub and copied into every .deb.
Email/privacy: the
maintainerin[package.metadata.deb]is shipped in every.deband therepositorylink already gives users a way to reach you (Issues). Use a dedicated alias (e.g. a Gmail+tag, a forwarding address, orsshelf@yourdomain), not your private inbox.
Already in place in this repo:
[package.metadata.deb](forcargo deb) and the.github/workflows/release-deb.ymlworkflow.sshelf completions <shell>andsshelf mansubcommands (clap).- Dual
MIT OR Apache-2.0license, committedCargo.lock, MSRV1.88.
Conventions / facts that matter:
- A release is a git tag
vX.Y.Zwhose number matchesCargo.toml’sversion. - Ship prebuilt binaries (Debian/Ubuntu’s packaged
rustcoften predates our MSRV 1.88). - sshelf
execsssh→ the.debdepends onopenssh-client; macOS hassshbuilt in. - Linux secrets use a pure-Rust Secret Service client — no
libdbus/OpenSSL/tokioC build deps — so cross-compiling is easy and the.debneeds no-devpackages. The Secret Service daemon is aRecommends(theage-vault fallback exists).
2. Target matrix
| OS / arch | Rust target | Built by |
|---|---|---|
| macOS Apple Silicon | aarch64-apple-darwin | dist, on an arm64 macOS runner |
| macOS Intel | x86_64-apple-darwin | dist (cross on the arm64 runner) |
| Linux x86_64 (Debian/Ubuntu amd64) | x86_64-unknown-linux-gnu | dist + .deb on ubuntu-22.04 |
| Linux arm64 (Debian/Ubuntu arm64) | aarch64-unknown-linux-gnu | dist + .deb on ubuntu-24.04-arm |
Linux x86_64/arm64 static (the .rpm) | *-unknown-linux-musl | release-rpm.yml (cargo generate-rpm) |
- GitHub’s free arm64 Linux runners (
ubuntu-24.04-arm, GA for public repos since Aug 2025) build arm64 natively — no QEMU. (They aren’t available to private repos on the free tier.) - macOS runners:
macos-14/macos-15are arm64,macos-13is the last Intel one. dist cross-compilesx86_64-apple-darwinon an arm64 runner (both SDKs are present). *-gnuis correct for.deb;*-muslgives a fully static tarball that runs on any distro (nice for the generic download and Homebrew-on-Linux), but isn’t used for.deb.
3. Homebrew + tarballs via dist
One-time setup
cargo install cargo-dist --locked # installs the `dist` binary
dist init # interactive; safe to rerun anytime
Answer dist init with:
- CI: GitHub.
- Installers:
shellandhomebrew. - Targets:
aarch64-apple-darwin,x86_64-apple-darwin,x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu(add the two*-musltargets if you want static tarballs). Decline Windows — sshelf is Unix-only; removex86_64-pc-windows-msvcif it’s added. - Updater: no (
install-updater = false) — Homebrew/apt self-update, and the shell-installer audience is small. - Homebrew tap:
max-rh/homebrew-tap. Create that repo first and initialize it with a README (so it has a default branch dist can push the formula to). Then add aHOMEBREW_TAP_TOKENsecret to thesshelfrepo — a PAT with write access to the tap repo, because the defaultGITHUB_TOKENcan’t push to another repo. Without it thepublish-homebrew-formulajob fails.
dist init writes its config to dist-workspace.toml and generates
.github/workflows/release.yml. Let dist init/dist generate manage it (it pins
cargo-dist-version to your installed version). Our config:
[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.32.0" # managed by dist; don't hand-edit
ci = "github"
installers = ["shell", "homebrew"]
tap = "max-rh/homebrew-tap"
targets = [
"aarch64-apple-darwin", "x86_64-apple-darwin",
"x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu",
]
publish-jobs = ["homebrew"]
install-path = "CARGO_HOME"
install-updater = false
Drop the Windows target:
dist initaddsx86_64-pc-windows-msvcby default. sshelf is Unix-only (the connect path usesexec()), so the Windows build can’t compile — remove that target fromtargets, leaving the four above.
Releasing
# bump version in Cargo.toml, commit, then:
git tag v0.1.0
git push origin v0.1.0
The tag triggers release.yml (dist): it builds every target, creates the GitHub Release
(tarballs + dist-manifest.json + shell installer), and updates the formula in
max-rh/homebrew-tap. When that workflow finishes, release-deb.yml runs via workflow_run
and attaches the .debs to the Release (§4) — sequenced, not racing.
Users then:
brew install max-rh/tap/sshelf # macOS or Linux, picks the right arch automatically
# or the shell installer dist prints in the release notes:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-rh/sshelf/releases/latest/download/sshelf-installer.sh | sh
macOS signing: Developer ID signing + notarization need the paid Apple Developer Program and are optional — a CLI installed via Homebrew runs fine unsigned. Do add a free ad-hoc
codesignstep for a stable signature. See §6 for the full free-vs-paid breakdown.
Completions in Homebrew: dist’s generated formula installs the binary. Shell completions are available immediately via
sshelf completions <shell>(§5); the.debinstalls them system-wide. If you want Homebrew to install them too, use the manual formula in the appendix withgenerate_completions_from_executableinstead of dist’s formula.
4. Debian/Ubuntu .deb
dist doesn’t build Debian packages, so a companion workflow does. The package metadata is
already in Cargo.toml:
[package.metadata.deb]
maintainer = "max-rh <max-rh@mail.com>" # public alias, not a personal inbox
depends = "$auto, openssh-client" # we exec ssh
recommends = "gnome-keyring" # Secret Service daemon (vault is the fallback)
section = "utils"
# ...assets: the binary, README/SECURITY, and the generated completions + man page...
The workflow .github/workflows/release-deb.yml builds
natively on each arch (ubuntu-22.04 for amd64, ubuntu-24.04-arm for arm64), generates
completions + the man page with the sshelf subcommands, runs cargo deb --no-build, and
attaches target/debian/*.deb to the Release. It triggers on workflow_run — i.e. after
dist’s release.yml completes — so the two never race to create the Release: dist owns creation,
this only attaches. (A release: published trigger wouldn’t fire, because dist creates the
Release with GITHUB_TOKEN, and GITHUB_TOKEN-created events can’t trigger downstream workflows.)
Outline:
on:
workflow_run: { workflows: ["Release"], types: [completed] }
jobs:
deb:
# only after a successful, tag-triggered Release run; head_branch is the tag
if: github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
strategy:
matrix:
include:
- { arch: amd64, os: ubuntu-22.04 }
- { arch: arm64, os: ubuntu-24.04-arm }
steps:
- uses: actions/checkout@v4
with: { ref: "${{ github.event.workflow_run.head_branch }}" } # the release tag
- run: cargo build --release --locked
- run: | # generate the packaged extras
bin=target/release/sshelf
"$bin" completions bash > dist-extra/sshelf.bash # + zsh, fish
"$bin" man > dist-extra/sshelf.1
- run: cargo deb --no-build # .deb arch == runner arch
- uses: softprops/action-gh-release@v2
with:
tag_name: "${{ github.event.workflow_run.head_branch }}"
files: target/debian/*.deb
Users install a downloaded package with:
sudo apt install ./sshelf_0.1.0-1_amd64.deb # resolves deps (openssh-client, …)
For a true apt install sshelf (no file download), host a signed APT repo — see the
appendix. That’s the most involved channel; the .deb-on-Releases above covers
most users.
4b. RedHat/Fedora .rpm (static musl)
Same shape as the .deb, with two differences. The package metadata is in Cargo.toml
([package.metadata.generate-rpm], built by cargo-generate-rpm),
and the binary is built static musl (*-unknown-linux-musl) so one .rpm runs on any RPM
distro — Fedora, RHEL/Rocky/Alma, openSUSE — regardless of glibc version. (sshelf is
distro-agnostic at runtime: it only shells out to the system ssh/sftp/ps/kill, all OpenSSH/
procps, and the Linux keyring is the pure-Rust Secret Service with the age-vault fallback — none
of it is Debian- or RPM-specific.) auto-req = "no" stops rpm from adding bogus shared-lib
Requires to a static binary; we declare openssh-clients explicitly.
.github/workflows/release-rpm.yml mirrors the .deb
workflow: workflow_run after dist’s Release, a matrix of x86_64 (ubuntu-22.04) and aarch64
(ubuntu-24.04-arm, native), rustup target add <musl>, cargo build --target <musl>, generate
completions/man, then cargo generate-rpm --target <musl> (which rewrites the target/release
asset paths to the per-target dir) and attaches the .rpm. Users install with:
sudo dnf install ./sshelf-0.8.0-1.x86_64.rpm # or .aarch64.rpm
Why musl, not glibc like the
.deb: a glibc binary built on the CI runner only runs on distros with an equal-or-newer glibc, which excludes older RHEL. Static musl sidesteps that entirely. (The.debkeeps glibc since Debian/Ubuntu users build/run on a known-recent glibc.)
4c. crates.io (cargo install sshelf)
cargo-dist has no built-in crates.io publish job (publish-jobs only knows homebrew/npm/
custom ./jobs), so publishing is a separate companion workflow,
release-crates.yml: workflow_run after the Release,
then cargo publish --locked. It needs a CARGO_REGISTRY_TOKEN repo secret (a crates.io API
token, ideally scoped to the sshelf crate); the step skips cleanly if the secret is unset, so the
workflow stays green before it’s added. The crate’s Cargo.toml carries the required metadata
(description, license, keywords, categories, repository, homepage) and an exclude
that drops docs//.github//examples/ from the published tarball. cargo publish builds from
the tag’s source, so it’s independent of the release binaries.
cargo install sshelf # once published
5. Shell completions & man page
sshelf generates these itself (clap), so packaging needs no extra tooling:
sshelf completions bash # also: zsh, fish, elvish, powershell
sshelf man # roff man page on stdout
.debships them system-wide (/usr/share/bash-completion/...,/usr/share/man/man1/...) — generated in the deb workflow (§4) and listed in[package.metadata.deb].assets.- Homebrew (dist formula): users can
source <(sshelf completions zsh), or switch to the manual formula (appendix) which auto-installs viagenerate_completions_from_executable(bin/"sshelf", "completions")andman1.install. - Tarball users: the binary is self-sufficient — run the subcommands as needed.
Implementation: src/main.rs builds the clap::Command with Cli::command() and feeds it to
clap_complete::generate(...) / clap_mangen::Man::new(...). No build.rs needed.
6. macOS signing — and why you don’t need the $99 Apple program
Developer ID signing + notarization require the paid Apple Developer Program ($99/yr). You
do not need it to ship sshelf, because it’s a CLI distributed via Homebrew, not a GUI
app. Here’s the free path and exactly what (if anything) you give up.
What macOS actually enforces:
- Apple Silicon refuses to run a binary with no signature — but a free ad-hoc signature satisfies it, and the macOS toolchain applies one automatically when it links the binary. (Intel Macs don’t even require that.)
- Gatekeeper’s “unidentified developer” block only hits files carrying the
com.apple.quarantinexattr, which browsers set on download.curl,git, and Homebrew don’t set it for CLI formulae — so abrew install-ed binary runs with no Gatekeeper prompt, signed or not. (Homebrew’s recent tightening — deprecating--no-quarantine, disabling failing casks in Sept 2026 — targets GUI.appcasks, not CLI formulae like sshelf.)
Free distribution that “just works” (recommended order):
-
Homebrew —
brew install max-rh/tap/sshelf. No quarantine, no Gatekeeper prompt, no Apple account. This is how most open-source Rust CLIs ship. ✅ your main path. -
Build from source —
cargo install --git https://github.com/max-rh/sshelf, or a formula withdepends_on "rust" => :build. Compiled locally → no signing questions at all. -
Ad-hoc sign in CI (free, recommended) — the chosen hardening. Guarantees a stable signature on every macOS artifact, no cert/account/Apple-program. Verified on an Intel build: the default
cargo buildleaves the binary “not signed at all”; one command fixes it:codesign --sign - --force target/<triple>/release/sshelf # ad-hoc, free codesign -dvv target/<triple>/release/sshelf 2>&1 | grep Signature # -> Signature=adhoc(arm64 binaries are auto ad-hoc-signed by the linker — Apple Silicon requires it to run; this step also covers the cross-built x86_64 and settles the Keychain point below.)
Wiring it into dist: dist builds macOS on macOS runners and generates
.github/workflows/release.yml. Add this step to that file’s macOS build job, right after the build, so both arches get a stable ad-hoc identity:- name: Ad-hoc sign macOS binaries (free, stable identity) if: runner.os == 'macOS' run: find target -type f -name sshelf -perm +111 -exec codesign --sign - --force {} \;Re-apply it if you re-run
dist init(which regeneratesrelease.yml). The Linux/.debside needs no signing.
The one thing you lose without paying: a user who downloads the release .tar.gz directly
in a browser gets it quarantined, so Gatekeeper blocks it until they clear it once:
xattr -dr com.apple.quarantine "$(command -v sshelf)" # or: right-click the file → Open
Document that, or just steer direct-download users to Homebrew. Notarization is the only thing that removes this for direct downloads — and that needs the paid program.
Keychain prompt (sshelf-specific): the per-connect Keychain prompt happens when the signature
is unstable (re-built dev binaries) or absent. A released, ad-hoc-signed binary has a
stable identity, and sshelf’s askpass child is the same binary file as the parent, so the
Keychain ACL one creates is honored by the other → no prompt. If a user still hits keychain
friction, the age vault (SSHELF_VAULT_PASSPHRASE) bypasses the OS keychain entirely — the
guaranteed-free fallback (see docs/security.md).
If you ever do pay ($99/yr) for friction-free direct downloads: sign with a Developer ID Application cert under the hardened runtime, then notarize (dist can automate this from CI secrets):
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: NAME (TEAMID)" target/<triple>/release/sshelf
ditto -c -k --keepParent target/<triple>/release/sshelf sshelf.zip
xcrun notarytool submit sshelf.zip --key AuthKey.p8 --key-id KEYID --issuer ISSUER_UUID --wait
(You can’t stapler staple a bare binary/zip — only .app/.dmg/.pkg — but a notarized zip
is fine for Homebrew; ship a stapled .pkg for offline direct downloads.)
7. Cross-compilation reference
- Native (what dist + the deb workflow use): build each target on a runner of that arch.
cross(cross-rs/cross):cross build --release --target aarch64-unknown-linux-gnu(Docker-based; handles the linker/sysroot).- Plain cargo cross-link (Linux):
(Pure-Rust apart from libc, so no othersudo apt-get install -y gcc-aarch64-linux-gnu rustup target add aarch64-unknown-linux-gnu CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ cargo build --release --target aarch64-unknown-linux-gnu-devlibs are needed.) - macOS universal binary: build both Darwin targets, then
lipo -create -output sshelf <arm64> <x86_64>.
8. Release checklist
- Bump
versioninCargo.toml; update aCHANGELOG.md. - CI green (
cargo test,clippy -D warnings,cargo fmt --check). git tag vX.Y.Z && git push origin vX.Y.Z.- Watch
release.yml(dist → tarballs + Homebrew tap + shell installer); when it finishes,release-deb.ymlruns viaworkflow_runand attaches the.debs. macOS artifacts are ad-hoc signed (§6). - Smoke-test one install per channel and connect to a host from inside the TUI (the
real-TTY acceptance check in
docs/progress.md):brew install max-rh/tap/sshelfsudo apt install ./sshelf_*_amd64.debsudo dnf install ./sshelf-*.x86_64.rpmcargo install sshelf(after the crates.io publish lands)
9. Appendix
Manual Homebrew formula (alternative to dist’s)
Use this if you want Homebrew to install completions + the man page, or prefer not to run dist.
Put it in max-rh/homebrew-tap as Formula/sshelf.rb:
class Sshelf < Formula
desc "TUI for managing and connecting to SSH hosts"
homepage "https://github.com/max-rh/sshelf"
version "0.1.0"
license any_of: ["MIT", "Apache-2.0"]
on_macos do
on_arm { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-apple-darwin.tar.gz"; sha256 "…" }
on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-apple-darwin.tar.gz"; sha256 "…" }
end
on_linux do
on_arm { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-aarch64-unknown-linux-gnu.tar.gz"; sha256 "…" }
on_intel { url "https://github.com/max-rh/sshelf/releases/download/v#{version}/sshelf-x86_64-unknown-linux-gnu.tar.gz"; sha256 "…" }
end
def install
bin.install "sshelf"
generate_completions_from_executable(bin/"sshelf", "completions")
(man1/"sshelf.1").write Utils.safe_popen_read(bin/"sshelf", "man")
end
test do
assert_match "sshelf", shell_output("#{bin}/sshelf --version")
end
end
(brew bump-formula-pr can update the version + SHAs on each release.)
Signed APT repository (apt install sshelf)
Host a GPG-signed repo (e.g. on GitHub Pages) with reprepro:
gpg --full-generate-key
gpg --armor --export YOUR_KEY_ID > sshelf-archive-keyring.asc
# apt/conf/distributions: Codename: stable / Architectures: amd64 arm64 / Components: main / SignWith: YOUR_KEY_ID
reprepro -b apt includedeb stable sshelf_0.1.0-1_amd64.deb sshelf_0.1.0-1_arm64.deb
# publish the ./apt tree (dists/, pool/, signed Release/InRelease) to Pages
Users:
curl -fsSL https://max-rh.github.io/sshelf-apt/sshelf-archive-keyring.asc \
| sudo tee /usr/share/keyrings/sshelf.asc >/dev/null
echo "deb [signed-by=/usr/share/keyrings/sshelf.asc] https://max-rh.github.io/sshelf-apt stable main" \
| sudo tee /etc/apt/sources.list.d/sshelf.list
sudo apt update && sudo apt install sshelf
An Ubuntu PPA (Launchpad) is the native alternative but requires vendoring crates
(cargo vendor, dh-cargo) because Launchpad builders have no network — more work than the
signed-repo-of-prebuilt-.debs above for the same apt install UX.
Sources
- dist (cargo-dist): https://opensource.axo.dev/cargo-dist/
- GitHub arm64 Linux runners GA (public repos): https://github.blog/changelog/2025-08-07-arm64-hosted-runners-for-public-repositories-are-now-generally-available/
cargo-deb: https://github.com/kornelski/cargo-deb- Apple notarization: https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution
- Homebrew Formula Cookbook: https://docs.brew.sh/Formula-Cookbook
Progress log
Reverse-chronological. Newest entry on top. Every change to the project adds an entry here (the docs-in-sync rule). Keep entries short: what changed, why, and what’s next.
Current milestone: Distribution — publish to crates.io and ship .rpm packages, plus a leaner
published crate. (Interactive 2FA shipped in v0.8.0.)
2026-06-23 — Distribution: crates.io + RPM packaging
- crates.io: new
release-crates.yml(workflow_run after dist’s Release →cargo publish; needs aCARGO_REGISTRY_TOKENsecret, skips cleanly if unset). cargo-dist has no built-in crates.io publish job —publish-jobsonly knowshomebrew/npm/custom — so it’s a companion workflow like the.deb. .rpm: newrelease-rpm.yml+[package.metadata.generate-rpm](x86_64 + aarch64), built as a static musl binary so one rpm runs on any RPM distro regardless of glibc. ssh works identically on RHEL/Fedora — sshelf only shells out to system ssh/sftp/ps/kill.Cargo.toml: homepage → the Pages docs site, refreshed description + keywords,readme, and anexcludethat trims the published crate 64 → 43 files (drops docs/, .github/, examples/, the gif). Docs synced (packaging.md §4b/§4c, README install, CHANGELOG). Ships in the next release.
2026-06-23 — Interactive 2FA support
- New: hosts can be flagged
requires_2fa(add/edit form toggle, orsshelf add … --2fa). Connecting to one shows a code popup before theexec()handoff; the entered one-time code is passed tosshviaSSHELF_2FA_CODEand answered by the askpass helper at the verification prompt — the same channel that supplies a stored password. - Why: a spike confirmed that a stored-secret connect runs with
SSH_ASKPASS_REQUIRE=force, which routes the keyboard-interactive code prompt to our helper with no terminal fallback, so it failed before. The helper now answers a non-secret prompt with the queued code;configure_askpassforce-wires the helper when a secret exists or a code is queued (so key+2FA works too). The CLI direct-connect path prompts for the code on the terminal. - New module
ui/two_factor.rs;Host.requires_2fa(old files loadfalse);exec_connect/configure_askpasstake an optional code (transfer + forward spawners passNone). Manual entry only — no TOTP seeds stored (rejected: same-vault second factor + a dep). Docs synced (decisions D-022, data-model, ux, structure, README, CHANGELOG). Targets v0.8.0 viafeat/two-factor.
2026-06-21 — Port forwarding: background SSH tunnels (M0–M4)
- New feature: background port forwards that survive sshelf exiting.
Ctrl-fon a host opens a popup (Local-L/ Remote-R/ Dynamic-DSOCKS);F4opens a manager that lists every active forward and stops any. - Each forward is a detached
ssh -Nprocess in its own process group (stdprocess_group(0), no new dep), tracked by PID inforwards.jsonand reconciled against the OS (ps) on launch, on opening the manager, and each tick while it’s open — with a zombie filter and a PID-reuse guard.ExitOnForwardFailure=yes+ a brief readiness poll surfaces bind/auth errors in the popup. An M0 spike confirmed detached survival (PPID→1, own process group); an#[ignore]e2e drives a real-Lthrough a localhost sshd (bind → traffic → busy-port failure → kill). - New modules
forwards.rs,ui/forward_popup.rs,ui/forwards.rs; the sshd e2e helper was extracted into a sharedtestsupportmodule (transfer e2e now uses it too). Docs synced (decisions D-021, data-modelforwards.json, ux, structure, README, CHANGELOG). 168 tests + 2 e2e; clippy-D warnings+ fmt clean. Targets v0.7.0 viafeat/port-forward→ PR → cut-release.
2026-06-17 — Sites: docs + feature complete (M5)
- Docs synced:
decisions.mdD-020 (the Sites ADR),data-model.md([[site]]schema + inheritance/override + back-compat),ux.md(grouped/flat list,site:filter, F3 manager, CLI table),structure.md(ui/sites.rs+ model note), README (feature bullet, keys, usage), CHANGELOG[Unreleased]. - Sites is feature-complete: one-per-host grouping with optional inherited SSH defaults
(bastion/user/port/identity); grouped-when-idle / flat-when-filtering list; wizard chooser; F3
manager with rename + delete cascades; and the full CLI. 145 tests + 1 e2e; clippy + fmt clean.
Ships in v0.6.0 via the usual
feat/sitesbranch → PR → cut-release.
2026-06-17 — Sites: CLI surface (M4)
sshelf add --site NAME(-s) assigns a site (warns, non-fatally, if it isn’t defined yet).sshelf siteslists defined sites with member counts + their defaults;sshelf sites --jsonfor scripts;sshelf sites add NAME [-u/-p/-J/-i]creates one.sshelf listshows a·site·column;--jsonalready carries thesitefield and acommandthat reflects inheritance. Dynamic completion of site names on--site.- 145 tests (add
--site+sitesparse); clippy + fmt clean. Verified end to end. - Next: docs (D-020, data-model, ux, structure, README, CHANGELOG).
2026-06-17 — Sites: wizard chooser + F3 manager (M3)
- The add/edit form gains a Site chooser (←/→ over the defined sites + “(none)”); editing a host preselects its site.
- New F3 sites manager (
ui/sites.rs): a list of sites with add / edit / delete and an inline form for each site’s optional defaults (user/port/jump/identity, with name-uniqueness + port validation). Name edits are tracked as renames; on save the app rewrites member hosts’siteand clears any host whose site was deleted (orphans self-heal). Help overlay + list hint updated (F3,site:, and the previously-missing^t). - Tests: wizard chooser + preselect; manager add/edit/rename/delete/duplicate-reject; an app-level F3 rename-cascade + delete-orphan end to end. 143 tests; clippy + fmt clean.
- Next: CLI (
add --site,sites/sites add, list column, completion), then docs.
2026-06-17 — Sites: grouped/flat host list (M2)
- The host list now groups by site when idle (
── {site} ({n}) ──section headers, sites alphabetical,(no site)last) and shows a flat ranked list with a dim·site·column while filtering.recomputebuilds a groupedorderwhen the query is empty (group_order);orderstill holds host indices only, so selection/navigation are unchanged — the renderer maps the selected host past the non-selectable headers to theListStateindex. - Tests:
group_ordersectioning (case-insensitive,(no site)last); render checks for the grouped headers + the filtered site column. 135 tests; clippy + fmt clean. - Next: the wizard site chooser + F3 sites manager (M3), then CLI (M4).
2026-06-17 — Sites: model + inheritance + search (M1)
- New Site concept: a one-per-host grouping that may carry optional shared SSH defaults
(user/port/jump/identity) member hosts inherit at connect time. Bare site = pure grouping;
per-host fields always override; auth stays per-host. Distinct from many-valued
tags. model.rs:Sitestruct +Host.site: Option<String>(by name) +HostsFile.sites([[site]], sites-first; noformat_versionbump — old files load unchanged). Inheritance viaHost::with_site_defaults(&[Site])(clone, fill only unset fields, id preserved; unknown site name degrades to plain grouping) +find_site(case-insensitive).search_haystackincludes the site.search.rs:parse_querynow also yields an optionalsite:NAMEtoken;rankfilters by it.- Threaded resolution into every Host→ssh-args boundary: TUI connect/yank/transfer, CLI
connect/
-/print-command/list --jsoncommand.App.sitesloaded + persisted (and it follows an F2 hosts-file move). Verified end-to-end viaprint-command+list --json. - 132 tests (model inheritance/degradation,
site:filter, store round-trip + pre-sites back-compat); clippy + fmt clean. No UI yet. - Next: the grouped/flat list (M2), then the wizard chooser + F3 sites manager (M3), CLI (M4).
2026-06-16 — Transfer: --transfer-log diagnostics
- Added a transfer debug log:
sshelf --transfer-log <FILE>(or$SSHELF_TRANSFER_LOG) appends everyssh/sftpcommand the worker runs plus its full stderr toFILE, so a failed transfer can be inspected after the fact (the status line still shows the one-line cause). No secrets are logged — the password reachessshviaSSH_ASKPASS, never argv. The e2e test asserts the log captures the master +get/putcommands. Docs: README,ux.md(CLI table + transfer section),security.md.
2026-06-16 — Transfer: use sftp (not scp) for the copy itself
- Bug found in local testing: transferring a filename with spaces failed
(
scp: failed to upload … to '/…). OpenSSH 9+scpspeaks the SFTP protocol and takes the remote path literally, so the shell-quoting legacyscpneeded became literal quotes in the name. Plain names slipped through because they aren’t quoted. - Fixed by running transfers through
sftpget/putover the same master used for listing —sftpquotes via its own command parser consistently across OpenSSH versions, so the version-dependentscpquoting trap is gone. Removedscp_args/remote_spec; added atransfer_batchunit test and a spaces regression to the e2e test.
2026-06-16 — Transfer screen: transport core + worker
- Started the dual-pane SFTP/SCP transfer screen. Settled the transport (see
decisions.mdD-019): move files over the systemsftp/scpriding a singlesshControlMaster, so keys/agent/ProxyJump and the stored keyring/vault secret are reused unchanged and password hosts work with no PTY. A spike against a local sshd confirmedSSH_ASKPASSopens the master and thatsftp/scpride it (put/get + recursive). - Landed the tested core in
src/transfer/mod.rs: the master/sftp/scpargv builders, theuser@hosttarget + shell-quoted remote-path spec, the worker↔UI message protocol, and progress math. - Added the worker thread + ControlMaster lifecycle (
src/transfer/worker.rs): it opens the master (reusingssh::configure_askpass, nowpub(crate)), polls it ready, lists remote dirs by parsingsftp ls -l, runsscptransfers with throttled progress + mid-flight cancel, and tears the master + control socket down on stop via RAII. 101 tests; clippy + fmt clean. No UI yet; the live end-to-end run lands with the engine milestone. - Added
transfer/pane.rs: one side’s state — fuzzy filter + selection + navigation reused from the key-picker browser, a synthetic..entry,ls -F-style dir/@-symlink labels with control-char stripping, and a local-directory reader. Kept source-agnostic rather than behind aDirSourcetrait (a synchronous remotelist()would block the very UI loop the worker keeps responsive); the screen feeds local entries viastd::fsand remote ones via the worker. 109 tests; clippy + fmt clean. - Wired the screen end to end:
transfer/screen.rs(two panes over one session — local nav is synchronous, remote nav requests via the worker, events drained each tick) andui/transfer.rs(two panes, progress/status line, hint bar;TestBackend-snapshotted via a borrowed view, and a “terminal too small” clamp).Ctrl-ton the list opens it (Outcome::Transfer); the event loop polls + drains while it’s open and tears the worker down on close (RAII). Keys:Tabswitch ·→/Enteropen ·Ctrl-ssend file/folder ·←/Backspaceup ·Esccancel/ clear/close. Docs:ux.mdtransfer section + keybinding. 113 tests; clippy + fmt clean. - Validated the transport end to end against a throwaway localhost
sshd(transfer/e2e.rs,#[ignore]d — run withcargo test -- --ignored): the master opens,sftppwd/lsparse, single-file download + upload (contents verified), and recursive directory download all pass. - Robustness + docs pass: a same-named destination is skipped rather than clobbered;
README gains a feature bullet + the
^tkey,security.mdcovers the transfer network path, andstructure.mdmaps the new modules. Added master-command tests for ProxyJump + password hosts — the auth itself reusesbuild_args/configure_askpass(already tested), and the M0 spike provedSSH_ASKPASSopens the master, so a password target and key/agent jumps work; a fully automated password-auth transfer E2E needs a PAM/Docker sshd (the rootless test server is key-auth only) and is a CI-with-Docker follow-up. - The transfer screen is functionally complete: dual-pane browse + fuzzy on both sides, single-file and recursive folder transfer in both directions over one multiplexed master, live progress, cancel, and overwrite-skip. 116 unit tests + 1 e2e; clippy + fmt clean.
2026-06-13 — CLI: non-interactive add, list –json, dynamic completion, reconnect-last
sshelf addgained flags for a fully non-interactive add (scripts/dotfiles):NAME+-H/--hostnamerequired;-u/-p/-a/-i/-J/-t/--extra/--password-stdin. Auth is inferred (keyfrom--identity,passwordfrom--password-stdin, elseagent).--extraallows hyphen-leading values;--password-stdinkeeps the secret out of argv. Baresshelf addstill opens the TUI form. Duplicate names are refused. (AddArgs::into_hostis pure/tested.)sshelf list --jsonemits each selected host’s fields plus its generatedcommand, always valid JSON (even empty) — the stable surface for integrations.- Dynamic shell completion of host names via
clap_complete(unstable-dynamic):CompleteEnvinmain,ArgValueCandidateson the<host>args of direct-connect /print-command/set-password;host_name_candidatesreadshosts.tomlside-effect-free. Enable withsource <(COMPLETE=<shell> sshelf). sshelf -reconnects to the most-recently-used host (last_used_idover the frecency state); the CLI connect path was factored into a sharedconnect().- 99 tests; clippy + fmt clean; verified end-to-end (add/list –json/password-stdin/completion).
- Docs: README (usage + an “Adding hosts from the CLI” flag table + a “Shell completions”
section) and the
docs/ux.mdCLI table.
2026-06-12 — CLI: print generated ssh command
- Added
sshelf print-command <host>: prints the same shell-quotedssh …command as the TUICtrl-yyank action, without connecting or updating frecency. Useful for scripts, wrappers, and review before running a connection. - Fixed generated command strings to expand identity-file
~before shell-quoting, so yanked or printed commands remain copy-paste runnable. - Docs synced: README usage,
docs/ux.mdCLI table, anddocs/ssh-command.mdbuilder note.
2026-06-07 — Pre-launch hardening
sshelf addnow opens the TUI with the add form ready (app::run_add) — it was a placeholder message. Empty-list hint and internal comments cleaned of milestone references.- Vault env hygiene:
configure_askpassstripsSSHELF_VAULT_PASSPHRASEfrom the child env when no stored secret is wired; kept (and now documented) for vault-mode askpass, which reads it as ssh’s child. Two new env-wiring tests (ssh.rs). - SECURITY.md: concrete private-reporting channels (GitHub advisories + email) replace the
placeholder note; added the vault-mode env-inheritance tradeoff (mirrored in
docs/security.md+docs/ssh-command.md). - CHANGELOG.md added (backfilled 0.1.0 / 0.2.0); README now states the no-network posture
(no telemetry / account / network calls) and documents
sshelf add. - CI: new
cargo audit(RustSec) and MSRV-1.88 check jobs.
2026-06-07 — Release v0.2.0
- Cut v0.2.0: ships the
sshelf <host>direct-connect andsshelf list <query>filter (below). Taggingv0.2.0republishes brew / shell installer /.debvia dist.
2026-06-07 — CLI: direct connect + list filter
sshelf <host>connects straight to a saved host by name/id, skipping the TUI (reuses the TUI connect path: frecency recorded beforeexec, askpass wired only when a secret exists). A miss suggests close names. Clap routes viaargs_conflicts_with_subcommands, so subcommand names win.sshelf list [query]filters with the same syntax as the TUI search box (search::rank): fuzzy text and/ortag:NAME. Plainsshelf listis unchanged.- 88 tests (added clap-routing + host-resolution); clippy + fmt clean. Docs: README usage + a brew
completion-reload note; new
docs/ux.mdCLI section.
2026-06-07 — README demo GIF
- Added an animated demo to the top of the README (
docs/sshelf-readme.gif): fuzzy-search → yank the generatedsshcommand.
2026-06-06 — v0.1.0 released
- First public release is live: dist’s
Releaseworkflow built all four targets, created the GitHub Release (tarballs + shell installer), and published the Homebrew formula;release-debattached the amd64/arm64.debs. All jobs green. - README Install section rewritten for the real channels (Homebrew, shell installer,
.deb, from source).docs/packaging.mdsynced to the shipped setup:dist-workspace.tomlconfig,workflow_runsequencing of the.debjob, and theHOMEBREW_TAP_TOKENprerequisite.
2026-06-06 — Release pipeline: dist (cargo-dist) wired up
dist init: shell + Homebrew installers, 4 Unix targets (mac + linux × x86_64/arm64),install-updater = false. Addedrelease.yml,dist-workspace.toml, and[profile.dist].- Dropped the
x86_64-pc-windows-msvctarget dist added by default — sshelf is Unix-only (the connect path usesexec()), so a Windows build can’t compile. - Reworked
release-deb.ymlto run viaworkflow_runafter the distReleaseworkflow finishes, attaching the.debs to the release dist creates — avoids both workflows racing to create the same release. - Before tagging: create the
max-rh/homebrew-taprepo + aHOMEBREW_TAP_TOKENsecret (PAT) so the Homebrew formula can be published.
2026-06-06 — CI: fix the push trigger
ci.ymllistened onmain, but the default branch ismaster, so direct pushes never ran CI. Now triggers on[master, main].
2026-06-06 — Funding notes: trim public meta-commentary
- Removed the BTC-address caveat from the README Support section (the donate badge + address stay).
- Trimmed the
.github/FUNDING.ymlcomment down to the functional config.
2026-06-06 — Docs: contributor guide + naming polish
- Adopted
CONTRIBUTING.mdas the contributor guide (GitHub-conventional name) and refreshed its cross-references indocs/{index,structure,decisions}.md. - Standardized the “docs-in-sync rule” naming across the docs.
- No code changes.
2026-06-05 — Post-v1: browser fuzzy search, dynamic wizard width, settings screen ✅
- File browser fuzzy search — type to filter the listing (nucleo);
Backspaceedits the filter (else up-dir),Escclears it (else cancels). Sharedui::highlightbetween the host list and browser. - Dynamic wizard width — the add/edit form sizes to the terminal (clamped 56–100), fixing
placeholder truncation; longest placeholders trimmed; placeholders now read
optional ·/required ·. - Settings screen (
F2) +ui/settings.rs: edit the hosts-file location (default shown;~expanded), config-file path shown read-only. Newhosts_fileconfig key;--configflag +$SSHELF_CONFIGenv (plumbed via env so subcommands + askpass-irrelevant paths stay uniform);Config::save/hosts_path;App.hosts_paththreaded through list/import/set-password. - Fix: the hosts-file relocate could overwrite an existing target with the (possibly empty) in-memory hosts → now it adopts an existing file and only writes through to a new path, committing config only on success. Two app-level tests cover both branches.
- Help overlay height bumped (the F2 line was clipping). 84 tests; clippy + fmt clean.
- Deviation to confirm: “custom config file” is via
--config/env (shown read-only in settings), not editable in the wizard — the bootstrap-correct interpretation. - Snapshots:
target/{wizard,browse,settings}-snapshot.txt.
2026-06-05 — Post-v1: .pem keys + in-TUI file browser ✅
Follow-up to the wizard work (user requests):
.pem/ keyless keys are detected —scan_keysincludes any private key by sniffing aPRIVATE KEYheader, not just<name>.pubpairs (AWS keys show up).- File browser (
ui/browse.rs) — the Key field opens it withEnter(←/→still cycles recent~/.sshkeys); navigate dirs and pick a key anywhere without typing a path. A browsed path is stored as the host’s identity even outside~/.ssh. - Placeholders now mark fields
optional ·/required ·. The Key field’s hint becomes “←/→ recent keys · ↵ browse files” when focused. - 75 tests (incl.
scan_keysagainst a temp dir with a.pem, browser nav, Enter→browse); clippy + fmt clean. Snapshots:target/{wizard,browse}-snapshot.txt. - Acceptance gate: the browser + Enter→browse→pick flow is
TestBackend-only; a real-TTY run (open the Key field, browse to a.pem, pick, save, connect) is still pending — folded into gate #2 below.
2026-06-05 — Post-v1: auth-aware wizard, key picker, key-passphrase auto-supply ✅
User-requested wizard improvements:
- Every field shows a dim placeholder explaining it.
- The form is auth-aware — only relevant fields show: key → Key picker + optional Key passphrase; password → Password; agent → neither.
- Key picker cycles private keys discovered under
~/.ssh(files with a.pubsibling). - Key passphrase (optional) is stored as the host secret; askpass now answers passphrase prompts too, and connect wires askpass whenever a stored secret exists (password OR passphrase).
Hardening review — confirmed and fixed:
- the “password NOT stored” message → “secret NOT stored” (applies to key passphrases too);
is_secret_prompttightened to OpenSSH prompt shapes (ends-withpassword:/ containspassphrase for) so a keyboard-interactive server can’t phish the stored secret;discover_ssh_keysno longer uses lossy UTF-8 conversion (won’t miss/corrupt keys);- editing a multi-key host no longer drops the extra identity files.
- Dismissed false alarms: env-clearing already unconditional, the keyring check is fail-closed, multi-key-passphrase is out of scope. Skipped 2 lows (wide-char mask cosmetics; the already documented macOS double-Keychain-prompt on unsigned builds).
- 66 tests; clippy
-D warnings+cargo fmt --checkclean.
2026-06-05 — M8: OSS readiness ✅
- Linux verified for real (Docker
rust:latest): build + all 63 tests pass. The first Linux build caught a bug —sync-secret-servicepulled the Clibdbus-sys(needslibdbus-1-dev). Switched to pure-Rustasync-secret-service+crypto-rust+async-io→ no C/OpenSSL/tokio build deps. (Closes acceptance gate #3.) README.md,SECURITY.md(threat model + macOS-signing note),LICENSE-MIT+LICENSE-APACHE(dual), and.github/workflows/ci.yml(fmt + clippy + build + test on macOS & Linux, plus a headless-vault job that stores/retrieves via the age vault withDBUS_SESSION_BUS_ADDRESSunset — verified locally).cargo fmtapplied repo-wide so the CI format check passes.- 63 tests; clippy
-D warningsclean on macOS and Linux.
2026-06-05 — M7: read-only import from ~/.ssh/config ✅
import.rs:ssh2-config 0.7.1parse (ALLOW_UNKNOWN_FIELDS) →Hostmapping (name, hostname, user, port, identity files; the parser expands~to an absolute path). Skips wildcard patterns; warns aboutMatch/Include/ProxyJump(unsupported).Ctrl-oin the TUI imports all new (non-duplicate-by-name) hosts;sshelf import [--dry-run]does the same from the CLI. Never writes~/.ssh/config.- Verified against the real
~/.ssh/config: parsed 4 hosts read-only (mtime unchanged), correct mapping,--dry-runwrote nothing. - v1 deviation: no in-flight per-host selection UI — it imports all new hosts, then you
curate with edit/delete (recorded in
docs/ux.md). - 63 tests pass; clippy
-D warningsclean.
2026-06-06 — Distribution: dist + .deb + clap completions/man (chosen stack)
Picked the channels (GitHub user max-rh): dist/cargo-dist for Homebrew + tarballs +
shell installer, cargo-deb for Debian/Ubuntu, clap for completions/man, no crates.io.
- Code: added
sshelf completions <shell>andsshelf mansubcommands (clap_complete/clap_mangenviaCli::command()— no build.rs). Verified bash/zsh/fish + roff output. - Packaging:
[package.metadata.deb]inCargo.toml(dependsopenssh-client, recommendsgnome-keyring, ships completions + man);.github/workflows/release-deb.ymlbuilds amd64 (ubuntu-22.04) + arm64 (ubuntu-24.04-arm) natively and attaches.debs to thev*Release (upserts alongside dist’srelease.yml). docs/packaging.mdrewritten around this stack (multi-arch x86+arm, distinitchoices, the deb companion, the macOS signing/Keychain note, manual Homebrew formula + APT repo in an appendix). dist’srelease.ymlitself is generated bydist init(documented).- §6 reframed: no paid Apple Developer Program needed — a CLI via Homebrew runs unsigned
(Homebrew doesn’t quarantine formulae; arm64 just needs the free auto ad-hoc signature).
Paid Developer ID/notarization is optional (only removes Gatekeeper friction for direct
.tar.gzdownloads). Vault stays the free Keychain fallback. - Chose option 3 (free ad-hoc signing): verified on this Intel Mac that a default build is
“not signed at all” and
codesign --sign - --force→Signature=adhoc; documented the exact step + where it slots into dist’srelease.yml(§6). No paid Apple program. - Email: advised an alias (public in
.deb/repo);authorsmade optional. License: keep dual MIT OR Apache-2.0. Funding: BTC only for now (GitHub Sponsors needs a payout setup) — README Support section +.github/FUNDING.yml(custom→README). - Pre-public-push scan: clean (no real keys/personal email/host IPs). Swapped a coincidental LAN
IP in a test for the RFC5737 doc range; set
Cargo.tomlrepository/homepage to max-rh/sshelf. - 84 tests; clippy + fmt clean. BTC address filled in. Ready for the initial public push
(branch
master).
⚠ Unverified paths (acceptance gates before “done”)
These are verified by unit tests but NOT yet exercised on a real path; treat as manual acceptance gates (a sandbox can’t cover them):
- macOS OS-keyring path — only the vault secret path (
SSHELF_VAULT_PASSPHRASE) is verified end-to-end. The default macOS path (no env var → Keychain) is unrun; an unsigned dev build’s re-exec’d askpass child may hit a Keychain access prompt per connect (ACLs are keyed to code signature). Run from a real macOS GUI session; until then, the vault is the recommended setup and is what’s been proven. - The full in-TUI connect chain has never run as one piece. For a password host it is:
real TTY →
exec_connect(which setsSSH_ASKPASS/SSHELF_*env) →exec(ssh)→ ssh re-execssshelf(askpass mode) as a child, which resolves paths + fetches the secret. The M5 E2E hand-set the env and calledsshdirectly — it did not go throughexec_connect; andTestBackenddoesn’t touch raw mode / alt-screen. Acceptance test: connect to a real password host from inside the TUI (not justssh), and exercise the key file browser (open the Key field →Enter→ browse to a.pem, type-to-filter → pick → save → connect) and the F2 settings relocate (change the hosts file, confirm it adopts/relocates correctly). If macOS Keychain prompts on every connect for the unsigned dev build, that’s expected → use the vault or a signed build. Linux build— ✅ closed (M8): built + tested in Dockerrust:latest(63 tests pass) with the pure-Rustasync-secret-servicebackend; CI now builds/tests Linux + a headlessDBUS_SESSION_BUS_ADDRESS-unset vault job. (First real CI run still pending.)
2026-06-05 — M6: tags, config, theme, frecency wiring ✅
- Tag filtering (the explicitly-chosen v1 feature):
tag:NAMEtokens in the query AND every tag (case-insensitive, exact); remaining words fuzzy-match. Combine freely (tag:prod web). Help overlay + hint bar updated. default_sortwired into the TUI (was list-CLI only): empty query honors frecency-or-name from config.config.tomlmade real: a commented default is written on first run (TUI orlist), withdecay_rate,default_sort, and a newaccentcolor (themes the UI via a one-time color cell). Default-template parse is tested.- Deleted dead
error.rs(committed fully toanyhow). - 59 tests pass; clippy
-D warningsclean. Verified default config write + tag filter.
2026-06-05 — M5: secrets + password auto-supply ✅ (verified end-to-end)
vault.rs: age-encrypted (age 0.10.1, scrypt + ChaCha20-Poly1305)host_id → passwordmap; store/get/delete + atomic writes.secrets.rs: routes to the OS keyring by default, or the vault whenSSHELF_VAULT_PASSPHRASEis set (deterministic, headless/CI-friendly).keyring 3.6.3with per-target backends (apple-native / sync-secret-service / windows-native).askpass.rs: headlessSSH_ASKPASSmode — inspectsargv[1], answers only password prompts (fetches bySSHELF_HOST_ID), declines everything else with exit 1.ssh.rs:configure_askpasssetsSSH_ASKPASS/REQUIRE=force/SSHELF_ASKPASS/SSHELF_HOST_IDfor password hosts only, clearing inherited askpass otherwise.- Wizard gained a masked Password field; save stores the secret; delete removes it.
- New
sshelf set-password <name|id>CLI (reads stdin) for headless/scripted provisioning. - End-to-end verified with the real binary against the live password sshd:
set-password→ vault; askpass returns the secret for a password prompt and declines a host-key prompt (exit 1); and a fullssh(SSH_ASKPASS=sshelf) logged in with no prompt (PW_AUTOSUPPLY_OK). - 54 tests pass (vault round-trip, prompt classification, wizard password capture); clippy clean.
2026-06-05 — M4: add / edit / delete ✅
ui/widgets.rs: hand-rolled single-lineTextField(insert/backspace/cursor moves).ui/wizard.rs: full-screen add/edit form (9 focusable fields: name, hostname, user, port, auth toggle, identity, jump hosts, tags, extra args) with inline validation; returnsWizardOutcome {Continue, Cancel, Save(Host)}. Chose a single-screen form over a paged wizard (simpler/editable);ux.mdupdated.app.rs:Ctrl-aadd,Ctrl-eedit (prefilled),Ctrl-ddelete (confirm popup). Save upserts by id and writeshosts.tomlatomically; delete also drops the frecency entry.- Verified add-persists-to-disk and delete via tests (incl. reload-from-disk). Wizard render
snapshot at
target/wizard-snapshot.txt. - 46 tests pass; clippy
-D warningsclean.
2026-06-05 — M3: connect via exec() + yank ✅
ssh.rs:build_args(-iper key with~expansion,-ponly if non-22,-Jcomma chain,-o StrictHostKeyChecking=accept-new, shlex-split extra args,user@host);command_string(readable, tilde-preserved, for yank);exec_connectviaCommandExt::exec(unix process replacement);copy_to_clipboard(arboard, best-effort).app.rs:Enter→Outcome::Connect,Ctrl-y→Outcome::Yank. Connect defers to afterratatui::restore():runrecords frecency + saves state, thenexecs ssh (clean TTY). Panic-safety is handled by ratatui’sinit()panic hook (no separate RAII guard needed).- Added
shlex 2.0.1,arboard 3.x(no-default-features, text-only). - Verified: recreated the spike sshd with a public key and connected with the exact
build_argsflag set (-i … -p 2222 -o StrictHostKeyChecking=accept-new tester@127.0.0.1) →CONNECT_OK. (Interactive TUI→exec is TTY-only; argv logic is unit-tested and the live connection is proven here.) - 33 tests pass; clippy
-D warningsclean (collapsed nested ifs into 1.88 let-chains).
2026-06-05 — M2: core TUI (list + fuzzy search) ✅
Added ratatui 0.30.0 + nucleo-matcher 0.3.1. The atuin-style launcher renders: search box
(with matched/total in the title), highlighted fuzzy list, contextual hint bar, F1 help overlay.
search.rs: nucleo fuzzy ranking; empty query → frecency order, else score desc with frecency tiebreak;match_indicesfor per-char highlight.app.rs:App+ pureon_keyreturningOutcome {Continue, Quit, Connect(idx)}, plus the sync event loop usingratatui::init()/restore(). Single-mode search → Ctrl-based actions (resolved the plain-letter-vs-typing conflict;ux.mdupdated).ui/{mod,list,help}.rs: rendering as pure fns of&App, verified withTestBackend(no TTY). ASCII snapshot written totarget/tui-snapshot.txt.- 25 tests pass; clippy
-D warningsclean. Connect currently shows a placeholder status; the realexec()handoff is M3.
2026-06-05 — M1: scaffold + persistence ✅
Crate sshelf (edition 2024, rust-version = 1.88, license MIT OR Apache-2.0) builds clean
with clippy -D warnings; 12 unit tests pass.
- Deps resolved:
serde 1.0.228,toml 1.1.2,serde_json 1.0.150,etcetera 0.11.0,clap 4.6.1,thiserror 2.0.18,anyhow 1.0.102,ulid 1.2.1. - Modules:
model(Host/AuthMethod/HostsFile + ULID ids),paths(XDG viaetcetera::Xdg→~/.config/sshelfconfirmed on macOS),store(TOML load/save + atomic temp+rename),state(frecency:use_count/last_used,score = count·e^(−decay·days)),config(decay_rate, default_sort),error(typedSshelfError). main: clap CLI (list/add/import), askpass-via-env dispatch stub,listworks and sorts by frecency. Verified end-to-end againstexamples/hosts.sample.toml.- Forward-declared API (
save_hosts,atomic_write,state::save/record_use,Host::new,find,search_haystack) carries#[allow(dead_code)]+ a milestone note; each allow is removed as the function is wired up. - Note: cargo defaulted to edition 2024; updated the project guide accordingly.
2026-06-05 — M0: askpass mechanism validated (spike) ✅
Empirically validated the password auto-supply design against a real password-auth sshd
(Docker lscr.io/linuxserver/openssh-server, OpenSSH 10.2 client) on macOS. Also bumped the
toolchain: Rust 1.74 → 1.96.0 via rustup update (clears the ratatui 0.30 MSRV gate).
- Test 1 (success):
SSH_ASKPASS=helper SSH_ASKPASS_REQUIRE=force+PreferredAuthentications=passwordStrictHostKeyChecking=accept-new→ logged in, exit 0. ConfirmsSSH_ASKPASSsatisfies interactivePasswordAuthentication(not just key passphrases). The helper receivedargv[1] = "tester@127.0.0.1's password: ".
- Test 2 (host-key routing): with
StrictHostKeyChecking=ask+ fresh known_hosts, ssh sent the helper the host-key prompt ("…Are you sure you want to continue connecting (yes/no/[fingerprint])?"), and a naive “always return the password” helper caused an infinite loop on"Please type 'yes', 'no' or the fingerprint:". - Conclusions (both already in the design): the helper must inspect
argv[1]and answer only password prompts (exit non-zero otherwise), and we must pass-o StrictHostKeyChecking=accept-newso the host-key prompt never reaches it. See ssh-command.md §3. - Spike container kept running (
sshelf-spike, host port 2222) for reuse in M5.
2026-06-05 — Documentation foundation
- Created the project guide (the docs-in-sync rule + the hard project invariants).
- Created the
docs/tree:index,progress,architecture,structure,data-model,ssh-command,ux,decisions,security— all seeded from the project plan. - No Rust code yet. Toolchain still on Rust 1.74 — must
rustup updateto 1.88+ before M1. - Next: M0 askpass spike (validate password auto-supply on macOS + Linux before building on it).
Milestones
Tracking against the project plan. Status: ⬜ not started · 🟡 in progress · ✅ done.
| # | Milestone | Status |
|---|---|---|
| — | Docs foundation (project guide + docs/) | ✅ |
| M0 | Spike SSH_ASKPASS password mechanism | ✅ (macOS; Linux pending in CI) |
| M1 | Scaffold crate + persistence (paths/model/store, clap, licenses) | ✅ |
| M2 | Core TUI: list + fuzzy search + highlight + hint bar | ✅ |
| M3 | Connect via exec() handoff (key/agent hosts) + yank | ✅ |
| M4 | Add/Edit/Delete wizard (+ quick-add) | ✅ |
| M5 | Secrets (keyring + age vault) + password auto-supply (askpass) | ✅ |
| M6 | Polish: frecency tuning, tags, config, help, theme | ✅ |
| M7 | Read-only import from ~/.ssh/config | ✅ |
| M8 | OSS readiness: README, SECURITY, CI, licenses | ✅ |
The full milestone detail lives in the project plan.