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: v0.16.0, secrets from a password manager. v0.15.0 (add a host from an ssh command, save the secret on first connect, and the upload fix) is tagged on Sep 14 and not pushed.
2026-09-14: v0.15.0, add a host from an ssh command, and save the secret on first connect
Two features, one bounded fix, and a tmux change, released together with the upload fix below.
sshelf add --from-ssh. The new sshcmd module reads an ssh command line with OpenSSH’s own
option grammar and maps it onto a host: user@host or an ssh:// URL, -l, -p, -i, -J, and
password auth when -o PasswordAuthentication=yes or PreferredAuthentications=password says
so. Everything else goes into extra_args, quoted only where a word needs it so the connect-time
split gives the same words back, and -v, -N, -f, -o StrictHostKeyChecking and a few more
are dropped with a note each. It opens the add form filled in (Wizard::prefill, focus on the
password for a password host), or with --quiet adds it the way a flag-built add does. The
parser follows ssh in reading options after the destination, since ssh re-reads them, so only the
first plain word after the destination counts as the remote command that gets refused. D-034.
The pipe case took more than expected. crossterm 0.29 opens /dev/tty when stdin isn’t a
terminal, but on macOS its event source registers that with kqueue, and the form stopped at
Failed to initialize input reader. A small kqueue probe in a real terminal showed /dev/tty
failing with EINVAL while /dev/ttys004 worked, so reopening /dev/tty onto fd 0 would have
failed the same way. The prefilled path reads what it needs from the pipe, then dup2s the device
ttyname reports for stdout (or stderr) onto fd 0 before the TUI starts. With no terminal at all
the host is added the --quiet way, with a note.
The secret, saved on first connect. A connect to a password host with nothing stored, or to a
key host whose key needs a passphrase, asks on the terminal before the handoff, stores the answer,
proves it with one ssh ... exit, and removes it again if that fails. An encrypted key is probed
with BatchMode=yes first, so a key already in the agent is never asked about. 2FA hosts store
without the check. Both exec paths now share one tail in main.rs (handoff), and the terminal
reader behind the 2FA prompt is shared with the new secret prompt without changing the code
prompt. In the TUI, a 2FA host that would be asked skips its code popup so both questions come on
the terminal, secret first. tmux mode falls back in place with its own reason. D-035.
A refused stored secret says so. Every wired command carries SSHELF_CONNECT_ID, and the
helper leaves askpass-<id> in the runtime directory the first time it answers a secret prompt.
The same prompt again prints one line naming set-password and ^e, and declines. The secret
stays. The marker holds the prompt rather than nothing, so a host with two encrypted keys isn’t
told its passphrase was refused when ssh moves on to the second key. The helper’s stderr reached
the terminal of a real connect, so the NumberOfPasswordPrompts=1 fallback wasn’t needed. The
runtime directory lookup moved from the transfer worker to paths::runtime_dir.
tmux -d. tmux_connect_args now passes -d for windows and panes both, so focus stays on the
picker, which is what search-connect.md and index.md described all along. I changed the code
rather than the docs: firing off several hosts in a row is the point of the mode. D-025 reworded.
Found on the way. OpenSSH 10.3 prints at most 100 bytes of a key’s path in its passphrase prompt
(seen directly: a 120-byte path came through cut at .../scratch), and the helper matches the path
exactly, so a stored passphrase for a key with a longer path is never supplied. The first connect
now skips such keys instead of asking for a passphrase and then calling a correct one refused.
Changing the helper’s matching is a separate piece of work. Also separate: an error message with a
| in it prints as a bare Error:, because the shared printer drops those lines when it folds a
toml diagram. The empty-stdin messages of set-password and --password-stdin already did that
before this release; the new --from-ssh message is printed directly to avoid it.
Verification. Unit tests cover the parser table, the add routing and the flag conflicts,
Wizard::prefill, the trigger matrix, the probe and verify exit rules, the connect id on the wired
and unwired paths, and the marker. tests/add_from_ssh.rs drives the real binary: the motivating
line with --quiet gives a key host with ~/Downloads/dev-ooblek-privco.pem, user ubuntu, no
extra args and the expected print-command argv, and stdin carries the line and then the secret.
tests/first_connect_e2e.rs runs (ignored) against a rootless sshd with password auth on: a wrong
password is refused and nothing is kept, Enter skips, an encrypted key is asked for, checked and
kept while a ps watcher finds the passphrase in no argv, a wrong passphrase is removed, a key in a
throwaway agent is never asked about, an unencrypted key never prompts, a 2FA host asks for the
password before the code and says it wasn’t checked, and a stale stored password prints the
refused line once and stays stored.
By hand, in a private tmux server with a throwaway vault: the piped form opens filled in with focus
on Name and Esc adds nothing; the password prompt reads with echo off and a ps scan across the
store and verify found nothing; Enter on an encrypted-key host in the TUI asks, saves and logs
in, and the second connect goes straight in; in tmux mode a password host with nothing stored
prints the new reason and connects in place, and a key host opens a background window while the
picker keeps focus; a stale stored password prints the refused line on the terminal, and the
transfer screen says the stored password was refused. ~/.ssh hashed the same before and after.
The two things the rootless sshd couldn’t show were checked afterwards against a real password
server, lscr.io/linuxserver/openssh-server in Docker, which offers keyboard-interactive as well.
A wrong password on first connect is refused and nothing is kept. The right one prints
saved password, the server logs Accepted password for the verify and again for the session,
and the next connect goes straight in; a ps scan across the prompt, the store and the verify
found the password in no argv. The TUI path asks after teardown, saves, and lands in a shell. A
stale stored password prints the refused line once and stays stored. With the secret in the macOS
Keychain instead of the vault (a throwaway entry, deleted afterwards), tmux mode opened the host in
a background window that logged in through the helper while the picker kept focus, and the debug
build’s helper read the entry without an approval prompt.
2026-09-14: uploads install with a no-replace link, like downloads always have
A reader on Reddit read the comment in transfer/screen.rs saying uploads cannot use the
install step downloads use, and asked what happens if the remote destination changes between
the listing check and the transfer. Nothing did. put creates and truncates, so a file that
appeared on the server after the check was overwritten, which is the one thing the transfer
screen promises never to do.
The docs were also optimistic about the size of that window. docs/transfer.md said the
listing is refreshed immediately before each send, and it is, but the refresh lands too late
for that send’s own check: send() checks the listing the pane is already showing. So the
first item of a queue was checked against whatever was last on screen, and later items against
a listing taken before the previous transfer started.
An upload now does what a download does. put writes a .sshelf-part- temporary into the
destination directory, and sftp’s ln installs it under the real name, which the server
refuses if anything is there, symlinks included. A refused link is followed by an ls to find
out whether the name was taken (protocol 3 reports every refusal as Failure), and the entry
comes back as the same skip the pre-flight check raises. Where the server cannot link at all,
the temporary is renamed onto the name the ls just showed free, the remote twin of the exFAT
fallback downloads have. A cancel or a failure removes the remote temporary.
Folders are now the only send resting on the listing alone, so the refusal to send into a
listing cut short at 50,000 entries narrowed to folders; single files go into such a directory
fine. transfer/e2e.rs covers the new path against a real sshd, including that OpenSSH takes
the link rather than the fallback. D-033.
2026-09-12: the first three issues off the tracker
Three reports came in on Sep 10, the first real issue traffic the project has had. All three are fixed here; a fourth (a script uploaded to the host on connect) is a feature request and is logged rather than built.
#20, the remote pane unusable on an AD/LDAP host. list_remote parsed ls -la by
whitespace column. Under a plain -l, sftp prints the server’s listing line verbatim, and
that line spells the owner and group as names, so a group called domain users was two
columns, everything after it shifted right, the size was read off the month, and every filename
came back with the tail of a timestamp on the front. Navigation broke with it. The listing asks
for ls -lan now: -n makes the client format the line from the file attributes and the ids
come out numeric, so no remote naming convention can move the columns again. The parser also
stopped reading an unparseable size as zero and skips the line instead, which is the tripwire
that would have caught this the first time. D-031; reported by @ReubenM, who also named the fix.
#18, transfer asking for a passphrase it could not take. sshelf holds the terminal in raw
mode, and OpenSSH reads a passphrase from /dev/tty rather than stdin, so closing the
ControlMaster’s stdin never stopped it asking. On a key host with nothing stored and nothing in
the agent, Enter passphrase for key '...' was painted over the TUI’s hint line while every
keystroke went to sshelf’s event loop. It could not be answered, the master sat there for the
full thirty-second handshake, and the screen then reported a timeout that blamed the password.
Both the master and the port-forward spawner now pass -o BatchMode=yes when no askpass helper
is wired, ahead of build_args so it beats anything in extra_args. ssh fails in about a
second instead, and ssh::classify_auth_error turns the bare Permission denied (publickey)
into the two ways out: ssh-add the key, or store its passphrase with ^e. D-032.
#19, the port-forward popup reading as broken. Not reproducible as filed: typing, ←/→
and ^s all work. Two real things underneath it, though. The footer promised ←/→ change on
every row, but the arrows only change the Type chooser; on a text field they walk a cursor,
which over an empty field looks exactly like a dead key. The hint is per-row now. And every
unhandled Ctrl-/Alt- combo fell through to the focused text field and inserted its bare letter,
so Ctrl-a Ctrl-u Ctrl-w left auw behind. The same leak was in the host wizard, the settings
screen and the sites editor; all four take the same guard.
Not done: #17, uploading a script to the host on connect. It is a sibling of the per-host
remote_command item already on the list, and it needs the same design work. extra_args is
shared by the transfer master, the forward builder and sshelf export, so the obvious
LocalCommand workaround would fire on every listing and leak into the exported config. Both
want one brief.
Next: v0.15.0, secrets from a password manager.
2026-09-09: security hardening, thirteen findings closed
An outside review read the source, the configuration, the history, the dependencies and the CI
at ce7d6ed and came back with thirteen things worth fixing. None of them was a remote code
execution or a leak in the default setup, but two undercut promises the docs were already
making, so this is a minor release rather than a patch, and it carries no new features.
The askpass helper now knows which secret it holds. It used to answer any prompt ending in
password: and any prompt containing passphrase for with the one stored value, whichever kind
that value was. The text of a keyboard-interactive prompt is written by the server, and
Password: is a perfectly ordinary shape, so a host that rejected your key could ask for a
password and be handed the key’s passphrase. configure_askpass now also sets
SSHELF_SECRET_KIND (password, passphrase or agent) and, for key hosts,
SSHELF_IDENTITY_FILES. The helper answers a password prompt only for a password host, and
OpenSSH’s own Enter passphrase for key '<path>': only for a key host whose -i list holds
that path. Anything secret-shaped of the wrong kind is declined, and never answered with the
queued verification code instead. A missing or unreadable kind declines everything. The third
kind, agent, exists so an agent host with a verification code still answers the code prompt
while refusing both secret shapes.
Key hosts pin public-key auth. -o PreferredAuthentications=publickey, or
publickey,keyboard-interactive for a host that needs a verification code. A server can no
longer steer a key host into a password prompt at all. This is the first of two behaviour
changes: a key host that quietly fell back to a password now fails with the server’s
“Permission denied (publickey)”.
A jump hop never sees the helper. ssh starts the hop as a child, which inherits
SSH_ASKPASS, and it forwards only -l, -p, -J, -F and -v from the destination, so
nothing on the target’s command line reached the hop. With one jump host and something to
protect, -J is replaced by an explicit ProxyCommand running the hop with BatchMode=yes,
PasswordAuthentication=no and KbdInteractiveAuthentication=no. A jump string outside
A-Za-z0-9._@:[]- never goes inside that command, since ssh runs it through a shell. Two or
more hops cannot be constrained one by one, so nothing is wired: ssh asks on the terminal and
sshelf prints one line first. That is the second behaviour change. In tmux mode such a
connection opens in place, because a new window has no terminal to ask on. Decision: D-029.
Worth writing down, because it was the thing I was least sure of: an experiment with two
rootless sshd instances showed that plain -J does not pass StrictHostKeyChecking or
UserKnownHostsFile down to the hop either. The hop has always done its own host-key check
against the user’s real ~/.ssh/known_hosts. So the ProxyCommand is behaviour-equivalent
there and adds no new failure mode. OpenSSH also resolves ~ from the passwd database rather
than $HOME, which is why the hop half of the manual check could not be driven from a
throwaway HOME without writing under the real ~/.ssh, which is not something sshelf does.
The rest, one line each. The transfer screen’s control socket moved from
/tmp/sshelf-mux-<pid>-<seq>.sock into a per-session 0700 directory under
$XDG_RUNTIME_DIR (or the data dir), created with mkdir rather than create_dir_all so an
existing name is refused rather than adopted. Short-lived sftp children (listing, mkdir,
pwd) run under a deadline and an output cap, the worker polls its command channel while one
is running, and closing the screen waits two seconds for an acknowledgement instead of joining
a thread that may be blocked. Single-file downloads land on a .sshelf-part-<ulid> name and are
installed with a link, which fails rather than replacing anything, symlinks included; folders
and uploads keep the listing check, and the remote listing is refreshed right before each send.
A cancel that arrives while a listing is running is
no longer swallowed by it, and one that arrives before the child starts is honoured too (a
failed spawn used to drop it, and with it the transfer parked behind it), and ssh -O check and ssh -O exit are bounded too, so no call the
worker makes is unbounded any more. An upload into a listing the entry cap cut short is refused
rather than guessed at, since a listing check is all an upload has. On a filesystem with no hard
links (exFAT, FAT32, some network mounts) the download install checks the name and renames
instead of throwing the bytes away. atomic_write picks a ULID temp name, creates it
exclusively with its final mode at open(2),
and sweeps siblings older than an hour. Forward stderr logs moved to
<data_dir>/logs/fwd-<id>.log at 0600, and stopping a forward also unlinks the old /tmp
one. The transfer log is opened O_NOFOLLOW and 0600. ensure_dirs no longer chmods a config
directory sshelf did not create, and sshelf doctor gained a config directory permissions
check for exactly that case. Plain CLI output runs host and site fields through one sanitizer
(src/display.rs), the add form and the importers refuse such a name, and --json is
untouched. The 2FA code is masked in the TUI and read with echo off on the command line, in a
Zeroizing buffer either way. Decision: D-030.
The release pipeline. Every uses: is pinned to a 40-character commit SHA with the version
in a trailing comment, and every one was resolved from the API rather than from memory. The
cut-release.yml version input reaches the shell through env: instead of being spliced into
the script, the job is gated on github.ref rather than a run: check, the checkout no longer
persists credentials, and RELEASE_TOKEN is read in one step from a release environment. The
.deb, .rpm and crates.io companions check out workflow_run.head_sha and refuse to publish
if the tag has moved off it; the .deb and .rpm workflows are now a read-only build job plus
a small write-scoped attach job. release.yml is read-only at the top level, publishes build
attestations, and installs dist through a pinned action rather than piping a script from the
network. CI fails if RELOAD_SSH_ALGO is set, and the audit job is
cargo audit --deny unsound --deny yanked with three ignores that each name their reason and
date. dist-workspace.toml gained allow-dirty = ["ci"], so release.yml is hand-maintained
from here: regenerating means dropping that key, running dist generate, and re-applying the
pins.
Did the tests have teeth. Yes, checked rather than assumed. Reverting the classifier to the
old shape-only behaviour makes askpass::tests::classify_matrix,
a_key_host_with_no_identity_files_answers_nothing, and two of the four end-to-end tests in
tests/askpass_plumbing.rs fail, including the one that asserts a Password: prompt gets
nothing from a key host. That file drives a real sshelf process through a stub ssh that asks
the prompts a hostile endpoint would.
For Max, outside this session. Create the GitHub environment named release, put
RELEASE_TOKEN in it, and delete the repository-level copy. This is not a blocker: a job that
names an environment still reads repository secrets, and GitHub creates a missing environment
on the first run, so a cut would work today. It is the point of the change that is missing.
While the token is a repository secret, every workflow in the repo can read it; moving it is
what confines it to the one job that pushes, and a required reviewer on the environment puts a
human in front of that push.
Turn on Dependabot alerts, which the review noted are disabled. File the upstream issue asking
ssh2-config to move its OpenSSH-cloning build script into an explicit developer tool and drop
git2 from its published build dependencies, and decide whether the askpass finding warrants a
GHSA after the tag. Evaluating a smaller SSH config parser is the other half of that and is not
scheduled.
What’s next. v0.15.0, secrets from a password manager (op / bw / rbw / pass). The
secret-kind plumbing added here is what that brief extends.
2026-09-04: punctuation and phrasing pass on the public text
Docs and copy only: no code changed, no version bump, nothing any sentence claims is
different. Everything a stranger can read (README, CHANGELOG, CONTRIBUTING, PRIVACY,
SECURITY, every page under docs/, llms.txt) went through the house-style checker, which
found about 660 hits, most of them em dashes. Each one became a split sentence, a
comma, a pair of parentheses, or “to” for a range, whichever the sentence wanted; the rest
were bolded-label bullets, checkmarks in the milestone table and the old milestone headings,
a few plural “we“s in a one-person project, and a handful of banned words. Where a doc quotes a
string the binary actually prints (the sshelf doctor sample output, the error-message list
in the doctor entry below, 2FA host — connecting here) the quote is left byte-for-byte as
it is, since the docs are supposed to match the program. Cargo.toml’s description lost
its em dash too, so the crates.io blurb reads like the rest.
python3 .claude/skills/human-voice/check.py passes on every public file, mdbook build
renders, and the §6 anchor in packaging.md was re-pointed after its heading changed.
Two things this pass couldn’t reach, both worth a follow-up. The GitHub repo description
(the “About” box, set by hand in the web UI) isn’t in the repo: it should be checked for em
dashes and matched to the new Cargo.toml description. And cut-release.yml writes the
next version heading as ## [x.y.z] — DATE, which would put an em dash straight back into
the CHANGELOG on the next release; the headings here are now ## [x.y.z] (DATE), so that
one line in the workflow has to follow. The binary’s own strings are still full of em
dashes, which is why the sample output in doctor.md still has them; strings and the docs
that quote them should move in the same change, not separately.
2026-09-04: the first two reported bugs
Two issues from users, both display bugs, both fixed for v0.13.1. Nothing in this change bumps the version; the release workflow does that.
Issue #16, the host list showing $USER for a host that inherits its user from a site.
Host::endpoint() resolves nothing, and every list-style caller handed it the raw record, so a
host with no user of its own borrowed the local login name. Connecting was always right,
because those paths already call with_site_defaults. There is now endpoint_in(&sites) on
Host, used by the TUI row, sshelf list, the shell-completion help text, and the import
preview (which resolves against the sites already on file plus the ones the import is about to
create). search_haystack takes the sites too, so searching for the site’s user finds the
hosts that inherit it. That was the same bug one layer down: search matched the raw endpoint.
Issue #15, the remote transfer pane hiding dotfiles. list_remote sent ls -l to sftp,
and sftp’s own ls skips dot-entries unless you pass -a, while the local pane reads the
directory itself and never filtered. A hidden directory on a server was invisible with no way
to walk into it. The listing uses ls -la now, so both sides show the same thing. No toggle:
the reasons are in D-028.
Nothing on disk changed. sshelf list --json still flattens the host as stored, so an
inherited user stays null, with the resolved values visible in the generated command.
endpoint() itself is untouched for callers that want the raw record.
Tests that would have caught both. A TestBackend render case asserting the row reads
deploy@ and never the machine’s own $USER@; a search case for the inherited user; a
list_row case for the plain CLI output and the completion help; a parse_ls_line case for a
dotfile. The transfer e2e now puts a .dotfile and a .hidden-dir/inner.txt on the throwaway
sshd, asserts both are listed, and enters the hidden directory. Also checked by hand: sshelf list and the TUI against a scratch database with a dc site, and a real download of
~/.sshelf-test/.hidden/inner.txt through the transfer screen against the rootless test sshd.
Next: nothing further here. v0.14.0 (secrets from op / bw / rbw / pass) is the next
piece of work.
2026-09-04: README as a landing page, PRIVACY.md, llms.txt
Docs and assets only: no code changed, no version bump, no behavior claim that isn’t already true of v0.13.0.
- README rebuilt. A one-line pitch and badge row (now including the docs site, ratatui, and
the MSRV), the demo clip, install, then a first-person “why I built this”, one short section
per feature with a real capture, a comparison with the tools people weigh sshelf against, and
the never-list written as concrete promises. The hand-drawn ASCII screen is gone; the
captures replace it.
First five minutesand the documentation block survive from the old README because they were already doing their job. - Nine captures, all real.
docs/assets/gainslauncher,transfer,forwards,sites,wizard,doctor,export(PNG),tmux.gif, andlogo.svg;docs/sshelf-readme.gifwas re-recorded. Every one is VHS driving the actual release binary, with the same theme, font size and width, so the page reads as one tool. Nothing is a mockup: the transfer and forward shots move real bytes and bind real ports against a throwaway rootlesssshdon localhost (the same tricksrc/testsupport.rsuses for the e2e tests), and the tmux clip opens a real session. Everything on screen is demo data (RFC-5737 addresses,example.net,mike/deploy), with the machine’s own home, hostname and user kept out of frame. - Two things the recording turned up. VHS can’t send F1 to F12, so the
F3/F4screens are captured with sshelf running inside a scratch tmux session and the key delivered bytmux send-keys. And a host that inherits its site’s bastion really does try the bastion: pointing the tmux clip at aprod-dcmember made the new window die on a DNS failure in under a second: correct behavior, wrong host for a demo. PRIVACY.mdat the repo root: what sshelf reads, writes, runs, and sends, in that order, plus where secrets live and how to check the claims yourself. Linked from the README’s never-list and fromSECURITY.md, which now points at it for the non-attacker half of the picture.docs/llms.txt, in the standard shape: description, a paragraph, and one line per guide page.mdbook buildcopies it to the site root, so it serves at/llms.txt, anddocs/assetslands at/assetsfor the guide pages to reuse later.- The comparison table is sourced, not remembered. Every cell was re-read from the project’s own README (or site) in September 2026, and a cell the source doesn’t state is a dash rather than a guess.
2026-08-21: sshelf doctor, and errors that name the next action
- New
doctor.rs+sshelf doctor. Seven checks, each one line ofok/warn/failplus a single runnable next action when it isn’t ok: the OpenSSH version (fail below 8.4, theSSH_ASKPASS_REQUIREfloor stored secrets ride on; warn if the banner can’t be parsed), the host database (parse errors, and duplicate names or ids, since an id keys both the stored secret and the frecency history), the secret backend, danglingsitereferences, stored secrets whose host is gone,$SSH_AUTH_SOCKwhen any host uses agent auth (also flagged when it points at a socket that no longer exists), and whether the exported ssh_config fragment still matches the database. Exit 0 unless something failed; warnings don’t fail the run, sosshelf doctor && ...is usable in a script. D-027. - Local and read-only. No pings, no test connections, no version lookups:
doctorreports whether sshelf is set up, never whether a host is up. The single write is a throwaway keyring entry (sshelf-doctor-probe, under the realsshelfservice so it exercises the actual code path) that is deleted again, because a backend that reads but can’t write is one that fails the first time a password is saved, and a read-only probe would pass. - Honest about what it can’t check. The
keyringcrate has no portable enumeration, so orphan detection only has teeth in vault mode; the keyring case says it didn’t run rather than reporting a clean bill of health nobody checked. Export staleness is compared by content (the render is deterministic), not by mtime. - Every check is a pure function over inputs the caller gathers, so the whole matrix is
fixture-tested without a keyring, a config dir, or an
sshbinary;secrets.rsgained read-only introspection (backend,probe,stored_ids) andvault.rsanidsaccessor. - Error-message pass. Audited every user-facing string against one rule (name the thing,
the cause, the next action) and rewrote the ones that failed it. Before → after:
save failed: {e}→hosts NOT saved to <path>: {e} — nothing was writtenno host selected→no host under the cursor — clear the filter (esc), or add one (^a)HOME is not set→$HOME is not set — sshelf can't locate ~/.ssh/config to import fromimport failed: {e}→could not import <path>: {e}parsed ok but save failed→<n> host(s) parsed, but <path> could not be written: {e}host saved; secret NOT stored→... — retry with sshelf set-password <name>hosts updated; config NOT saved→... <config path> was not saved: {e} — the new location won't stickhosts NOT written: {e}→could not write <path>: {e} — the hosts file is unchangedcould not start transfer→could not open the transfer screen for <host>: {e}forward up, but not saved→forward up, but not recorded: {e} — F4 will lose it when sshelf restartsfailed to launch ssh→could not launch ssh: {e} — is an OpenSSH client installed and on your PATH?empty password; nothing stored→nothing on stdin — nothing stored; pipe the password in, e.g. printf %s "$PASS" | sshelf set-password <name>a site named 'x' already exists→... — pick another name, or edit that one with F3no host with name or id 'x'→... — run sshelf list to see your hostscould not decrypt vault (wrong passphrase?)→could not decrypt the vault — is $SSHELF_VAULT_PASSPHRASE the passphrase it was created with?- bare
authentication failed/connection refused/connection timed out/could not resolve the hoston a failed forward now each name what to check. No behavior changed inside the pass, only the words.
- Verified end-to-end against isolated config dirs: a healthy setup reports 7 × ok and
exits 0; a deliberately broken one (a
sitethat isn’t defined, a hand-edited stale export fragment,$SSH_AUTH_SOCKunset with agent hosts) reports fail + 2 warn and exits 1, and the remedies it printed were then run:sshelf sites add ...andsshelf exporteach flipped their check to ok and the run back to exit 0. Duplicate names/ids and an unparseablehosts.tomlboth fail with the offending values (a multi-line TOML error is folded onto one line, keeping the line/column and the reason). Vault mode was checked both ways: reachable (including an orphan the keyring case can’t see) and unreadable with a wrong passphrase.~/.sshandhosts.tomlwere byte-identical after every run, and no probe entry was left in the keyring. - 27 new tests (284 pass, 4
#[ignore]d e2e), clippy +fmt --checkclean, no new dependencies. - Docs synced: new
doctor.md(+SUMMARY.md),cli.md,faq.md(relevant answers now end insshelf doctor, plus a new “something isn’t working” entry),structure.md,index.md, README,decisions.md(D-027),cli.md(global flags work on either side of a subcommand), CHANGELOG. Ships in v0.13.0. - Fixed the CLI-routing bug found during v0.12.0 verification. clap’s
args_conflicts_with_subcommandscounts the global flags as top-level args, sosshelf --config FILE set-password webfailed to parse, and, worse, the same flag beforelistmadesshelf --config FILE listread as “connect to a host namedlist”, which would have connected had such a host existed. The setting is gone; the one combination it actually guarded (sshelf <HOST> <subcommand>, which clap parses happily and dispatch would resolve to the subcommand, silently dropping the host) is now rejected by an explicit check with its own message. Every subcommand gained aname()so that message can say which one. Verified across the matrix (both global flags on either side of every subcommand, bare host,-, bare subcommand) with 3 new tests;--help, completions and the man page are unchanged in shape.
2026-08-21: tmux connect modes, transfer multi-select + F7 mkdir
- tmux mode. One config key,
tmux = "off" | "window" | "pane"(defaultoff, also on theF2settings screen as aSpace-cycled toggle). With a mode set and$TMUXpresent,Enterrunstmux new-window/split-windowand sshelf stays up, which is the feature: open four hosts without relaunching the picker. Windows are named after the host (sanitized); panes aren’t (split-windowhas no-n). Everything else is untouched: with the key off, or outside tmux, connect is the same teardown →exec()→ exit-to-shell path as before. Frecency is persisted before the spawn, for the same reason it is persisted beforeexec(). - What may cross the tmux boundary. A tmux window is a child of the tmux server, so it
inherits none of sshelf’s environment, and tmux’s only channel (
new-window -e KEY=VALUE) is the tmux client’s own argv, i.e.ps-visible. So only the askpass wiring rides it (SSH_ASKPASS,SSH_ASKPASS_REQUIRE,SSHELF_ASKPASS, and the opaqueSSHELF_HOST_IDthe helper trades for the real secret); a queued 2FA code, a vault master passphrase, or a tmux older than 3.0 sends the connection back toexec()with a one-line reason printed after the TUI is down. Rationale: D-025. A unit test asserts the code/passphrase variables can never appear in a tmux argv, and the ssh argv is passed as separate arguments after--so tmux execs it directly instead of letting a shell re-split a path with spaces. - Transfer multi-select.
Spacemarks the entry under the cursor,Ctrl-amarks everything the filter shows (again → clear all),Ctrl-ssends the marked set through the existing one-at-a-time worker as a queue, counting2 of 5 name → dest. Marks are positional and die with the listing (cd, refresh, error); sending consumes them. A destination that already holds the name is skipped and the queue continues; a real failure stops the rest and says how many were left.Escgained a rung: cancel → clear marks → clear filter → close. D-026. - Transfer
F7mkdir on both panes (Ctrl-falias for terminals that keep the function keys), throughstd::fs::create_dirlocally andsftp mkdirremotely: one directory, never-p, never adopting an existing name; the new directory lands under the cursor. - Fixed: cancelling a transfer left the screen permanently stuck in “transfer running” (the
worker cancelled silently and never told the UI). It now emits a
Cancelledevent. - Verified end-to-end beyond the tests: inside a scripted tmux session,
windowmode opened a window namedkeyboxrunningsshwith sshelf still live in pane 0 andstate.jsonalready written;panemode split the window instead; the same config with sshelf launched outside tmux exec’d in place (one window,cmd=ssh, no note); a 2FA host printed2FA host — connecting here ...and connected in place; a vault-mode host did the same with its own reason. A passphrase-protected key host logged in through a tmux window, proving the askpass wiring crossed, and the recorded tmux argv held only the four wiring vars, with the passphrase,SSHELF_2FA_CODEandSSHELF_VAULT_PASSPHRASEabsent from every real process argv.~/.sshwas byte-identical throughout (content hashes, not a listing). Transfers were exercised against a real rootlesssshdby two newe2e.rstests driving the actual screen: mark-all → send with a pre-existing duplicate mid-queue (sent 3 of 4 · skipped dup.txt (already there), the duplicate’s contents untouched), the same in the download direction,F7on both sides, and a repeatF7refusing the taken name. - 34 new tests (257 pass, 4
#[ignore]d e2e), clippy +fmt --checkclean, no new dependencies. - Docs synced:
transfer.md(keys, marks, mkdir),configuration.md(thetmuxkey + the F2 field),search-connect.md(a “Connecting inside tmux” section incl. the fallbacks),security.md(the tmux boundary),ssh-command.md(§2a, the tmux argv),architecture.md,structure.md,faq.md(three new answers + a Discussions pointer),index.md, README,decisions.md(D-025, D-026), CHANGELOG. Ships in v0.12.0. - Found, not fixed:
sshelf --config FILE <subcommand>is rejected by clap (args_conflicts_with_subcommandsvs. the global flag), andsshelf --config FILE listis even read as “connect to a host namedlist”. The env var$SSHELF_CONFIGworks. Logged as follow-up rather than folded into this release.
2026-07-27: sshelf import --tailscale, the tailnet as an inventory source
- New
tailscale.rs+ a--tailscaleflag onimport: runs the user’s owntailscale status --jsonand maps every eligible peer to a host: MagicDNS first label →name, the MagicDNS FQDN →hostname(Tailscale IP, IPv4 first, when MagicDNS is off for the tailnet), the tailnet → asite, ACL tags minus thetag:prefix →tags, authagent. Eligibility is one rule (the peer’sDNSNamemust sit under the tailnet’s ownMagicDNSSuffix), which drops Mullvad exit nodes and shared-in nodes without special cases; expired peers are skipped, offline ones are not (an asleep laptop is still a host). Binary resolution:$SSHELF_TAILSCALE_BIN→PATH→ the macOS app bundle, with an error that names all three. - Add-only, in both directions: existing hosts and sites are never updated or deleted
(case-insensitive match), so a re-run converges to “0 added” and a byte-identical
hosts.toml. Nothing tailscale-specific (node IDs, keys) is stored. The no-network posture stands: the CLI runs only from this subcommand, never at startup, on save, on a timer, or from the TUI. Rationale + rejected alternatives: D-024. - Refactor: both import modes now share one tail (
apply_import) that dedupes, creates sites (import::missing_sites), saves and refreshes the export, so the D-023 fragment refreshes after a tailnet import exactly as it does after an ssh-config one. The summary gained a duplicates count and a line per created site. - Verified end-to-end against a stub
tailscalefed the test fixture, with an isolated config dir: 3 hosts + the site land inhosts.toml, counts match (3 parsed, 3 excluded: 2 foreign, 1 expired), a second run adds 0 with an identical file,--dry-runwrites nothing, and an existingNAShost + differently-cased site are reused, not duplicated. A pre-created export fragment refreshed to the imported hosts; without one, nothing was created. Each failure mode gives one message and exit 1: backend not running, unparseable JSON, a bogus$SSHELF_TAILSCALE_BIN.~/.sshwas byte-identical throughout (content hashes, not a directory listing), and the ssh-config import path was re-checked through the shared tail. 18 new tests (213 pass), clippy +fmt --checkclean. - Live tailnet run: on a real 6-peer tailnet with MagicDNS disabled, so the IPv4
fallback took the real path, the binary resolved itself from the macOS app bundle and
imported the 2
peers with valid node keys (
100.xaddresses, site fromCurrentTailnet.Name), excluding 4 with expired keys and this machine (Self); a re-run added 0, andlist --json/print-commandproduced usablesshcommands. Earlier, before login, the same client reportedNeedsLogin→ the friendly “runtailscale up” error, so both live states are covered. - Docs synced:
import.md(now “Importing hosts”, with the mapping table and the opt-in/ no-network posture), cli reference (flag +$SSHELF_TAILSCALE_BIN), FAQ, structure, index, README, CHANGELOG (also collapsed a duplicated0.10.0heading + link line). Ships in v0.11.0 viafeat/import-tailscale. Next:--hcloud/--awsonly if demand shows up.
2026-07-12: sshelf export, an ssh_config Include fragment
- New
export.rs+sshelf export [--stdout]: renders every host (site defaults resolved, name-sorted, deterministic, no timestamps) as ssh_configHostblocks into~/.config/sshelf/ssh_config, and prints the oneIncludeline for the user to add to~/.ssh/config, which sshelf still never writes (it only reads it, to drop the hint once the Include is already present). After that, plain ssh/scp/sftp, rsync, git, and SSH-config pickers (VS Code Remote-SSH) resolve sshelf hosts by name. - Rendering mirrors
build_argsminus sshelf-only plumbing: noStrictHostKeyChecking=accept-new(an askpass necessity, not the user’s choice) and no askpass wiring. Exact-o Key=Valueextras translate to directives; other flags become an in-block comment. Names that can’t be a safeHostpattern (glob/negation/comment/quote chars) are skipped with a comment; all values are control-char-sanitized so nothing can inject extra directives. - Auto-refresh: once the file exists (creating it = opt-in), every hosts save rewrites it,
from TUI
persist_hosts,sshelf add,import,sites add, and the settings hosts-file adopt path. It’s best-effort, so a refresh failure never blocks a save. Deleting the file opts out. - Verified end-to-end:
ssh -G -Fthrough a realIncluderesolves hostname/user/port/ ProxyJump (incl. a site-inherited bastion) and the translated option; auto-refresh observed live onsshelf add. 14 new unit tests (195 pass, clippy clean). Docs synced: new guide pageexport.md, D-023, data-model, structure, cli, import, faq, index, README, CHANGELOG. Targets v0.10.0 viafeat/export.
2026-07-06: docs, user guide split from internals, slim README
- The mdBook site now leads with a Guide for users: install, quickstart, adding/editing
hosts, searching & connecting, file transfer, port forwarding, sites & tags,
passwords/keys/2FA, import, a full CLI reference, configuration, and an FAQ. Content that
previously lived only in the README (CLI flag table, completions setup, config reference) or
inside
ux.mdmoved into these pages;index.mdis a product landing page instead of a doc inventory, andSUMMARY.mdis reorganized into Guide / Understanding sshelf / Development. ux.mdslimmed to UI design notes (visual model, ranking, form design, modality, theming), with stale milestone markers removed; behavior and keybindings are documented in the Guide pages, which the docs-in-sync rule inCONTRIBUTING.mdnow points at.- README cut from ~230 to ~140 lines: pitch, install (secondary channels folded into a
<details>), a “first five minutes” block, and links into the site; added crates.io/CI/ license badges and an explicit no-telemetry / no-account / no-cloud line. Everything removed now lives on the site. - Why: the README had become the user manual because the site didn’t have one, while the site led with contributor docs, and the site is the crates.io homepage, so a real guide is what visitors should land on. Docs-only change; no code touched.
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, since 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 the 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 to 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, the 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, and 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, so 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, where local nav is synchronous, remote nav requests go via the worker, and events are 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, since 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, since 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, which 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 to 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, which is 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):
.pemand keyless keys are detected:scan_keysincludes any private key by sniffing aPRIVATE KEYheader rather than only<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, so 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, since 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), via the 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 (rather thansshalone), 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 that 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(as well as 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 sshelf 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, so
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 is one of not started, in progress, or done.
| # | Milestone | Status |
|---|---|---|
| n/a | Docs foundation (project guide + docs/) | done |
| M0 | Spike SSH_ASKPASS password mechanism | done (macOS; Linux pending in CI) |
| M1 | Scaffold crate + persistence (paths/model/store, clap, licenses) | done |
| M2 | Core TUI: list + fuzzy search + highlight + hint bar | done |
| M3 | Connect via exec() handoff (key/agent hosts) + yank | done |
| M4 | Add/Edit/Delete wizard (+ quick-add) | done |
| M5 | Secrets (keyring + age vault) + password auto-supply (askpass) | done |
| M6 | Polish: frecency tuning, tags, config, help, theme | done |
| M7 | Read-only import from ~/.ssh/config | done |
| M8 | OSS readiness: README, SECURITY, CI, licenses | done |
The full milestone detail lives in the project plan.