Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ portable-pty = { path = "vendor/portable-pty" }
# UTF-16 SDDL input for interprocess security descriptors.
widestring = "1.2"
wmi = { version = "0.18.4", default-features = false }
windows = { version = "0.62.2", features = [
"Win32_System_Com",
"Win32_System_Ole",
"Win32_System_TaskScheduler",
"Win32_System_Variant",
] }
windows-sys = { version = "0.61.2", features = [
"Wdk_System_Threading",
"Win32_Foundation",
Expand All @@ -69,6 +75,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_System_Pipes",
"Win32_System_RemoteDesktop",
"Win32_System_Threading",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
Expand Down
17 changes: 13 additions & 4 deletions docs/next/website/src/content/docs/persistence-remote.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Save repeat targets to work across several machines from one Herdr client:
```bash
herdr machine add workbox --label "Build machine" --remote-session agents
herdr machine add ssh://you@server:2222 --label "Production" --remote-session operations
herdr machine add windows-workbox --label "Desktop agents" --remote-desktop
herdr machine list
```

Expand All @@ -68,7 +69,7 @@ herdr machine remove <profile-id>

Renaming updates the label without reconnecting. Removing or disabling a machine disconnects only that machine from the client; its remote sessions keep running, even if the machine is unreachable. If you remove or disable the machine you are viewing, the client returns to Local. If Local is unavailable, it shows that and reconnects rather than selecting another remote. An unreadable or invalid saved-machine file leaves current connections unchanged and shows a notice; the client retries reading it automatically.

Saved profiles contain only the opaque ID, label, SSH target, explicit remote session, and enabled state. Passwords, private keys, agent tickets, and SSH control sockets are never stored in the machine catalog.
Saved profiles contain only the opaque ID, label, SSH target, explicit remote session, enabled state, and whether Windows desktop hosting is required. Passwords, private keys, agent tickets, and SSH control sockets are never stored in the machine catalog.

The client uses local keybindings by default. Custom commands advertised by the selected machine still execute on that machine. Herdr does not copy local command plugins, configuration, executables, or secrets to SSH hosts; a missing remote command or plugin fails visibly.

Expand All @@ -83,6 +84,14 @@ herdr --remote ssh://you@server:2222

In this mode, your local Herdr is a thin client. It connects over SSH, starts or attaches to the remote Herdr server, and streams the UI back to your local terminal. Because the client runs locally, Herdr can bridge local desktop features such as image clipboard paste into the remote session by copying the image to a remote temp file and pasting that path.

Windows SSH normally starts processes outside the signed-in desktop. To let agents on a Windows target interact with desktop apps, opt in explicitly:

```bash
herdr --remote windows-workbox --remote-desktop
```

To start a desktop server, Herdr requires exactly one active desktop login for the SSH account. It asks for confirmation, creates a one-time Windows task to launch the server in that login, and removes the task after the server is ready. Automatic reconnects only attach; they do not create tasks or start a server. A server with the same Herdr session name in Windows Session 0 or another login is left running and reported as a conflict. Use a different `--session` or stop that server explicitly. This option does not sign in, unlock Windows, or move existing panes between Windows sessions.

By default, `herdr --remote` uses your local Herdr keybindings for that attach. This keeps local muscle memory even when the remote server has different config. The local keybindings are a snapshot from attach time; detach and reattach after editing local keybindings. Use `--remote-keybindings server` when you want the remote server config instead. Local custom command keybindings are not sent, because those commands would run on the remote host.

For repeat targets, use your SSH config:
Expand All @@ -100,7 +109,7 @@ Then attach with:
herdr --remote workbox
```

Remote attach supports Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. Local and remote versions do not need to match once both support the stable endpoint generation. If no compatible binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows is not supported as the remote host.
Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. On Windows hosts, Herdr checks `PATH` and the active managed release. If no compatible binary exists, interactive runs prompt to install or update one; non-interactive runs fail instead of modifying the host. Linux and macOS installs use `~/.local/bin/herdr`; Windows installs use the complete Windows package and its app-local ConPTY runtime. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install.

By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket.

Expand All @@ -121,9 +130,9 @@ herdr --remote workbox --handoff

If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server and cannot access your local desktop clipboard beyond normal terminal text paste.

When your local and remote platforms match, Herdr can copy the current local binary for direct installs. For Homebrew, mise, and Nix installs, or when the platforms differ, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`.
For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, when the platforms differ, or for Windows hosts, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`.

For local builds or custom binaries, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach.
For local builds or custom packages, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. Use a bare Herdr executable for Linux or macOS targets and a complete `herdr-windows-x86_64.zip` package for Windows targets.

```bash
HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox
Expand Down
10 changes: 6 additions & 4 deletions docs/next/website/src/content/docs/windows-beta.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m
| Local persistent sessions | supported |
| Native panes through ConPTY | supported |
| Windows Terminal / PowerShell app attach | supported |
| `herdr --remote` to Linux/macOS hosts | supported |
| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages |
| Remote agents in the signed-in Windows desktop | supported with `--remote-desktop` |
| Remote clipboard images and image-file drops | supported |
| `cmd.exe` panes | supported |
| Native keyboard and mouse input | supported |
Expand All @@ -49,6 +50,8 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m

Local persistent sessions continue running after the client detaches or its terminal window closes. Servers and pane processes launched through Windows OpenSSH also survive logout; run `herdr` again to reconnect.

Windows OpenSSH starts its normal server in Windows Session 0. Add `--remote-desktop` to a direct remote attach or `machine add` command when agents must interact with apps in the SSH account's active desktop login. Herdr asks before it uses a one-time Windows task for the initial start, removes the task after readiness, and leaves an existing server in another Windows session untouched.

Windows agent process detection scans descendants of the pane shell and recognizes direct agents plus common command wrappers, including npm/Node and Git Bash process chains. It follows Git Bash-launched agents across emulated `exec` boundaries, but it is not the same as Unix foreground process-group detection.

Windows integration installation currently supports Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, OpenCode, Kilo Code CLI, Droid, Kimi Code CLI, Qoder CLI, and Antigravity CLI. Availability is narrower than on Unix; Herdr hides or rejects integrations whose install format is not supported on Windows.
Expand Down Expand Up @@ -80,7 +83,7 @@ switch_ascii_input_source_in_prefix = true

Other Windows IMEs are not supported by this option yet. See [Configuration](/docs/configuration/#prefix-input-source-switching).

Some Windows agents can receive `ctrl+v` and read clipboard images directly. Herdr's own clipboard-image reader is not wired into local native Windows panes, so agent-native image paste remains dependent on the terminal and agent. Agent image-paste shortcuts such as `alt+v` do not add a Herdr-managed local clipboard bridge. Remote clipboard image bridging is supported separately through `herdr --remote` to Linux and macOS hosts.
Some Windows agents can receive `ctrl+v` and read clipboard images directly. Herdr's own clipboard-image reader is not wired into local native Windows panes, so agent-native image paste remains dependent on the terminal and agent. Agent image-paste shortcuts such as `alt+v` do not add a Herdr-managed local clipboard bridge. Remote clipboard image bridging is supported separately through `herdr --remote` to Linux, macOS, and Windows hosts.

Kitty graphics is enabled by default and depends on the outer terminal. Herdr emits Kitty graphics protocol output on Windows as it does on other platforms. This path has been exercised with Windows WezTerm hosting Herdr through WSL, but native Windows terminal and ConPTY combinations are not all verified. Windows Terminal does not expose the Kitty graphics path Herdr uses. Set `[terminal].kitty_graphics = false` if the outer terminal mishandles graphics output.

Expand Down Expand Up @@ -118,7 +121,6 @@ For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is
| Capability | Status |
| --- | --- |
| Direct terminal attach (`herdr terminal attach`) | unsupported |
| Windows as a `herdr --remote` target host | unsupported |
| Live server handoff | unsupported |
| Unix file-descriptor handoff | unsupported |
| Unix foreground process groups | unsupported |
Expand All @@ -131,7 +133,7 @@ From Windows Terminal, use the same remote command as Linux and macOS:
herdr --remote workbox
```

The target host must run Linux or macOS. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup.
The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup.

Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only.

Expand Down
1 change: 1 addition & 0 deletions nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ rustPlatform.buildRustPackage {
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) (
lib.fileset.unions [
../assets
../distribution/install.ps1
../docs/next/api/herdr-api.schema.json
../src
../vendor/libghostty-vt
Expand Down
2 changes: 1 addition & 1 deletion src/checksum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub(crate) fn verify_sha256(path: &Path, expected: &str) -> io::Result<()> {
Ok(())
}

fn file_sha256(path: &Path) -> io::Result<String> {
pub(crate) fn file_sha256(path: &Path) -> io::Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
Expand Down
25 changes: 19 additions & 6 deletions src/cli/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::client::endpoint::{EndpointCatalog, ProfileId};

const HELP: &str = "Usage:
herdr machine list [--json]
herdr machine add <ssh-target> --label <label> [--remote-session <name>]
herdr machine add <ssh-target> --label <label> [--remote-session <name>] [--remote-desktop]
herdr machine rename <profile-id> --label <label>
herdr machine remove <profile-id>
herdr machine enable <profile-id>
Expand All @@ -14,7 +14,7 @@ Add prepares the remote Herdr installation and starts its server before saving.
Missing or incompatible installations require approval in an interactive terminal.
Changes apply automatically to open local Herdr clients.
Removing or disabling a machine leaves its remote sessions running.
Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state.
Saved machines contain a label, SSH target, explicit Herdr session, enabled state, and desktop hosting choice.
SSH credentials and key material remain owned by OpenSSH.";

#[derive(Serialize)]
Expand All @@ -25,6 +25,7 @@ struct MachineListRow<'a> {
session: &'a str,
enabled: bool,
selected: bool,
windows_desktop: bool,
}

pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> {
Expand Down Expand Up @@ -66,6 +67,7 @@ fn list(args: &[String]) -> std::io::Result<i32> {
session: &profile.session,
enabled: profile.enabled,
selected: catalog.selected_profile.as_ref() == Some(&profile.id),
windows_desktop: profile.windows_desktop,
})
.collect::<Vec<_>>();
if json {
Expand Down Expand Up @@ -93,12 +95,13 @@ fn add(args: &[String]) -> std::io::Result<i32> {
let args = super::expand_equals_args(args, &["--label", "--remote-session"]);
let Some(target) = args.first().filter(|value| !value.starts_with('-')) else {
eprintln!(
"usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]"
"usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>] [--remote-desktop]"
);
return Ok(2);
};
let mut label = None;
let mut session = None;
let mut windows_desktop = false;
let mut index = 1;
while index < args.len() {
let (name, value) = match args[index].as_str() {
Expand All @@ -110,6 +113,15 @@ fn add(args: &[String]) -> std::io::Result<i32> {
index += 2;
(args[index - 2].as_str(), value.clone())
}
"--remote-desktop" if !windows_desktop => {
windows_desktop = true;
index += 1;
continue;
}
"--remote-desktop" => {
eprintln!("--remote-desktop can only be specified once");
return Ok(2);
}
unknown => {
eprintln!("unknown machine add option: {unknown}");
return Ok(2);
Expand All @@ -135,14 +147,14 @@ fn add(args: &[String]) -> std::io::Result<i32> {
};
let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned());
let mut catalog = load_catalog()?;
match catalog.add_ssh(label.clone(), target, session.clone()) {
match catalog.add_ssh_with_desktop(label.clone(), target, session.clone(), windows_desktop) {
Ok(_) => {}
Err(error) => {
eprintln!("error: {error}");
return Ok(2);
}
}
if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) {
if let Err(error) = crate::remote::prepare_saved_ssh(target, &session, windows_desktop) {
eprintln!("error: {error}; machine was not saved");
crate::remote::print_remote_error_hint(&error, target);
return Ok(1);
Expand All @@ -153,7 +165,7 @@ fn add(args: &[String]) -> std::io::Result<i32> {
"remote prepared, but machine was not saved: {error}"
))
})?;
let id = match catalog.add_ssh(label, target, session) {
let id = match catalog.add_ssh_with_desktop(label, target, session, windows_desktop) {
Ok(id) => id,
Err(error) => {
eprintln!("error: {error}");
Expand Down Expand Up @@ -287,6 +299,7 @@ mod tests {
session: "agents",
enabled: true,
selected: false,
windows_desktop: false,
})
.unwrap();
assert!(!encoded.contains("password"));
Expand Down
4 changes: 4 additions & 0 deletions src/cli/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ struct ClientStatusJson {
protocol: u32,
endpoint_protocol_generation: u32,
endpoint_capabilities: Vec<&'static str>,
remote_host_bridge: bool,
remote_desktop_host: bool,
binary: String,
session: Option<String>,
}
Expand Down Expand Up @@ -301,6 +303,8 @@ fn client_status_json() -> ClientStatusJson {
crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY,
],
remote_host_bridge: true,
remote_desktop_host: cfg!(windows),
binary: current_exe_label(),
session: crate::session::active_name(),
}
Expand Down
1 change: 1 addition & 0 deletions src/client/endpoint/activation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> Test
target: "dev@example.com".into(),
session: "main".into(),
enabled: true,
windows_desktop: false,
};
let target = ClientEndpointId::Ssh(profile.id.clone());
shell.set_endpoint_catalog(&[profile]);
Expand Down
Loading
Loading