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.