Merge origin/main into fix/cli-orchestration

Both sides added to the Unreleased section of the CHANGELOG; #484's SSH entry
keeps main's place ahead of this branch's four, and the `Changed` section this
branch introduces moves below it. The `docs/` overlap the PR description warned
about merged on its own.
This commit is contained in:
l0ng-ai
2026-08-11 20:29:32 +08:00
21 changed files with 741 additions and 169 deletions
+16
View File
@@ -91,6 +91,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`tty7 wait` on it only ever times out, so the check belongs in the verb
people run when something is not working.
- **SSH probes the `~/.ssh` default identity keys** — a connection with no
identity file of its own used to offer the server nothing unless an agent
was running, which on Windows is the common case (the OpenSSH
Authentication Agent service is off by default), and then reported "no
public key was accepted" when no key had ever been sent. `id_ed25519`,
`id_ecdsa` and `id_rsa` are now offered after the connection's own files
and before the agent, OpenSSH-style, deduplicated against the explicit list
by canonical path so one key spelled two ways is offered once — every offer
spends one of the server's `MaxAuthTries`. A discovered key that is
encrypted is used only when its passphrase is already in the OS keychain:
russh has no offer-without-signing probe, so asking would spend a prompt on
a key the server may not even want. A key named in the profile still asks,
as before. The failure text now separates the two situations the old line
papered over — keys the server rejected are named, and a round that offered
nothing says where it looked. (#484)
### Changed
- **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a
+67 -11
View File
@@ -288,27 +288,64 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin
expand_tilde(&out)
}
pub fn expand_tilde(path: &str) -> String {
let home = || {
#[cfg(windows)]
let var = "USERPROFILE";
#[cfg(not(windows))]
let var = "HOME";
std::env::var(var).ok().filter(|h| !h.is_empty())
};
/// The platform home directory: `%USERPROFILE%` on Windows, `$HOME` elsewhere.
fn home_dir() -> Option<String> {
#[cfg(windows)]
let var = "USERPROFILE";
#[cfg(not(windows))]
let var = "HOME";
std::env::var(var).ok().filter(|h| !h.is_empty())
}
fn expand_tilde_with(path: &str, home: Option<&str>) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home() {
if let Some(home) = home {
let sep = if home.ends_with('/') { "" } else { "/" };
return format!("{home}{sep}{rest}");
}
} else if path == "~" {
if let Some(home) = home() {
return home;
if let Some(home) = home {
return home.to_string();
}
}
path.to_string()
}
pub fn expand_tilde(path: &str) -> String {
expand_tilde_with(path, home_dir().as_deref())
}
/// The private keys publickey auth probes when a connection carries no usable
/// `IdentityFile` of its own — OpenSSH's default-identity behaviour (issue
/// #484). Without it, "no profile key + no agent" offers the server zero keys,
/// which on Windows is the common case (the OpenSSH Authentication Agent
/// service is disabled by default there).
///
/// Both the GUI (`ui::ssh_connect`, preloading cached passphrases) and the
/// daemon (`daemon::ssh::auth`, offering the keys) must see the *same* list:
/// `NativeSshSpec::key_passphrases` is keyed on these exact strings, so the
/// two sides share this one definition rather than formatting their own.
///
/// The list stays short on purpose: every offered key spends one of the
/// server's `MaxAuthTries` (default 6), shared with explicit keys and agent
/// identities. `id_dsa` is long deprecated, `id_xmss`/`id_*_sk` are beyond
/// what russh can sign with, so the three software keys cover what exists in
/// practice — ed25519 first as the modern default.
pub fn default_identity_candidates() -> Vec<String> {
let Some(home) = home_dir() else {
return Vec::new();
};
default_identity_candidates_in(&home)
}
/// The pure core, home injected so tests never touch the environment.
fn default_identity_candidates_in(home: &str) -> Vec<String> {
["id_ed25519", "id_ecdsa", "id_rsa"]
.into_iter()
.map(|name| expand_tilde_with(&format!("~/.ssh/{name}"), Some(home)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -459,6 +496,25 @@ mod tests {
assert_eq!(expand_tilde("/abs/path"), "/abs/path");
}
#[test]
fn default_identity_candidates_are_ordered_and_home_relative() {
assert_eq!(
default_identity_candidates_in("/home/me"),
vec![
"/home/me/.ssh/id_ed25519".to_string(),
"/home/me/.ssh/id_ecdsa".to_string(),
"/home/me/.ssh/id_rsa".to_string()
]
);
// A trailing separator must not double up, and the strings must be
// exactly what an explicit `~/.ssh/...` entry expands to, because
// `key_passphrases` is keyed on them.
assert_eq!(
default_identity_candidates_in("/home/me/"),
default_identity_candidates_in("/home/me")
);
}
#[test]
fn profile_expanded_identity_files_uses_own_host_user() {
let mut p = SshProfile::new("x");
+521 -74
View File
@@ -439,10 +439,36 @@ async fn try_publickeys(
broker: &Arc<PromptBroker>,
) -> Outcome {
let mut last: Option<MethodSet> = None;
let mut round = KeyRound::default();
if spec.auth_mode != SshAuthMode::Agent {
for path in &spec.identity_files {
match try_identity_file(handle, spec, broker, path).await {
// OpenSSH parity (#484): the `~/.ssh` default identities are appended
// after the explicit ones (there is no `IdentitiesOnly` yet), and
// deduped against them by canonical path — the explicit list may spell
// the same key with different separators or casing, and every offer
// spends one of the server's MaxAuthTries. Dedup compares the *expanded*
// explicit paths, the same ones `try_identity_file` opens: a spec entry
// still carrying `~` or `%h` names a real file, and comparing it raw
// would fail to canonicalize and offer that key a second time.
let explicit: Vec<String> = spec
.identity_files
.iter()
.map(|p| {
crate::core::ssh_profile::expand_identity_placeholders(p, &spec.host, &spec.user)
})
.collect();
let discovered = dedup_candidates(
crate::core::ssh_profile::default_identity_candidates(),
&explicit,
canonical_key,
);
let files = spec
.identity_files
.iter()
.map(|p| (p.clone(), KeySource::Explicit))
.chain(discovered.into_iter().map(|p| (p, KeySource::Discovered)));
for (path, source) in files {
match try_identity_file(handle, spec, broker, &path, source, &mut round).await {
Outcome::Authenticated => return Outcome::Authenticated,
Outcome::Failed {
remaining_methods, ..
@@ -457,7 +483,7 @@ async fn try_publickeys(
}
if spec.auth_mode != SshAuthMode::PublicKey {
match try_agent(handle, spec).await {
match try_agent(handle, spec, &mut round).await {
Outcome::Authenticated => return Outcome::Authenticated,
Outcome::Failed {
remaining_methods, ..
@@ -472,7 +498,190 @@ async fn try_publickeys(
Outcome::Failed {
remaining_methods: last,
reason: Some("no public key was accepted".to_string()),
reason: Some(round.reason(spec.auth_mode)),
}
}
/// Where an identity file came from. Provenance decides failure behaviour:
/// an explicit key is the user's own choice, so its failures are said aloud
/// and its encrypted form may ask for a passphrase; a discovered `~/.ssh`
/// default is none of the user's doing, so every failure of one is silent
/// (#484).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeySource {
Explicit,
Discovered,
}
/// Canonical path for dedup: the same key reached via `~`, an absolute path,
/// or different separator/casing spellings must be offered once, not twice —
/// each offer spends one of the server's MaxAuthTries. Files that cannot be
/// canonicalized (missing) never enter the set; the read step skips them.
fn canonical_key(path: &str) -> Option<String> {
std::fs::canonicalize(path)
.ok()
.map(|p| p.to_string_lossy().into_owned())
}
/// Drop default candidates an explicit entry already names, comparing by
/// canonical path. Pure apart from the injected canonicalizer, so tests never
/// touch the filesystem.
fn dedup_candidates(
candidates: Vec<String>,
explicit: &[String],
canon: impl Fn(&str) -> Option<String>,
) -> Vec<String> {
let mut seen: std::collections::HashSet<String> =
explicit.iter().filter_map(|p| canon(p)).collect();
let mut out = Vec::new();
for candidate in candidates {
match canon(&candidate) {
Some(key) if seen.contains(&key) => {}
Some(key) => {
seen.insert(key);
out.push(candidate);
}
// Not canonicalizable means not readable; the read step skips it.
None => out.push(candidate),
}
}
out
}
/// What one publickey round learned, kept so the final error can distinguish
/// the two situations "no public key was accepted" used to paper over
/// (#484): nothing local could be offered at all, or keys went to the server
/// and it refused every one.
#[derive(Default)]
struct KeyRound {
/// File keys actually sent to the server, by their configured path.
offered_files: Vec<String>,
/// File keys the server rejected, same spelling.
rejected_files: Vec<String>,
/// Whether an agent answered, and how many of its identities were
/// sent / rejected.
agent_available: bool,
agent_offered: usize,
agent_rejected: usize,
/// Explicit files that could not be read or decoded, with the reason.
/// (Discovered candidates fail silently, so they never land here.)
unusable: Vec<String>,
/// Transport-level errors after a key was decoded.
errors: Vec<String>,
}
impl KeyRound {
fn reason(&self, mode: SshAuthMode) -> String {
if !self.rejected_files.is_empty() || self.agent_rejected > 0 {
let mut what = self.rejected_files.clone();
if self.agent_rejected > 0 {
what.push(format!(
"{} agent {}",
self.agent_rejected,
if self.agent_rejected == 1 {
"identity"
} else {
"identities"
}
));
}
return format!("server rejected public key(s): {}", what.join(", "));
}
if self.offered_files.is_empty() && self.agent_offered == 0 {
let mut looked: Vec<String> = Vec::new();
if mode != SshAuthMode::Agent {
looked.push("identity files".to_string());
looked.push("~/.ssh default keys".to_string());
}
if mode != SshAuthMode::PublicKey {
looked.push(if self.agent_available {
"the SSH agent".to_string()
} else {
"the SSH agent (unavailable)".to_string()
});
}
let mut msg = format!(
"no usable private key was found (checked: {})",
looked.join(", ")
);
if !self.unusable.is_empty() {
msg.push_str(&format!("; {}", self.unusable.join("; ")));
}
return msg;
}
// Keys were offered and none was rejected or accepted: the transport
// broke, and the last error says where.
if let Some(e) = self.errors.last() {
return e.clone();
}
"no public key was accepted".to_string()
}
}
/// Decode-time policy for one identity file, split from the network so the
/// source × encryption matrix stays unit-testable. The asymmetry is the
/// point (#484 review): russh has no offer-without-signature probe, so
/// trying an encrypted key means signing — i.e. prompting *before* the server
/// has shown any interest in that key. An explicit key earns that prompt; a
/// discovered default with no cached passphrase does not.
enum IdentityLoad {
Ready(russh::keys::PrivateKey),
/// Not worth an offer: a `.pub`, an undecodable file, or a discovered
/// candidate that is encrypted with no cached passphrase.
Skip,
/// An explicit key the user should hear about.
Unusable(String),
/// Explicit, encrypted, no cached passphrase — ask the user.
NeedsPassphrase,
}
fn load_identity(
contents: &str,
raw_path: &str,
source: KeySource,
cached: Option<&str>,
) -> IdentityLoad {
if PublicKey::from_openssh(contents.trim()).is_ok() {
// A `.pub` handed in as the identity file is never an offer. Worth a
// line in the log when the user named it themselves — pointing
// IdentityFile at the public half is a common slip, and the round is
// otherwise silent about it.
if source == KeySource::Explicit {
log::warn!("identity file {raw_path} is a public key; skipping");
}
return IdentityLoad::Skip;
}
match russh::keys::decode_secret_key(contents, None) {
Ok(key) => IdentityLoad::Ready(key),
Err(russh::keys::Error::KeyIsEncrypted) => match cached {
Some(passphrase) => match russh::keys::decode_secret_key(contents, Some(passphrase)) {
Ok(key) => IdentityLoad::Ready(key),
Err(e) => {
log::warn!("could not decrypt identity file {raw_path}: {e}");
match source {
KeySource::Explicit => IdentityLoad::Unusable(format!(
"could not decrypt identity file {raw_path}"
)),
// A stale cached passphrase for a key the user never
// configured: skip, don't shout.
KeySource::Discovered => IdentityLoad::Skip,
}
}
},
None => match source {
KeySource::Explicit => IdentityLoad::NeedsPassphrase,
KeySource::Discovered => IdentityLoad::Skip,
},
},
Err(e) => {
log::warn!("could not read identity file {raw_path}: {e}");
match source {
KeySource::Explicit => {
IdentityLoad::Unusable(format!("could not read identity file {raw_path}: {e}"))
}
KeySource::Discovered => IdentityLoad::Skip,
}
}
}
}
@@ -481,77 +690,110 @@ async fn try_identity_file(
spec: &NativeSshSpec,
broker: &Arc<PromptBroker>,
raw_path: &str,
source: KeySource,
round: &mut KeyRound,
) -> Outcome {
let path = expand_identity_path(raw_path, &spec.host, &spec.user);
let path =
crate::core::ssh_profile::expand_identity_placeholders(raw_path, &spec.host, &spec.user);
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => return failed(format!("cannot read identity file {path}: {e}")),
};
if PublicKey::from_openssh(contents.trim()).is_ok() {
log::warn!("identity file {path} is a public key; skipping");
return Outcome::Skipped;
}
let key = match russh::keys::decode_secret_key(&contents, None) {
Ok(k) => k,
Err(russh::keys::Error::KeyIsEncrypted) => {
let provided = spec
.key_passphrases
.as_ref()
.and_then(|m| m.get(raw_path))
.cloned();
let passphrase = match provided {
Some(p) => p,
None => {
let resp = broker
.prompt(AuthPromptKind::KeyPassphrase {
key_path: raw_path.to_string(),
comment: String::new(),
})
.await;
match resp {
AuthResponse::Secret(p) => p,
_ => return Outcome::Skipped,
Err(e) => {
return match source {
KeySource::Explicit => {
round
.unusable
.push(format!("cannot read identity file {raw_path}: {e}"));
Outcome::Failed {
remaining_methods: None,
reason: None,
}
}
// A default candidate that is not there is the normal case,
// not a failure.
KeySource::Discovered => Outcome::Skipped,
};
}
};
let cached = spec
.key_passphrases
.as_ref()
.and_then(|m| m.get(raw_path))
.map(String::as_str);
let key = match load_identity(&contents, raw_path, source, cached) {
IdentityLoad::Ready(k) => k,
IdentityLoad::Skip => return Outcome::Skipped,
IdentityLoad::Unusable(reason) => {
round.unusable.push(reason);
return Outcome::Failed {
remaining_methods: None,
reason: None,
};
}
IdentityLoad::NeedsPassphrase => {
let resp = broker
.prompt(AuthPromptKind::KeyPassphrase {
key_path: raw_path.to_string(),
comment: String::new(),
})
.await;
let AuthResponse::Secret(passphrase) = resp else {
return Outcome::Skipped;
};
match russh::keys::decode_secret_key(&contents, Some(&passphrase)) {
Ok(k) => k,
Err(e) => {
log::warn!("could not decrypt identity file {path}: {e}");
return failed(format!("could not decrypt identity file {path}"));
round
.unusable
.push(format!("could not decrypt identity file {raw_path}"));
return Outcome::Failed {
remaining_methods: None,
reason: None,
};
}
}
}
Err(e) => {
log::warn!("could not read identity file {path}: {e}");
return failed(format!("could not read identity file {path}"));
}
};
round.offered_files.push(raw_path.to_string());
let hash_alg = rsa_hash_alg(&key.algorithm());
let pk = PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg);
match handle.authenticate_publickey(&spec.user, pk).await {
Ok(AuthResult::Success) => Outcome::Authenticated,
Ok(AuthResult::Failure {
remaining_methods, ..
}) => Outcome::Failed {
remaining_methods: Some(remaining_methods),
reason: Some(format!("server rejected key {raw_path}")),
},
Err(e) => failed(format!("public-key auth error: {e}")),
}) => {
round.rejected_files.push(raw_path.to_string());
Outcome::Failed {
remaining_methods: Some(remaining_methods),
reason: None,
}
}
Err(e) => {
round
.errors
.push(format!("public-key auth error with {raw_path}: {e}"));
Outcome::Failed {
remaining_methods: None,
reason: None,
}
}
}
}
async fn try_agent(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) -> Outcome {
async fn try_agent(
handle: &mut Handle<ClientHandler>,
spec: &NativeSshSpec,
round: &mut KeyRound,
) -> Outcome {
#[cfg(unix)]
{
let agent = match AgentClient::connect_env().await {
Ok(a) => a,
Err(_) => return Outcome::Skipped,
};
try_agent_identities(handle, spec, agent).await
try_agent_identities(handle, spec, agent, round).await
}
#[cfg(windows)]
{
@@ -561,7 +803,7 @@ async fn try_agent(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) ->
Ok(a) => a,
Err(_) => return Outcome::Skipped,
};
try_agent_identities(handle, spec, agent).await
try_agent_identities(handle, spec, agent, round).await
}
}
@@ -569,6 +811,7 @@ async fn try_agent_identities<S>(
handle: &mut Handle<ClientHandler>,
spec: &NativeSshSpec,
mut agent: AgentClient<S>,
round: &mut KeyRound,
) -> Outcome
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send,
@@ -577,12 +820,14 @@ where
Ok(ids) => ids,
Err(_) => return Outcome::Skipped,
};
round.agent_available = true;
let mut last: Option<MethodSet> = None;
for identity in identities {
let pubkey: PublicKey = match &identity {
AgentIdentity::PublicKey { key, .. } => key.clone(),
AgentIdentity::Certificate { .. } => continue,
};
round.agent_offered += 1;
let hash_alg = rsa_hash_alg(&pubkey.algorithm());
match handle
.authenticate_publickey_with(&spec.user, pubkey, hash_alg, &mut agent)
@@ -591,13 +836,16 @@ where
Ok(AuthResult::Success) => return Outcome::Authenticated,
Ok(AuthResult::Failure {
remaining_methods, ..
}) => last = Some(remaining_methods),
}) => {
round.agent_rejected += 1;
last = Some(remaining_methods);
}
Err(_) => continue,
}
}
Outcome::Failed {
remaining_methods: last,
reason: Some("no agent key was accepted".to_string()),
reason: None,
}
}
@@ -756,26 +1004,6 @@ fn rsa_hash_alg(algorithm: &Algorithm) -> Option<HashAlg> {
}
}
fn expand_identity_path(path: &str, host: &str, user: &str) -> String {
let substituted = path.replace("%h", host).replace("%r", user);
if let Some(rest) = substituted.strip_prefix("~/") {
if let Some(home) = home_dir() {
return format!("{home}/{rest}");
}
}
substituted
}
#[cfg(unix)]
fn home_dir() -> Option<String> {
std::env::var("HOME").ok().filter(|h| !h.is_empty())
}
#[cfg(not(unix))]
fn home_dir() -> Option<String> {
std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -805,12 +1033,6 @@ mod tests {
assert!(!msg.ends_with(' '), "{msg}");
}
#[test]
fn identity_path_expands_tokens_and_tilde() {
let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy");
assert_eq!(p, "/keys/deploy@example.com/id");
}
#[test]
fn method_order_restricts_by_mode() {
assert_eq!(
@@ -871,4 +1093,229 @@ mod tests {
);
assert_eq!(rsa_hash_alg(&Algorithm::Ed25519), None);
}
#[test]
fn default_candidates_dedup_against_explicit_by_canonical_path() {
// The fake canonicalizer collapses spelling differences; two strings
// with the same canonical form are one file, and the explicit entry
// wins the offer slot.
let canon = |p: &str| Some(p.replace("//", "/"));
let out = dedup_candidates(
vec![
"/home/me/.ssh/id_ed25519".to_string(),
"/home/me/.ssh/id_ecdsa".to_string(),
"/home/me/.ssh/id_rsa".to_string(),
],
&["/home/me//.ssh/id_rsa".to_string()],
canon,
);
assert_eq!(
out,
vec![
"/home/me/.ssh/id_ed25519".to_string(),
"/home/me/.ssh/id_ecdsa".to_string()
]
);
}
#[test]
fn candidates_that_do_not_canonicalize_pass_through() {
// A missing default is the normal case; the read step skips it, so
// dedup must not drop it here either.
let out = dedup_candidates(vec!["/missing/id_ed25519".to_string()], &[], |_| None);
assert_eq!(out, vec!["/missing/id_ed25519".to_string()]);
}
const PASSPHRASE: &str = "correct horse battery staple";
/// The throwaway ed25519 key these tests offer, built here rather than
/// pasted in as a PEM blob: a private key sitting in the tree is a
/// secret-scanner hit whatever its provenance, and a scanner that has to
/// be overridden to stay green is one nobody reads. The seed is fixed, so
/// the bytes are the same on every run, and this key exists nowhere but
/// these assertions.
fn fixture_key() -> russh::keys::PrivateKey {
russh::keys::PrivateKey::from(russh::keys::ssh_key::private::Ed25519Keypair::from_seed(
&[7u8; 32],
))
}
fn plain_key() -> String {
fixture_key()
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("encode the fixture key")
.to_string()
}
/// The same key under `PASSPHRASE`. `encrypt_with` takes the KDF and
/// checkint rather than an RNG, which is what keeps this crate free of a
/// rand dependency it otherwise has no use for; the low bcrypt round count
/// is a test's, not a real key's.
fn encrypted_key() -> String {
fixture_key()
.encrypt_with(
russh::keys::ssh_key::Cipher::Aes256Ctr,
russh::keys::ssh_key::Kdf::Bcrypt {
salt: vec![9u8; 16],
rounds: 4,
},
0,
PASSPHRASE,
)
.expect("encrypt the fixture key")
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("encode the encrypted fixture key")
.to_string()
}
#[test]
fn load_identity_ready_for_plain_key_either_source() {
for source in [KeySource::Explicit, KeySource::Discovered] {
assert!(
matches!(
load_identity(&plain_key(), "k", source, None),
IdentityLoad::Ready(_)
),
"plain key must load for {source:?}"
);
}
}
#[test]
fn load_identity_skips_public_key_content() {
let public = fixture_key()
.public_key()
.to_openssh()
.expect("encode the fixture public key");
for source in [KeySource::Explicit, KeySource::Discovered] {
assert!(
matches!(
load_identity(&public, "k", source, None),
IdentityLoad::Skip
),
"a .pub is never an offer"
);
}
}
#[test]
fn load_identity_garbage_is_loud_for_explicit_quiet_for_discovered() {
assert!(matches!(
load_identity("not a key", "k", KeySource::Explicit, None),
IdentityLoad::Unusable(_)
));
assert!(matches!(
load_identity("not a key", "k", KeySource::Discovered, None),
IdentityLoad::Skip
));
}
#[test]
fn load_identity_encrypted_prompts_only_for_explicit() {
// The whole policy (#484): russh can only try an encrypted key by
// signing, so a discovered one with no cached passphrase is skipped
// rather than spending a prompt on a key the server may not want.
assert!(matches!(
load_identity(&encrypted_key(), "k", KeySource::Explicit, None),
IdentityLoad::NeedsPassphrase
));
assert!(matches!(
load_identity(&encrypted_key(), "k", KeySource::Discovered, None),
IdentityLoad::Skip
));
}
#[test]
fn load_identity_encrypted_uses_a_cached_passphrase_for_either_source() {
for source in [KeySource::Explicit, KeySource::Discovered] {
assert!(
matches!(
load_identity(&encrypted_key(), "k", source, Some(PASSPHRASE)),
IdentityLoad::Ready(_)
),
"cached passphrase must unlock for {source:?}"
);
}
}
#[test]
fn load_identity_wrong_cached_passphrase_is_loud_only_for_explicit() {
assert!(matches!(
load_identity(&encrypted_key(), "k", KeySource::Explicit, Some("wrong")),
IdentityLoad::Unusable(_)
));
assert!(matches!(
load_identity(&encrypted_key(), "k", KeySource::Discovered, Some("wrong")),
IdentityLoad::Skip
));
}
#[test]
fn reason_names_the_keys_the_server_rejected() {
let mut round = KeyRound::default();
round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()];
round.rejected_files = round.offered_files.clone();
let msg = round.reason(SshAuthMode::Auto);
assert_eq!(
msg,
"server rejected public key(s): /home/me/.ssh/id_ed25519"
);
round.agent_offered = 2;
round.agent_rejected = 2;
let msg = round.reason(SshAuthMode::Auto);
assert_eq!(
msg,
"server rejected public key(s): /home/me/.ssh/id_ed25519, 2 agent identities"
);
}
#[test]
fn reason_for_nothing_offered_says_where_it_looked() {
let round = KeyRound::default();
let msg = round.reason(SshAuthMode::Auto);
assert!(msg.contains("no usable private key was found"), "{msg}");
assert!(msg.contains("~/.ssh default keys"), "{msg}");
assert!(msg.contains("agent (unavailable)"), "{msg}");
// An agent that answered but held nothing is "checked", not
// "unavailable".
let mut round = KeyRound::default();
round.agent_available = true;
let msg = round.reason(SshAuthMode::Auto);
assert!(msg.contains("the SSH agent"), "{msg}");
assert!(!msg.contains("unavailable"), "{msg}");
// Pinned modes name only what they would have used.
let msg = KeyRound::default().reason(SshAuthMode::Agent);
assert!(!msg.contains("default keys"), "{msg}");
let msg = KeyRound::default().reason(SshAuthMode::PublicKey);
assert!(!msg.contains("agent"), "{msg}");
}
#[test]
fn reason_appends_unusable_explicit_files() {
let mut round = KeyRound::default();
round
.unusable
.push("cannot read identity file /bad/key: denied".to_string());
let msg = round.reason(SshAuthMode::PublicKey);
assert!(
msg.contains("cannot read identity file /bad/key: denied"),
"{msg}"
);
}
#[test]
fn reason_falls_back_to_the_transport_error_after_an_offer() {
let mut round = KeyRound::default();
round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()];
round.errors.push(
"public-key auth error with /home/me/.ssh/id_ed25519: connection lost".to_string(),
);
assert_eq!(
round.reason(SshAuthMode::Auto),
"public-key auth error with /home/me/.ssh/id_ed25519: connection lost"
);
}
}
+1 -1
View File
@@ -55,6 +55,6 @@ tty7 itself.
## Copy the session id
**Copy Session ID** — in the pane's right-click menu, beside *Copy Working
**Copy Session ID** — in the tab's right-click menu, beside *Copy Working
Directory*, and in the command palette — puts the agent's native id on the
clipboard. Paste it into `codex resume`, a bug report, or another tool.
+10 -6
View File
@@ -14,20 +14,24 @@ the agent say which one it is.
| Agent | |
|---|---|
| Claude Code · Codex · Copilot · OpenCode · Pi · Grok · Oh My Pi | Hooks available |
| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available |
| Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet |
Installing writes into that agent's own configuration directory and can be
undone from the same row — the button becomes **Uninstall**, and reads
**Outdated** with an **Update** when tty7 ships a newer hook.
Installing writes into that agent's own configuration directory. Once installed
the row grows a second **Uninstall** button beside the first, which itself
becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7
ships a newer hook.
<Note>
The hooks only do anything inside tty7. Running the same agent in another
terminal is unaffected.
</Note>
Connected [remote machines](/remote/workspaces) get their own row, so an agent
running on a dev box reports status to the window you are watching it from.
Hooks are installed per machine. Once a second
[machine](/remote/workspaces) is linked, a row of chips appears above the table
to pick which one you are looking at, and the table then shows that machine's
agents — so a dev box gets its hooks installed the same way, from the same
screen.
## The status dot
+3 -3
View File
@@ -22,9 +22,9 @@ server is reachable, whether its wire dialect matches this binary, and whether
running *inside* a tty7 pane.
Being inside a pane matters because the address-taking verbs (`split`, `send`,
`capture`, `procs`, `pane close`) default to `$TTY7_PANE`, and `run --keep`
files its pane into `$TTY7_WS`. Outside one you must name a target, and the
error says so rather than guessing.
`capture`, `procs`, `wait`, `pane close`) default to `$TTY7_PANE`, and
`run --keep` files its pane into `$TTY7_WS`. Outside one you must name a target,
and the error says so rather than guessing.
## Addresses
+15 -6
View File
@@ -19,7 +19,7 @@ Set inside every tty7 pane, inherited by anything launched from one.
| Variable | Meaning |
|---|---|
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `pane close`. |
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `ws tree`. |
| `TTY7_CONFIG_DIR` | The server's config dir — how the CLI finds the right server. You never pass a socket path. |
@@ -43,6 +43,16 @@ a stand-in.
## Top-level verbs
### `tty7 [PATH]`
No subcommand means the GUI. A running window is asked to come forward and open
a tab at `PATH`; if none is registered, the app is launched instead.
JSON: `{"path","delivered","launched"}` — `delivered` says an existing window
took it, `launched` that a new process was started.
Without `PATH` it just activates the app. `-m` is refused: this verb drives the
GUI on *this* machine.
### `tty7 ls`
Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`.
@@ -208,7 +218,7 @@ candidates.
| Command | Effect | JSON |
|---|---|---|
| `ws ls` | Every workspace | `{"workspaces":[...]}` |
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","tabs":[{"id","name","sidebar_group","root",…}]}` |
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` |
| `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` |
| `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` |
| `ws rm WORKSPACE` | Delete the workspace | `{"removed"}` |
@@ -262,9 +272,9 @@ a stand-in.
| `pane close --orphans` | Close every pane no workspace holds | `{"closed":[…]}` |
`--all` is the one that shows leaks. Each entry is
`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is
`tty7-cli` for panes this CLI spawned, and `orphan: true` means no workspace
holds it. An interrupted `run` and a removed workspace both leave orphans here.
`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id
of the workspace that owns the pane, and `orphan: true` means no workspace holds
it. An interrupted `run` and a removed workspace both leave orphans here.
`--orphans` is the reaper for exactly those. It closes what `pane ls --all`
lists as orphaned and nothing else — panes a workspace holds are untouched —
@@ -309,4 +319,3 @@ These parse and then exit 1 with an explanation:
- `ws stop` — the control dialect has no workspace-stop request yet
- `machine connect` / `machine disconnect` — use the GUI's connection manager
- bare `tty7 <path>` (launch or focus the GUI)
+17 -8
View File
@@ -33,10 +33,17 @@ turn for anything the primary lacks.
}
```
The defaults name faces the host OS actually ships — PingFang SC and Apple Color
Emoji on macOS, Microsoft YaHei and Segoe UI Emoji on Windows, Noto on Linux.
Those stock names are appended to whatever list you write, too, so a
`config.json` copied from another platform still resolves.
Leave `font_fallbacks` out and you get the platform's default chain:
| | Default fallbacks, in order |
|---|---|
| **macOS** | Menlo · Hasklug Nerd Font Mono · Maple Mono NF CN · PingFang SC · Apple Color Emoji |
| **Windows** | Maple Mono NF CN · Cascadia Mono · Microsoft YaHei · Segoe UI Emoji |
| **Linux** | Maple Mono NF CN · DejaVu Sans Mono · Noto Sans CJK SC · Noto Color Emoji |
Each ends in faces the host OS actually ships, and those stock names are
appended to whatever list you write yourself — so a `config.json` copied from
another platform still resolves.
## OpenType features
@@ -65,10 +72,12 @@ stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em.
Those glyphs get left-aligned in the slot, leaving a ~0.2em gap on the right of
every character.
[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is tried first on
every platform for exactly this reason: 0.6em Latin, 1.2em CJK, an exact
two-cell fit against Hack. It is referenced by name only, never bundled (~20 MB
per weight) — install it and tty7 picks it up with no config change.
[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is in every
platform's default chain for exactly this reason: 0.6em Latin, 1.2em CJK, an
exact two-cell fit against Hack. It leads the chain on Windows and Linux, and on
macOS sits behind Menlo and Hasklug, which cover Latin and Nerd Font glyphs
first. It is referenced by name only, never bundled (~20 MB per weight) —
install it and tty7 picks it up with no config change.
If you want CJK set *tight* rather than merely even, change the **primary** face
instead. One that advances 0.5em — Sarasa Mono SC, say — makes two columns
+1 -1
View File
@@ -35,7 +35,7 @@ Two limits keep a huge diff from becoming a huge wait:
| Limit | Value | What happens |
|---|---|---|
| Files rendered | 300 | *"Showing the first 300 of N changes."* |
| Files rendered | 300 | *"… and N more changed files — run git diff in the terminal to see them."* |
| Lines before auto-collapse | 400 per file | Big files start collapsed; expand the ones you care about |
Both are stated in the overlay when they apply — nothing is dropped silently.
+24 -9
View File
@@ -44,16 +44,28 @@ there too.
## Branches and remotes
The branch name at the top of the panel is a dropdown. It holds:
| | |
|---|---|
| **Checkout to…** | Switch branches, with a search box; offers **Stash & Switch** when the tree is dirty |
| **The branch list** | Click one to check it out. Past a dozen branches the list scrolls instead of growing past the window |
| **Create Branch…** | From here, or from any commit in the history |
| **Publish Branch** | For a branch with no upstream yet |
| **Sync Changes** | Pull, then push |
| **Push** · **Pull** · **Fetch** | Individually |
| **Fetch** · **Pull** · **Push** | Individually |
| **Switch Repository** | Only when the window has panes in more than one repo |
All of these are also in the command palette under **Git**, so they are
bindable.
Beside it, the sync button pulls then pushes — and relabels itself **Publish
Branch** when the branch has no upstream yet.
The command palette carries the verbs under **Git** — *Git: Commit*, *Stage
All*, *Unstage All*, *Discard All*, *Create Branch*, *Sync*, *Push*, *Pull*,
*Fetch* — so those are bindable. Checking out is a pick rather than a verb, so
it lives only in the dropdown.
<Warning>
Checking out does not stash for you. A dirty tree that would be clobbered
makes git refuse the checkout, and tty7 shows you git's own refusal as a
notification rather than working around it.
</Warning>
When a repository is mid-operation — merging, rebasing, cherry-picking,
reverting, bisecting, applying — the panel says so instead of pretending
@@ -61,9 +73,12 @@ everything is normal.
## History
**Git: Toggle Commit History** (or the *History* section header) opens the
commit graph: branches drawn as lanes, a filter box, **Current Branch** or
**All Branches**, and *Load more* at the bottom.
The *History* section header opens the commit graph: branches drawn as lanes, a
filter box, **Current Branch** or **All Branches**, and *Load more* at the
bottom.
**Git: Toggle Commit History** does the same from the keyboard. It ships with no
default key — bind one under **Settings → Keyboard Shortcuts**.
Click a commit for its detail view — message, parents, and the files it touched,
each openable as a diff. From a commit's menu:
+3 -3
View File
@@ -26,8 +26,8 @@ removes itself from the equation if you run the same shell elsewhere.
injection when shells nest.
<Note>
A shell launched with custom arguments is left alone for bash and PowerShell,
because tty7's injection would conflict with the flags you chose.
A shell launched with custom arguments is left alone for bash, PowerShell, and
WSL, because tty7's injection would conflict with the flags you chose.
</Note>
## What it reports
@@ -39,7 +39,7 @@ injection when shells nest.
| Command finished, with exit code | `OSC 133;D` | The "finished after 42s" notification, failure marks in [history](/terminal/history) |
| Working directory | `OSC 7` | New tabs and splits opening in the right place, the sidebar's repo grouping, the git branch readout |
| Editing mode (vi / emacs) | `OSC 133;V` | Matching tty7's key handling to your shell's mode |
| Window title | `OSC 0` | Tab labels |
| Window title | `OSC 0` | Tab labels. Only PowerShell is given this — zsh, bash, and fish already set a title of their own, and tty7 reads whatever they emit |
## What turns off without it
+1 -1
View File
@@ -38,7 +38,7 @@ the profile.
<kbd>⌘</kbd>-clicking a `localhost:PORT` link inside an SSH pane can open a
temporary forward for exactly that port and then open the browser — turn on
**Settings → Input → Links → Forward SSH loopback links**.
**Settings → Terminal → Links → Forward SSH loopback links**.
That is the right tool for "let me look at this dev server once". For something
you use every day, put it in the profile.
+5 -4
View File
@@ -60,7 +60,7 @@ instead of a password echoing into your shell.
| **Name** | A label for this connection |
| **Host** | Hostname or IP |
| **User** | Login user — blank resolves at connect time |
| **Auth** | *Auto* (tries every applicable method), *Password*, *Key*, *Agent*, or *2FA* |
| **Auth** | *Auto* (tries every applicable method), *GSSAPI*, *Password*, *Key*, *Agent*, or *2FA* |
| **Jump host** | Another profile, or a `ProxyJump` chain |
| **Port forwarding** | Rules opened with the connection |
@@ -110,6 +110,7 @@ the current pane. Useful after a laptop sleeps or a network changes.
## What is not supported
- No fallback to the system `ssh` binary
- No `Match`, `canonicalize*`, or GSSAPI directives from `~/.ssh/config`
- Kerberos `gssapi-with-mic` is offered by the desktop app for managed
connections, but is not part of the `~/.ssh/config` resolution path
- No `Match` or `canonicalize*` directives from `~/.ssh/config`
- No GSSAPI *directives* from `~/.ssh/config`. Kerberos `gssapi-with-mic` itself
is supported — pick **GSSAPI** in a profile's Auth field — it is just not
something the config-file resolution path reads
+16 -14
View File
@@ -8,23 +8,22 @@ description: "Fuzzy history search, and whether each pane gets its own."
<kbd>⌃ R</kbd> opens a fuzzy search over what you have actually run. Type any
fragment — the letters do not have to be adjacent — and the list narrows.
Each row carries the context the plain shell version throws away:
Each row carries context the plain shell version throws away:
- **where** you ran it, so `npm run dev` from three repositories is three
distinct entries
- **when**, as a relative time
- **when** you last ran it, as a relative time
- **whether it failed**, from the exit code
Ranking mixes frequency with recency, and commands you ran in the *current*
directory are pushed up — the thing you want is usually the thing you last did
here.
A command appears once, however many times you have run it — repeats collapse
into their most recent occurrence. Ranking mixes frequency with recency, and
commands you ran in the *current* directory are pushed up, so the thing you want
is usually the thing you last did here.
<Frame caption="Placeholder — screenshot: ⌃R open, matching rows showing directory, time, and a failed exit">
<Frame caption="Placeholder — screenshot: ⌃R open, matching rows showing relative time and a failed exit">
<img src="/images/placeholder.svg" alt="Fuzzy history search" />
</Frame>
<kbd>⏎</kbd> puts the command on the prompt. <kbd>Esc</kbd> closes without
touching it.
<kbd>⏎</kbd> puts the command on the prompt. <kbd>⌘ ⏎</kbd> puts it there and
runs it. <kbd>Esc</kbd> closes without touching it.
### Handing <kbd>⌃ R</kbd> back
@@ -35,16 +34,19 @@ working.
## Where the history comes from
Your existing shell history file, as-is. Nothing is imported or converted, there
is no separate store to warm up, and a history written outside tty7 shows up
immediately.
Your existing shell history file, as-is. Nothing is imported or converted, and a
history written outside tty7 shows up immediately.
The per-row extras — when you last ran it, whether it failed — come from a small
file tty7 keeps alongside it, filled in as you run things. A command tty7 has
never seen still appears; it just arrives without a timestamp or an exit code.
## One history, or one per pane
By default every pane shares your shell's history file, which is what a terminal
has always done: a command typed in one pane is available in the next.
**Settings → Terminal → Give each pane its own shell history**
**Settings → Input → Prompt → Give each pane its own shell history**
(`per_pane_history: true`) changes that. Each pane gets a private history file:
- **seeded** from your real history when the pane opens, so it is not blank
+5 -5
View File
@@ -11,7 +11,7 @@ pointer underline; click to open one.
Anything that looks like a URL is detected, including one the shell wrapped
across two lines — tty7 stitches it back together before opening it.
Turn detection off with **Settings → Input → Links → Detect URLs**
Turn detection off with **Settings → Terminal → Links → Detect URLs**
(`link_url: false`).
## Files
@@ -19,7 +19,7 @@ Turn detection off with **Settings → Input → Links → Detect URLs**
A file path in the output — a compiler error, a test failure, a `grep -n` hit —
opens in your default application for that file type.
To send it somewhere specific instead, set **Settings → Input → Links → Open
To send it somewhere specific instead, set **Settings → Terminal → Links → Open
files with**. The command runs with placeholders substituted:
```
@@ -41,9 +41,9 @@ The same setting is `link_file_command` in `config.json`.
useful if the server is on this machine.
When the pane is inside an SSH session it usually is not. Turn on **Settings →
Input → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`) and
tty7 opens a temporary port forward through that connection first, so the link
reaches the server on the remote machine.
Terminal → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`)
and tty7 opens a temporary port forward through that connection first, so the
link reaches the server on the remote machine.
<Tip>
For a forward you want to keep, set one up properly instead —
+1 -1
View File
@@ -19,7 +19,7 @@ ahead of the cursor.
|---|---|
| <kbd>→</kbd> | Accept the whole suggestion |
| Keep typing | The suggestion narrows |
| <kbd>Esc</kbd> or anything that does not match | It disappears |
| Anything that does not match | It disappears |
Your existing shell history is what feeds it — there is no separate database to
build up first, and it carries across sessions and reboots.
+4 -3
View File
@@ -23,7 +23,7 @@ Results are grouped, and the groups are the map of the app:
| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
| **Workspaces** | New Workspace · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… |
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Checkout to… · Create Branch… · Sync · Push · Pull · Fetch · Toggle Commit History |
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch |
| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All |
| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding |
| **Agents** | Send Selection · Send Git Diff for Review · Copy Session ID |
@@ -43,8 +43,9 @@ me@devbox:2222
[::1]:22
```
Saved profiles and `~/.ssh/config` aliases show up the same way — start typing
the name. [SSH →](/remote/ssh)
Saved profiles show up the same way — start typing the name. A host that only
exists in `~/.ssh/config` does not: import it into a profile first, or reach it
from the workspace switcher. [SSH →](/remote/ssh)
## Sending context to an agent
+5 -4
View File
@@ -20,15 +20,16 @@ Everything tty7 knows about the focused pane, in one column:
| **Processes** | the process tree inside the pane, with the foreground process marked |
| **Ports** | every port those processes are listening on |
The working directory row has **Reveal in Finder** / **Open Folder** beside it.
On a local pane, the working directory row has **Reveal in Finder** / **Open
Folder** beside it.
The ports section is the quickest answer to "what is this pane serving, and
where" — the same data `tty7 procs` prints.
## Source Control
The git panel for the focused pane's repository: staged changes, unstaged
changes, untracked files, and merge conflicts, each in its own group. Write a
message, commit, and push without leaving the window.
The git panel for the focused pane's repository, in four groups — **Merge
Changes**, **Staged Changes**, **Changes**, **Untracked**. Write a message,
commit, and push without leaving the window.
[Source control →](/git/source-control)
+15 -11
View File
@@ -39,8 +39,9 @@ default) and *Flat*.
[diff overlay](/git/diffs).
</Card>
<Card title="Whether you have looked" icon="circle-dot">
An unread marker on tabs that produced output while you were elsewhere.
*Mark as Unread* is in the right-click menu.
An unread marker on tabs where a coding agent finished its turn while you
were elsewhere. Agent tabs also carry *Mark as Unread* in the right-click
menu, once there is a finished turn to mark.
</Card>
</CardGroup>
@@ -50,21 +51,24 @@ they simply stop opening the overlay.
## Rearranging
Drag a row to reorder it, or drag a whole group header to move the group.
Dropping a row into another group moves the tab there.
Drag a row to reorder it within its group, or drag a whole group header to move
the group. A row cannot be dragged into a different group: with the default repo
grouping a tab's group comes from its working directory, so `cd` is what moves
it.
## Naming
Almost no tab has a name of its own, so the label falls back through the best
evidence available, in order:
Almost no tab has a name of its own, so the sidebar falls back:
1. a name you set (right-click → **Rename Tab…**)
2. the coding agent running in the tab — "Claude Code"
3. the last segment of the working directory
4. the foreground process
2. the title the shell is reporting — the running command, usually
3. **Shell 3**, numbered by position, when there is no title at all
That order is also what `tty7 tab ls` reports as `label`, with `name` left
literal so a script can tell a real name from a stand-in.
`tty7 tab ls` answers the same question with more evidence, because a script has
no screen to look at. Its `label` falls back through the name, then the coding
agent running in the tab ("Claude Code"), then the last segment of the working
directory, then the foreground process — while `name` stays literal, so a script
can tell a real name from a stand-in.
## The switcher
+4 -3
View File
@@ -23,9 +23,10 @@ A new tab always opens in the current pane's directory. Where it lands in the
list is **Settings → Window & Tabs → New tab position** — *After current* by
default, or *At end*.
Right-click a tab for the rest: rename, close others, close to the right, copy
the working directory, mark unread, and — when a coding agent is running there —
fork its session.
Right-click a tab for the rest: rename, split right or down, a new worktree tab,
copy the working directory, copy the session id, close · close others · close to
the right, and — when a coding agent is running there — mark unread and fork its
session.
## Splits
+7 -1
View File
@@ -191,7 +191,13 @@ fn build_spec_inner(
let mut key_passphrases: HashMap<String, String> = HashMap::new();
if matches!(profile.auth, AuthMode::Auto | AuthMode::PublicKey) {
for path in &identity_files {
// Explicit files, then the same `~/.ssh` defaults the daemon probes
// (#484): it looks passphrases up by the candidate string, so both
// sides must iterate the one shared list.
for path in identity_files
.iter()
.chain(crate::core::ssh_profile::default_identity_candidates().iter())
{
let Ok(bytes) = std::fs::read(path) else {
continue;
};