From 00e1aa8218004ad42c8ec440d8c8b460b8e21b75 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Tue, 11 Aug 2026 14:35:54 +0800
Subject: [PATCH 1/2] docs: correct claims that no longer match the code
Audited every page under docs/ against the source. Fixes for what the
code actually does:
- agents: the status vocabulary is idle/working/waiting/done, not
running/waiting/idle; hook rows grow a separate Uninstall button; the
Settings table labels read "Copilot CLI" and "Grok Build"; Copy Session
ID lives in the tab's context menu, not the pane's
- cli: `pane ls --all` reports the owning workspace id, not "tty7-cli";
document bare `tty7 [PATH]` as the GUI launcher it is instead of listing
it as unimplemented; note `active_tab` and the `diagnostics` array; wait
also defaults to $TTY7_PANE
- git: the branch dropdown is a plain list with no search box and no
stash-and-switch, and checkout is not a palette command; quote the diff
overlay's own overflow notice rather than the sidebar's
- window: the unread marker tracks a finished agent turn, not any output;
rows cannot be dragged across groups; the sidebar and `tty7 tab ls`
resolve labels differently; drop Toggle Commit History and Checkout to
from the palette's Git group; ~/.ssh/config aliases are not palette
entries
- terminal: Ctrl+R dedups by command text and shows no directory; Esc does
not dismiss a ghost suggestion; document Cmd+Enter
- remote: GSSAPI is an ordinary Auth choice, not a managed-connection-only
mechanism
- fonts: Maple Mono NF CN leads the chain on Windows and Linux only; list
the real per-platform defaults
- settings paths: the three Links settings and per-pane history were filed
under the wrong sections
---
docs/agents/sessions.mdx | 2 +-
docs/agents/status.mdx | 21 +++++++++++-------
docs/cli/overview.mdx | 6 ++---
docs/cli/reference.mdx | 27 ++++++++++++++++-------
docs/customization/fonts.mdx | 25 ++++++++++++++-------
docs/git/diffs.mdx | 2 +-
docs/git/source-control.mdx | 33 ++++++++++++++++++++--------
docs/reference/shell-integration.mdx | 6 ++---
docs/remote/port-forwarding.mdx | 2 +-
docs/remote/ssh.mdx | 9 ++++----
docs/terminal/history.mdx | 30 +++++++++++++------------
docs/terminal/links.mdx | 10 ++++-----
docs/terminal/prompt.mdx | 2 +-
docs/window/command-palette.mdx | 7 +++---
docs/window/side-panel.mdx | 9 ++++----
docs/window/sidebar.mdx | 26 ++++++++++++----------
docs/window/tabs-and-splits.mdx | 7 +++---
17 files changed, 137 insertions(+), 87 deletions(-)
diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx
index 7f70818f..bc8f685b 100644
--- a/docs/agents/sessions.mdx
+++ b/docs/agents/sessions.mdx
@@ -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.
diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx
index d9799e53..70eab33c 100644
--- a/docs/agents/status.mdx
+++ b/docs/agents/status.mdx
@@ -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.
The hooks only do anything inside tty7. Running the same agent in another
terminal is unaffected.
-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
@@ -43,8 +47,9 @@ Every tab chip and sidebar row carries a dot:
-The same three states are what `tty7 agents` reports as `running` / `waiting` /
-`idle`, and what [`tty7 wait`](/agents/orchestration) blocks on.
+The same three states are what `tty7 agents` reports as `working` / `waiting` /
+`done`, and what [`tty7 wait`](/agents/orchestration) blocks on. A fourth state,
+`idle`, carries no dot — it is an agent that has not started a turn.
## Notifications
diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx
index ac1c7336..0754bc8b 100644
--- a/docs/cli/overview.mdx
+++ b/docs/cli/overview.mdx
@@ -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
diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx
index 254abd98..92380b29 100644
--- a/docs/cli/reference.mdx
+++ b/docs/cli/reference.mdx
@@ -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`.
@@ -115,8 +125,10 @@ JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","na
### `tty7 agents`
Every pane running a recognised coding agent. Table:
-`PANE AGENT STATUS MESSAGE`, status one of `running` / `waiting` / `idle`.
-JSON: `{"agents":[...]}`.
+`PANE AGENT STATUS MESSAGE`, status one of `idle` / `working` / `waiting` /
+`done`. JSON: `{"agents":[...]}`, plus a `"diagnostics"` array when an agent's
+status hook is missing or out of date — that is why an agent can be listed with
+a status that never moves.
### `tty7 wait [%PANE] [--until STATE,…] [--changed] [--timeout SECS] [--interval MS]`
@@ -164,7 +176,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"}` |
@@ -217,9 +229,9 @@ a stand-in.
| `pane close [%PANE]` | Close the pane; its shell is hung up | `{"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.
`title` is usually the running command — `claude`, `nvim`, `cargo` — which makes
`pane ls --all --json` a quick way to find "the pane running X".
@@ -250,4 +262,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 ` (launch or focus the GUI)
diff --git a/docs/customization/fonts.mdx b/docs/customization/fonts.mdx
index 544fc5ba..29777c6d 100644
--- a/docs/customization/fonts.mdx
+++ b/docs/customization/fonts.mdx
@@ -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
diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx
index 5c2441f3..836d825a 100644
--- a/docs/git/diffs.mdx
+++ b/docs/git/diffs.mdx
@@ -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.
diff --git a/docs/git/source-control.mdx b/docs/git/source-control.mdx
index da3f23f2..b0c0b4ef 100644
--- a/docs/git/source-control.mdx
+++ b/docs/git/source-control.mdx
@@ -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.
+
+
+ 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.
+
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:
diff --git a/docs/reference/shell-integration.mdx b/docs/reference/shell-integration.mdx
index 9e732d29..3f77bfbf 100644
--- a/docs/reference/shell-integration.mdx
+++ b/docs/reference/shell-integration.mdx
@@ -26,8 +26,8 @@ removes itself from the equation if you run the same shell elsewhere.
injection when shells nest.
- 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.
## 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
diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx
index b23cbc95..99e1b1aa 100644
--- a/docs/remote/port-forwarding.mdx
+++ b/docs/remote/port-forwarding.mdx
@@ -38,7 +38,7 @@ the profile.
⌘-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.
diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx
index ed44a8ab..fe632340 100644
--- a/docs/remote/ssh.mdx
+++ b/docs/remote/ssh.mdx
@@ -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
diff --git a/docs/terminal/history.mdx b/docs/terminal/history.mdx
index 4b3f1433..c3e875ec 100644
--- a/docs/terminal/history.mdx
+++ b/docs/terminal/history.mdx
@@ -8,23 +8,22 @@ description: "Fuzzy history search, and whether each pane gets its own."
⌃ R 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.
-
+
-⏎ puts the command on the prompt. Esc closes without
-touching it.
+⏎ puts the command on the prompt. ⌘ ⏎ puts it there and
+runs it. Esc closes without touching it.
### Handing ⌃ R 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
diff --git a/docs/terminal/links.mdx b/docs/terminal/links.mdx
index 0b8c3d34..5d937729 100644
--- a/docs/terminal/links.mdx
+++ b/docs/terminal/links.mdx
@@ -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.
For a forward you want to keep, set one up properly instead —
diff --git a/docs/terminal/prompt.mdx b/docs/terminal/prompt.mdx
index e430c190..5e71f2d4 100644
--- a/docs/terminal/prompt.mdx
+++ b/docs/terminal/prompt.mdx
@@ -19,7 +19,7 @@ ahead of the cursor.
|---|---|
| → | Accept the whole suggestion |
| Keep typing | The suggestion narrows |
-| Esc 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.
diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx
index 154eb289..ffff9b38 100644
--- a/docs/window/command-palette.mdx
+++ b/docs/window/command-palette.mdx
@@ -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
diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx
index 11bacee7..d2f4258c 100644
--- a/docs/window/side-panel.mdx
+++ b/docs/window/side-panel.mdx
@@ -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)
diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx
index 9b49e427..ef9236e8 100644
--- a/docs/window/sidebar.mdx
+++ b/docs/window/sidebar.mdx
@@ -39,8 +39,9 @@ default) and *Flat*.
[diff overlay](/git/diffs).
- 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.
@@ -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
diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx
index 423fe05d..e0095085 100644
--- a/docs/window/tabs-and-splits.mdx
+++ b/docs/window/tabs-and-splits.mdx
@@ -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
From 364e24af87b98ab273dbdfa03703aa5dea548566 Mon Sep 17 00:00:00 2001
From: Hongwei Qin <122079993+shihuaidexianyu@users.noreply.github.com>
Date: Tue, 11 Aug 2026 20:03:02 +0800
Subject: [PATCH 2/2] feat(ssh): probe ~/.ssh default identity keys (#507)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A connection with no explicit IdentityFile used to offer the server
nothing at all when the agent was unavailable — the default on Windows,
where the OpenSSH Authentication Agent service ships disabled — and then
reported "no public key was accepted", for keys it had never sent.
Offer the `~/.ssh` defaults (id_ed25519, id_ecdsa, id_rsa) after the
explicit identities and before the agent, from one shared candidate list
in `core::ssh_profile` so the GUI and the daemon key `key_passphrases`
by the same strings. Candidates are deduped against the explicit list by
canonical path, comparing the expanded paths the reader actually opens.
A discovered key that is encrypted is used only when its passphrase is
already cached, never prompted for; explicit keys keep prompting. Files
that do not exist are skipped in silence, a `.pub` is never offered as a
private key, and the failure text now separates "the server turned these
down" from "nothing usable was found".
The tests build their ed25519 fixture at run time from a fixed seed
rather than embedding a PEM blob, so the tree carries no private key.
Closes #484.
---
CHANGELOG.md | 16 +
crates/tty7-core/src/core/ssh_profile.rs | 78 ++-
crates/tty7-core/src/daemon/ssh/auth.rs | 595 ++++++++++++++++++++---
src/ui/ssh_connect.rs | 8 +-
4 files changed, 611 insertions(+), 86 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1067ed5a..9db2090c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -63,6 +63,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
A pane's shell is recorded alongside, so a git bash pane no longer comes
back as PowerShell.
+- **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)
+
### Fixed
- **An SFTP upload no longer sits in the browser under its temporary name** —
diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs
index 8f5db096..8fcbfdad 100644
--- a/crates/tty7-core/src/core/ssh_profile.rs
+++ b/crates/tty7-core/src/core/ssh_profile.rs
@@ -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 {
+ #[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 {
+ 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 {
+ ["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");
diff --git a/crates/tty7-core/src/daemon/ssh/auth.rs b/crates/tty7-core/src/daemon/ssh/auth.rs
index f8661e96..1e8412c4 100644
--- a/crates/tty7-core/src/daemon/ssh/auth.rs
+++ b/crates/tty7-core/src/daemon/ssh/auth.rs
@@ -439,10 +439,36 @@ async fn try_publickeys(
broker: &Arc,
) -> Outcome {
let mut last: Option = 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 = 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 {
+ 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,
+ explicit: &[String],
+ canon: impl Fn(&str) -> Option,
+) -> Vec {
+ let mut seen: std::collections::HashSet =
+ 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,
+ /// File keys the server rejected, same spelling.
+ rejected_files: Vec,
+ /// 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,
+ /// Transport-level errors after a key was decoded.
+ errors: Vec,
+}
+
+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 = 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,
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, spec: &NativeSshSpec) -> Outcome {
+async fn try_agent(
+ handle: &mut Handle,
+ 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, 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(
handle: &mut Handle,
spec: &NativeSshSpec,
mut agent: AgentClient,
+ 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 = 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 {
}
}
-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 {
- std::env::var("HOME").ok().filter(|h| !h.is_empty())
-}
-
-#[cfg(not(unix))]
-fn home_dir() -> Option {
- 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"
+ );
+ }
}
diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs
index 06c6699b..ee5a49b0 100644
--- a/src/ui/ssh_connect.rs
+++ b/src/ui/ssh_connect.rs
@@ -191,7 +191,13 @@ fn build_spec_inner(
let mut key_passphrases: HashMap = 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;
};