mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user