aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-05 19:34:24 +0300
committerGitHub <noreply@github.com>2026-08-05 19:34:24 +0300
commit2863b715132fe29e53089e06f105943d1df0b3b4 (patch)
tree6b4af2f6fdec6de2888fd2bff3c0829d6719b727
parenteb93ec8ce62b05de776f751f4d2f52ff60f93f16 (diff)
feat(windows): support remote attach to unix hosts (#2329)
* feat(windows): support remote attach to unix hosts * fix(remote): preserve unix bridge behavior
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml4
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx4
-rw-r--r--docs/next/website/src/content/docs/ja/configuration.mdx4
-rw-r--r--docs/next/website/src/content/docs/ja/persistence-remote.mdx6
-rw-r--r--docs/next/website/src/content/docs/ja/windows-beta.mdx16
-rw-r--r--docs/next/website/src/content/docs/persistence-remote.mdx6
-rw-r--r--docs/next/website/src/content/docs/windows-beta.mdx16
-rw-r--r--docs/next/website/src/content/docs/zh-cn/configuration.mdx4
-rw-r--r--docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx6
-rw-r--r--docs/next/website/src/content/docs/zh-cn/windows-beta.mdx16
-rw-r--r--src/client/mod.rs247
-rw-r--r--src/ipc.rs100
-rw-r--r--src/main.rs1
-rw-r--r--src/platform/fallback.rs79
-rw-r--r--src/platform/linux.rs6
-rw-r--r--src/platform/macos.rs6
-rw-r--r--src/platform/mod.rs16
-rw-r--r--src/platform/unix_common.rs131
-rw-r--r--src/platform/windows.rs201
-rw-r--r--src/platform/windows/clipboard_image.rs409
-rw-r--r--src/remote.rs144
-rw-r--r--src/remote/attach.rs (renamed from src/remote/unix.rs)632
-rw-r--r--src/remote/host_unix.rs69
-rw-r--r--src/update.rs5
25 files changed, 1667 insertions, 462 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 980fcb5d..fbf3ebcf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -697,6 +697,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"unicode-width",
+ "widestring",
"windows-sys",
"wmi",
]
diff --git a/Cargo.toml b/Cargo.toml
index 84949e2d..52455835 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,11 +49,15 @@ schemars = { version = "1.2.1", features = ["derive"] }
portable-pty = { path = "vendor/portable-pty" }
[target.'cfg(windows)'.dependencies]
+# UTF-16 SDDL input for interprocess security descriptors.
+widestring = "1.2"
wmi = { version = "0.18.4", default-features = false }
windows-sys = { version = "0.61.2", features = [
"Wdk_System_Threading",
"Win32_Foundation",
"Win32_Globalization",
+ "Win32_Security",
+ "Win32_Storage_FileSystem",
"Win32_System_DataExchange",
"Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index e0c41c28..ce4db4e2 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -98,14 +98,14 @@ Deleting a worktree checkout is explicit. Use `Delete worktree checkout...` on a
## Remote attach
-Remote attach manages its SSH connection with a temporary keepalive and connection-reuse fallback by default.
+Remote attach manages its SSH connection with temporary keepalives and, where supported, connection reuse by default.
```toml
[remote]
manage_ssh_config = true
```
-When enabled, `herdr --remote` writes a private temporary SSH config that includes your `~/.ssh/config` and `/etc/ssh/ssh_config` first, then adds fallback `ServerAliveInterval` and `ServerAliveCountMax` values. Your own keepalive settings win. Herdr also uses a private per-attach OpenSSH control socket to reuse the first authenticated connection. Set `manage_ssh_config = false` to run remote attach through plain `ssh` without Herdr's generated config or control socket.
+When enabled, `herdr --remote` writes a private temporary SSH config that includes your user and system SSH configs first, then adds fallback `ServerAliveInterval` and `ServerAliveCountMax` values. Your own keepalive settings win. Linux and macOS clients also use a private per-attach OpenSSH control socket to reuse the first authenticated connection; Windows OpenSSH does not. Set `manage_ssh_config = false` to run remote attach through plain `ssh` without Herdr's generated config or control socket.
## Keybindings
diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx
index acc958fb..9f7c0e5e 100644
--- a/docs/next/website/src/content/docs/ja/configuration.mdx
+++ b/docs/next/website/src/content/docs/ja/configuration.mdx
@@ -98,14 +98,14 @@ worktree チェックアウトの削除は明示的に行います。グルーãƒ
## リモートアタッチ
-リモートアタッチは、デフォルトで一時的なキープアライブと接続再利用のフォールバックを使って SSH 接続を管理します。
+リモートアタッチは、デフォルトで一時的なキープアライブと、対応する環境では接続再利用を使って SSH 接続を管理します。
```toml
[remote]
manage_ssh_config = true
```
-有効な場合、`herdr --remote` は最初に `~/.ssh/config` と `/etc/ssh/ssh_config` を取り込み、その後にフォールバックの `ServerAliveInterval` と `ServerAliveCountMax` の値を加えた、プライベートな一時 SSH 設定を書き込みます。ユーザー自身のキープアライブ設定が優先されます。Herdr は、最初に認証した接続を再利用するため、アタッチごとにプライベートな OpenSSH コントロールソケットも使います。Herdr が生成した設定やコントロールソケットを使わず、通常の `ssh` でリモートアタッチを実行するには `manage_ssh_config = false` を設定してください。
+有効な場合、`herdr --remote` は最初にユーザーとシステムの SSH 設定を取り込み、その後にフォールバックの `ServerAliveInterval` と `ServerAliveCountMax` の値を加えた、プライベートな一時 SSH 設定を書き込みます。ユーザー自身のキープアライブ設定が優先されます。Linux と macOS クライアントは、最初に認証した接続を再利用するため、アタッチごとにプライベートな OpenSSH コントロールソケットも使います。Windows OpenSSH ではこの接続再利用を使いません。Herdr が生成した設定やコントロールソケットを使わず、通常の `ssh` でリモートアタッチを実行するには `manage_ssh_config = false` を設定してください。
## キーバインド
diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx
index f8d3ff02..6788dd06 100644
--- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx
+++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx
@@ -63,11 +63,9 @@ Host workbox
herdr --remote workbox
```
-リモートアタッチは x86_64 と aarch64 の Linux および macOS ホストをサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある一致する `herdr` を優先し、次に `~/.local/bin/herdr` を確認します。一致するバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。
+リモートアタッチは、Linux、macOS、Windows のローカルクライアントから、x86_64 と aarch64 の Linux および macOS ホストへの接続をサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある一致する `herdr` を優先してから、一般的な直接インストール、Homebrew、mise、Nix プロファイルのパスを確認します。一致するバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。Windows をリモートホストとして使うことはできません。
-ネイティブ Windows の `herdr --remote` は Windows ベータの範囲外です。Windows からはサーバーに SSH してそこで `herdr` を実行してください。
-
-デフォルトでは、`herdr --remote` はあなたの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Herdr が生成するブリッジ設定を使わず素の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。
+デフォルトでは、`herdr --remote` はユーザーの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してリモートセットアップとブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Linux と macOS クライアントは接続再利用用のプライベートなアタッチ単位コントロールソケットも使いますが、Windows OpenSSH では使いません。Herdr が生成する設定やコントロールソケットを使わず通常の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。
デフォルトでは、実行中のリモートサーバーの置き換えや再起動が必要な場合、リモートアタッチは通常の再起動/停止フローを使います。対応する実行中リモートサーバーで実験的なライブハンドオフにオプトインするには `--handoff` を渡します:
diff --git a/docs/next/website/src/content/docs/ja/windows-beta.mdx b/docs/next/website/src/content/docs/ja/windows-beta.mdx
index ee0f8567..8edbf7e0 100644
--- a/docs/next/website/src/content/docs/ja/windows-beta.mdx
+++ b/docs/next/website/src/content/docs/ja/windows-beta.mdx
@@ -26,6 +26,8 @@ Windows ベータビルドはプレビューチャンネルでのみ提供され
| ローカル永続セッション | ベータ |
| ConPTY によるネイティブペイン | ベータ |
| Windows Terminal / PowerShell アプリからのアタッチ | ベータ |
+| Linux/macOS ホストへの `herdr --remote` | ベータ |
+| リモートクリップボード画像と画像ファイルのドロップ | ベータ |
| `cmd.exe` ペイン | ベータ |
| 起動時 cwd とワークスペースラベル | ベータ |
| ペイン起動時の cwd | ベータ |
@@ -47,14 +49,14 @@ Windows のエージェントプロセス検出は、ペインのシェルの子
| --- | --- |
| シェルで `cd` した後のライブ cwd | 部分的 |
| シェルインテグレーション/OSC7 によるライブ cwd | ベータ |
-| エージェントへのクリップボード画像貼り付け | 未検証 |
+| ローカルペインのエージェントへのクリップボード画像貼り付け | 未検証 |
| CJK IME の変換候補位置 | 部分的 |
| Kitty graphics のレンダリング | 未検証 |
| ホストカーソル描画 | 部分的 |
Herdr はペインを正しいディレクトリで起動でき、Herdr を起動したディレクトリから最初のワークスペースを作成できます。起動後の PowerShell のディレクトリ変更は別問題です: Herdr が調べられるプロセスのフィールドは、その後の論理的な `cd` の変化を確実には追跡しません。ライブ cwd の報告には Herdr インテグレーションかプロンプトのシェルインテグレーションを使ってください。
-Windows Terminal は特定のエージェント向けに画像貼り付け経路をサポートしているかもしれませんが、Herdr 自身のクリップボード画像リーダーはまだ Windows に配線されていません。Windows のクリップボードブリッジが実装・テストされるまで、`alt+v` の画像貼り付けは未検証として扱ってください。リモートクリップボードの画像ブリッジは別機能で、引き続き Unix/macOS の `herdr --remote` に紐づいています。
+`herdr --remote` では、設定されたリモート画像貼り付けキーが Windows のクリップボード画像を読み取り、リモートホストへ転送します。Windows Terminal にローカル画像ファイルをひとつドロップした場合も、そのファイルを転送してリモート側のパスを貼り付けます。ローカルのネイティブ Windows ペインへのクリップボード画像貼り付けは、引き続き未検証です。
Kitty graphics は実験的なままで、まだ Windows でのサポートを謳っていません。Windows Terminal での画像レンダリングを特にテストしているのでない限り、`experimental.kitty_graphics = false` のままにしてください。
@@ -92,22 +94,20 @@ Herdr のペインテキストコピーは Windows ベータで動作しますã€
| 機能 | 状況 |
| --- | --- |
| ダイレクトターミナルアタッチ | 未サポート |
-| Windows バイナリからの `herdr --remote` | 未サポート |
+| `herdr --remote` の接続先としての Windows | 未サポート |
| ライブサーバーハンドオフ | 未サポート |
| Unix ファイルディスクリプタのハンドオフ | 未サポート |
| Unix フォアグラウンドプロセスグループ | 未サポート |
-| リモートクリップボード画像ブリッジ | 未サポート |
| プレフィックスによる入力ソース切り替え | 未サポート |
| 署名済みバイナリ / SmartScreen 回避 | 未サポート |
-Windows からのリモート作業は、サーバーに SSH してそこで `herdr` を実行してください:
+Windows Terminal からは、Linux や macOS と同じリモートコマンドを使います:
```powershell
-ssh you@server
-herdr
+herdr --remote workbox
```
-このモードでは Herdr はリモートホスト上で動きます。ネイティブ Windows の `herdr --remote` はベータの範囲外です。
+接続先ホストは Linux または macOS である必要があります。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。
Windows のアップデートは Windows インストーラー経由で行われ、バージョン付きインストールジャンクションを更新します。アップデート後は実行中の Herdr セッションを再起動してください。ライブハンドオフは Unix 専用です。
diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx
index a365ac88..befbfb25 100644
--- a/docs/next/website/src/content/docs/persistence-remote.mdx
+++ b/docs/next/website/src/content/docs/persistence-remote.mdx
@@ -63,11 +63,9 @@ Then attach with:
herdr --remote workbox
```
-Remote attach supports Linux and macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no matching 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.
+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 matching `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no matching 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.
-Native Windows `herdr --remote` is not part of the Windows beta. From Windows, SSH into the server and run `herdr` there.
-
-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 and a private per-attach control socket for connection reuse. Existing user keepalive settings win. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket.
+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.
Remote attach uses your normal OpenSSH authentication. If the target uses a passphrase-protected key in a non-interactive shell, script, CI job, or mobile terminal that cannot show the passphrase prompt, load the key into ssh-agent first:
diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx
index 6533ca6f..b21d1efb 100644
--- a/docs/next/website/src/content/docs/windows-beta.mdx
+++ b/docs/next/website/src/content/docs/windows-beta.mdx
@@ -26,6 +26,8 @@ For internal beta testing, `HERDR_MANIFEST_URL` can point the installer at a cus
| Local persistent sessions | beta |
| Native panes through ConPTY | beta |
| Windows Terminal / PowerShell app attach | beta |
+| `herdr --remote` to Linux/macOS hosts | beta |
+| Remote clipboard images and image-file drops | beta |
| `cmd.exe` panes | beta |
| Startup cwd and workspace labels | beta |
| Pane launch cwd | beta |
@@ -47,14 +49,14 @@ Plugins support `windows` as a manifest platform in preview. GitHub install, loc
| --- | --- |
| Live cwd after shell `cd` | partial |
| Live cwd via shell integration/OSC7 | beta |
-| Clipboard image paste to agents | unverified |
+| Clipboard image paste to agents in local panes | unverified |
| CJK IME composition anchoring | partial |
| Kitty graphics rendering | unverified |
| Host cursor rendering | partial |
Herdr can launch panes in the right directory and can create the initial workspace from the directory where you started Herdr. PowerShell directory changes after startup are different: the process field Herdr can inspect does not reliably track later logical `cd` changes. Use Herdr integrations or prompt shell integration for live cwd reporting.
-Windows Terminal may support image paste paths for specific agents, but Herdr's own clipboard-image reader is not wired on Windows yet. Treat `alt+v` image paste as unverified until the Windows clipboard bridge is implemented and tested. Remote clipboard image bridging is separate and remains tied to Unix/macOS `herdr --remote`.
+During `herdr --remote`, the configured remote image paste key reads a Windows clipboard image and transfers it to the remote host. Dropping one local image file into Windows Terminal also transfers that file and pastes its remote path. Clipboard image paste into a local native Windows pane remains unverified.
Kitty graphics remains experimental and is not claimed as Windows-supported yet. Leave `experimental.kitty_graphics = false` unless you are specifically testing image rendering in Windows Terminal.
@@ -92,22 +94,20 @@ For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is
| Capability | Status |
| --- | --- |
| Direct terminal attach | unsupported |
-| `herdr --remote` from the Windows binary | unsupported |
+| Windows as a `herdr --remote` target host | unsupported |
| Live server handoff | unsupported |
| Unix file-descriptor handoff | unsupported |
| Unix foreground process groups | unsupported |
-| Remote clipboard image bridge | unsupported |
| Prefix input-source switching | unsupported |
| Signed binary / SmartScreen avoidance | unsupported |
-For remote work from Windows, SSH into the server and run `herdr` there:
+From Windows Terminal, use the same remote command as Linux and macOS:
```powershell
-ssh you@server
-herdr
+herdr --remote workbox
```
-That mode runs Herdr on the remote host. Native Windows `herdr --remote` is not part of the beta.
+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.
Windows updates run through the Windows installer and update the versioned install junction. Restart running Herdr sessions after updating. Live handoff is Unix-only.
diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
index 2247dff8..0cb9ce42 100644
--- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
@@ -98,14 +98,14 @@ Git 工作区行提供 worktree 操作。`New worktree` 会创建检出:如果
## 远程连接
-远程连接默认使用临时保活设置和连接复用兜底来管理 SSH 连接。
+远程连接默认使用临时保活设置,并在支持时复用连接,以管理 SSH 连接。
```toml
[remote]
manage_ssh_config = true
```
-启用后,`herdr --remote` 会写入一份私有的临时 SSH 配置:先包含你的 `~/.ssh/config` 和 `/etc/ssh/ssh_config`,再添加兜底的 `ServerAliveInterval` 和 `ServerAliveCountMax` 值。你自己的保活设置优先。Herdr 还会为每次远程连接使用私有的 OpenSSH control socket,以复用首次通过身份验证的连接。设置 `manage_ssh_config = false` 可通过普通 `ssh` 进行远程连接,不使用 Herdr 生成的配置或 control socket。
+启用后,`herdr --remote` 会写入一份私有的临时 SSH 配置:先包含用户和系统 SSH 配置,再添加兜底的 `ServerAliveInterval` 和 `ServerAliveCountMax` 值。你自己的保活设置优先。Linux 和 macOS 客户端还会为每次远程连接使用私有的 OpenSSH control socket,以复用首次通过身份验证的连接;Windows OpenSSH 不使用此复用方式。设置 `manage_ssh_config = false` 可通过普通 `ssh` 进行远程连接,不使用 Herdr 生成的配置或 control socket。
## 按键绑定
diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
index a81a8469..f2b9fb70 100644
--- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
@@ -63,11 +63,9 @@ Host workbox
herdr --remote workbox
```
-远程连接支持 x86_64 和 aarch64 的 Linux 与 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上已有的匹配 `herdr`,然后检查 `~/.local/bin/herdr`。如果没有匹配的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。
+远程连接支持 Linux、macOS 和 Windows 本地客户端连接 x86_64 与 aarch64 的 Linux 和 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上已有的匹配 `herdr`,然后检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。如果没有匹配的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。Windows 不能作为远程主机。
-Windows 原生的 `herdr --remote` 不在 Windows 测试版范围内。在 Windows 上请 SSH 到服务器并在那里运行 `herdr`。
-
-默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的桥接配置,而使用普通 `ssh`。
+默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行远程设置和桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。Linux 和 macOS 客户端还会添加一个私有的单次连接 control socket 来复用连接;Windows OpenSSH 不使用此方式。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的配置或 control socket,而使用普通 `ssh`。
默认情况下,如果需要替换或重启运行中的远程服务器,远程连接使用常规的重启/停止流程。要对受支持的运行中远程服务器启用实验性实时交接,加上 `--handoff`:
diff --git a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
index 781a6f96..d5c7b7bc 100644
--- a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
@@ -26,6 +26,8 @@ Windows 测试版构建只通过预览通道发布。在 Windows 上,Herdr 默è®
| 本地持久会话 | 测试版 |
| 通过 ConPTY 的原生窗格 | 测试版 |
| Windows Terminal / PowerShell 应用连接 | 测试版 |
+| 连接 Linux/macOS 主机的 `herdr --remote` | 测试版 |
+| 远程剪贴板图像和图像文件拖放 | 测试版 |
| `cmd.exe` 窗格 | 测试版 |
| 启动 cwd 和工作区标签 | 测试版 |
| 窗格启动 cwd | 测试版 |
@@ -47,14 +49,14 @@ Windows 智能体进程检测会扫描窗格 shell 的后代进程,识别直接è
| --- | --- |
| shell `cd` 之后的实时 cwd | 部分支持 |
| 通过 shell 集成/OSC7 的实时 cwd | 测试版 |
-| 向智能体粘贴剪贴板图像 | 未验证 |
+| 向本地窗格中的智能体粘贴剪贴板图像 | 未验证 |
| CJK 输入法候选窗口定位 | 部分支持 |
| Kitty graphics 渲染 | 未验证 |
| 主机光标渲染 | 部分支持 |
Herdr 可以在正确的目录中启动窗格,并能从你启动 Herdr 的目录创建初始工作区。启动之后 PowerShell 的目录变化则不同: Herdr 能检查的进程字段无法可靠跟踪后续的逻辑 `cd` 变化。实时 cwd 上报请使用 Herdr 集成或提示符 shell 集成。
-Windows Terminal 可能为特定智能体支持图像粘贴路径,但 Herdr 自己的剪贴板图像读取器尚未在 Windows 上接通。在 Windows 剪贴板桥实现并测试之前,请把 `alt+v` 图像粘贴视为未验证。远程剪贴板图像桥是独立功能,仍然绑定于 Unix/macOS 的 `herdr --remote`。
+使用 `herdr --remote` 时,配置的远程图像粘贴键会读取 Windows 剪贴板图像并传输到远程主机。把一个本地图像文件拖入 Windows Terminal 也会传输该文件并粘贴远程路径。向本地原生 Windows 窗格粘贴剪贴板图像仍未验证。
Kitty graphics 仍是实验性功能,尚未宣称支持 Windows。除非你专门在 Windows Terminal 中测试图像渲染,否则保持 `experimental.kitty_graphics = false`。
@@ -92,22 +94,20 @@ Herdr 的窗格文本复制在 Windows 测试版上可用。在窗格内拖选æ–
| 能力 | 状态 |
| --- | --- |
| 直接终端附加 | 不支持 |
-| Windows 二进制的 `herdr --remote` | 不支持 |
+| Windows 作为 `herdr --remote` 的目标主机 | 不支持 |
| 实时服务器交接 | 不支持 |
| Unix 文件描述符交接 | 不支持 |
| Unix 前台进程组 | 不支持 |
-| 远程剪贴板图像桥 | 不支持 |
| 前缀输入法切换 | 不支持 |
| 签名二进制 / 规避 SmartScreen | 不支持 |
-在 Windows 上进行远程工作,请 SSH 到服务器并在那里运行 `herdr`:
+在 Windows Terminal 中,使用与 Linux 和 macOS 相同的远程命令:
```powershell
-ssh you@server
-herdr
+herdr --remote workbox
```
-这种模式下 Herdr 运行在远程主机上。Windows 原生的 `herdr --remote` 不在测试版范围内。
+目标主机必须运行 Linux 或 macOS。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。
Windows 更新通过 Windows 安装器进行,并更新带版本号的安装联接点。更新后请重启运行中的 Herdr 会话。实时交接仅限 Unix。
diff --git a/src/client/mod.rs b/src/client/mod.rs
index c0fe73d3..fd92d188 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -37,7 +37,6 @@ use tracing::{debug, info, warn};
use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
-#[cfg(unix)]
use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
@@ -59,7 +58,6 @@ struct ClientLoopConfig {
host_cursor: crate::config::HostCursorModeConfig,
kitty_graphics_enabled: bool,
mouse_capture_active: bool,
- #[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
}
@@ -83,7 +81,6 @@ struct ClientState {
#[cfg(unix)]
mouse_scroll_lines: usize,
/// Local-client shortcut that sends a clipboard image to a remote Herdr session.
- #[cfg(unix)]
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
/// Whether outer focus gain should force a full host-terminal redraw.
redraw_on_focus_gained: bool,
@@ -667,7 +664,6 @@ fn requested_render_encoding() -> RenderEncoding {
}
}
-#[cfg(unix)]
fn is_remote_client_process() -> bool {
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
}
@@ -680,11 +676,9 @@ fn is_remote_client_process() -> bool {
/// window; on a high-latency link that easily exceeds 5s, so it gets a far
/// larger budget. See issue #753.
const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5);
-#[cfg(unix)]
const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60);
fn handshake_read_timeout() -> Duration {
- #[cfg(unix)]
if is_remote_client_process() {
return REMOTE_HANDSHAKE_READ_TIMEOUT;
}
@@ -1126,7 +1120,6 @@ fn run_client_with_mode(
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let host_cursor = loaded_config.config.ui.host_cursor;
let direct_attach_requested = attach_request.is_some();
- #[cfg(unix)]
let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config);
let kitty_graphics_enabled =
loaded_config.config.experimental.kitty_graphics && !direct_attach_requested;
@@ -1137,7 +1130,6 @@ fn run_client_with_mode(
host_cursor,
kitty_graphics_enabled,
mouse_capture_active: mouse_capture,
- #[cfg(unix)]
remote_image_paste_key,
};
@@ -1295,7 +1287,6 @@ async fn run_client_loop(
#[cfg(windows)]
let _ = config.mouse_scroll_lines;
let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor);
- #[cfg(unix)]
let is_remote_client = is_remote_client_process();
let mut state = ClientState {
@@ -1308,7 +1299,6 @@ async fn run_client_loop(
attach_escape,
#[cfg(unix)]
mouse_scroll_lines: config.mouse_scroll_lines,
- #[cfg(unix)]
remote_image_paste_key: config.remote_image_paste_key,
redraw_on_focus_gained: config.redraw_on_focus_gained,
repaint_pending: false,
@@ -1466,26 +1456,7 @@ async fn run_client_loop(
state.remote_image_paste_key,
) {
if let Some(image) = crate::platform::read_clipboard_image() {
- if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
- warn!(
- bytes = image.bytes.len(),
- max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
- "local clipboard image is too large to bridge"
- );
- continue;
- }
- info!(
- bytes = image.bytes.len(),
- extension = image.extension,
- "bridging local clipboard image paste to remote server"
- );
- let msg = ClientMessage::ClipboardImage {
- extension: image.extension.to_owned(),
- data: image.bytes,
- };
- if let Err(e) = write_to_server(&mut write_stream, &msg) {
- return Err(ClientError::ConnectionLost(e));
- }
+ write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?;
continue;
}
info!(
@@ -1493,18 +1464,7 @@ async fn run_client_loop(
);
}
if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) {
- info!(
- bytes = image.bytes.len(),
- extension = image.extension,
- "bridging local image file drop to remote server"
- );
- let msg = ClientMessage::ClipboardImage {
- extension: image.extension.to_owned(),
- data: image.bytes,
- };
- if let Err(e) = write_to_server(&mut write_stream, &msg) {
- return Err(ClientError::ConnectionLost(e));
- }
+ write_remote_image_to_server(&mut write_stream, image, "file drop")?;
continue;
}
let msg = ClientMessage::Input { data };
@@ -1517,6 +1477,23 @@ async fn run_client_loop(
if state.attach_escape.is_some() {
continue;
}
+ if should_bridge_clipboard_image_events(
+ &events,
+ is_remote_client,
+ state.remote_image_paste_key,
+ ) {
+ if let Some(image) = crate::platform::read_clipboard_image() {
+ write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?;
+ continue;
+ }
+ info!(
+ "clipboard image paste trigger received, but local clipboard has no image"
+ );
+ }
+ if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) {
+ write_remote_image_to_server(&mut write_stream, image, "file drop")?;
+ continue;
+ }
let raw_events = events
.iter()
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
@@ -1614,7 +1591,6 @@ async fn run_client_loop(
&mut state.sound_config,
&mut state.redraw_on_focus_gained,
&mut state.draw_host_cursor,
- #[cfg(unix)]
&mut state.remote_image_paste_key,
);
}
@@ -1733,11 +1709,41 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<
protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
}
+fn write_remote_image_to_server(
+ stream: &mut LocalStream,
+ image: crate::platform::ClipboardImage,
+ source: &'static str,
+) -> Result<(), ClientError> {
+ if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
+ warn!(
+ bytes = image.bytes.len(),
+ max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ source,
+ "local image is too large to bridge"
+ );
+ return Ok(());
+ }
+
+ info!(
+ bytes = image.bytes.len(),
+ extension = image.extension,
+ source,
+ "bridging local image to remote server"
+ );
+ write_to_server(
+ stream,
+ &ClientMessage::ClipboardImage {
+ extension: image.extension.to_owned(),
+ data: image.bytes,
+ },
+ )
+ .map_err(ClientError::ConnectionLost)
+}
+
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
-#[cfg(unix)]
fn client_remote_image_paste_key(
config: &crate::config::Config,
) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
@@ -1758,7 +1764,7 @@ fn reload_local_client_config(
sound_config: &mut crate::config::SoundConfig,
redraw_on_focus_gained: &mut bool,
draw_host_cursor: &mut bool,
- #[cfg(unix)] remote_image_paste_key: &mut Option<(
+ remote_image_paste_key: &mut Option<(
crossterm::event::KeyCode,
crossterm::event::KeyModifiers,
)>,
@@ -1768,15 +1774,11 @@ fn reload_local_client_config(
for diagnostic in loaded.config.ui.sound.diagnostics() {
warn!(diagnostic = %diagnostic, "local sound config diagnostic");
}
- #[cfg(unix)]
let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config);
*sound_config = loaded.config.ui.sound;
*redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
*draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor);
- #[cfg(unix)]
- {
- *remote_image_paste_key = loaded_remote_image_paste_key;
- }
+ *remote_image_paste_key = loaded_remote_image_paste_key;
debug!("reloaded local client config");
}
Err(diagnostics) => {
@@ -1874,12 +1876,68 @@ fn should_bridge_clipboard_image_paste(
)
}
+#[cfg(any(windows, test))]
+fn should_bridge_clipboard_image_events(
+ events: &[crate::protocol::ClientInputEvent],
+ is_remote_client: bool,
+ remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+) -> bool {
+ if !is_remote_client {
+ return false;
+ }
+ if matches!(
+ events,
+ [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty()
+ ) {
+ return true;
+ }
+
+ let Some(remote_image_paste_key) = remote_image_paste_key else {
+ return false;
+ };
+ matches!(
+ events,
+ [event]
+ if matches!(
+ event.to_raw_input_event(),
+ crate::raw_input::RawInputEvent::Key(key)
+ if key.kind == crossterm::event::KeyEventKind::Press
+ && crate::config::terminal_key_matches_combo(
+ &key,
+ remote_image_paste_key,
+ )
+ )
+ )
+}
+
#[cfg(unix)]
fn read_image_file_from_terminal_drop(
data: &[u8],
is_remote_client: bool,
) -> Option<crate::platform::ClipboardImage> {
let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?;
+ read_image_file(path, extension)
+}
+
+#[cfg(any(windows, test))]
+fn read_image_file_from_client_events(
+ events: &[crate::protocol::ClientInputEvent],
+ is_remote_client: bool,
+) -> Option<crate::platform::ClipboardImage> {
+ let [crate::protocol::ClientInputEvent::Paste { text }] = events else {
+ return None;
+ };
+ let text = normalized_terminal_drop_text(text)?;
+ // Windows events already carry native paths; Unix backslash unescaping would corrupt them.
+ let (path, extension) =
+ image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?;
+ read_image_file(path, extension)
+}
+
+fn read_image_file(
+ path: std::path::PathBuf,
+ extension: &'static str,
+) -> Option<crate::platform::ClipboardImage> {
let metadata = std::fs::metadata(&path).ok()?;
if !metadata.is_file() {
return None;
@@ -1907,23 +1965,29 @@ fn image_path_from_terminal_drop(
data: &[u8],
is_remote_client: bool,
) -> Option<(std::path::PathBuf, &'static str)> {
- if !is_remote_client {
- return None;
- }
-
let bytes = bracketed_paste_payload(data).unwrap_or(data);
let text = std::str::from_utf8(bytes).ok()?;
+ let text = normalized_terminal_drop_text(text)?;
+ let text = unescape_terminal_drop_path(strip_matching_path_quotes(text));
+ image_path_from_drop_text(&text, is_remote_client)
+}
+
+fn normalized_terminal_drop_text(text: &str) -> Option<&str> {
let text = text.trim_end_matches(['\r', '\n']);
- if text.is_empty() || text.contains(['\r', '\n']) {
+ (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text)
+}
+
+fn image_path_from_drop_text(
+ text: &str,
+ is_remote_client: bool,
+) -> Option<(std::path::PathBuf, &'static str)> {
+ if !is_remote_client {
return None;
}
-
- let text = unescape_terminal_drop_path(strip_matching_path_quotes(text));
let path = std::path::PathBuf::from(text);
if !path.is_absolute() {
return None;
}
-
let extension = recognized_image_extension(path.extension()?.to_str()?)?;
Some((path, extension))
}
@@ -1935,7 +1999,6 @@ fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> {
data.strip_prefix(START)?.strip_suffix(END)
}
-#[cfg(unix)]
fn strip_matching_path_quotes(text: &str) -> &str {
if text.len() < 2 {
return text;
@@ -1966,7 +2029,6 @@ fn unescape_terminal_drop_path(text: &str) -> String {
unescaped
}
-#[cfg(unix)]
fn recognized_image_extension(extension: &str) -> Option<&'static str> {
if extension.eq_ignore_ascii_case("png") {
Some("png")
@@ -2364,6 +2426,14 @@ mod tests {
}
#[test]
+ fn remote_client_uses_extended_handshake_timeout() {
+ let _guard = env_lock().lock().unwrap();
+ let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");
+
+ assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
+ }
+
+ #[test]
fn host_cursor_policy_auto_uses_platform_default() {
assert_eq!(
should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
@@ -2421,12 +2491,10 @@ mod tests {
));
}
- #[cfg(unix)]
struct TempImageFile {
path: std::path::PathBuf,
}
- #[cfg(unix)]
impl TempImageFile {
fn new(extension: &str, bytes: &[u8]) -> Self {
Self::with_name_fragment("test", extension, bytes)
@@ -2446,13 +2514,64 @@ mod tests {
}
}
- #[cfg(unix)]
impl Drop for TempImageFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
+ #[test]
+ fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() {
+ let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap();
+ let key = crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('v'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ };
+ let empty_paste = crate::protocol::ClientInputEvent::Paste {
+ text: String::new(),
+ };
+
+ assert!(should_bridge_clipboard_image_events(
+ std::slice::from_ref(&key),
+ true,
+ Some(ctrl_v),
+ ));
+ assert!(should_bridge_clipboard_image_events(
+ std::slice::from_ref(&empty_paste),
+ true,
+ None,
+ ));
+ assert!(!should_bridge_clipboard_image_events(
+ &[key],
+ false,
+ Some(ctrl_v),
+ ));
+ assert!(!should_bridge_clipboard_image_events(
+ &[crate::protocol::ClientInputEvent::Paste {
+ text: "text".to_string(),
+ }],
+ true,
+ Some(ctrl_v),
+ ));
+ }
+
+ #[test]
+ fn remote_image_file_drop_bridge_reads_semantic_paste_path() {
+ let file = TempImageFile::new("PNG", b"image-bytes");
+ let events = [crate::protocol::ClientInputEvent::Paste {
+ text: format!("\"{}\"", file.path.display()),
+ }];
+
+ let image = read_image_file_from_client_events(&events, true).unwrap();
+
+ assert_eq!(image.extension, "png");
+ assert_eq!(image.bytes, b"image-bytes");
+ }
+
#[cfg(unix)]
#[test]
fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() {
@@ -2963,14 +3082,12 @@ mod tests {
let mut sound_config = crate::config::SoundConfig::default();
let mut redraw_on_focus_gained = true;
let mut draw_host_cursor = false;
- #[cfg(unix)]
let mut remote_image_paste_key = None;
reload_local_client_config(
&mut sound_config,
&mut redraw_on_focus_gained,
&mut draw_host_cursor,
- #[cfg(unix)]
&mut remote_image_paste_key,
);
diff --git a/src/ipc.rs b/src/ipc.rs
index c293a278..36e69ea2 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -16,6 +16,12 @@ pub(crate) enum LocalStreamRead {
Closed,
}
+pub(crate) enum LocalStreamReadCount {
+ Data(usize),
+ Pending,
+ Closed,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SocketFileIdentity {
#[cfg(unix)]
@@ -125,16 +131,69 @@ pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool)
}
}
+#[cfg(unix)]
+pub(crate) fn shutdown_local_stream_write(stream: &LocalStream) -> io::Result<()> {
+ match stream {
+ LocalStream::UdSocket(stream) => stream.inner().shutdown(std::net::Shutdown::Write),
+ }
+}
+
+/// Binds a listener for private terminal traffic. Unix callers restrict the
+/// socket file after binding; Windows must set the named-pipe DACL at creation.
+pub(crate) fn bind_private_local_listener(path: &Path) -> io::Result<LocalListener> {
+ #[cfg(unix)]
+ {
+ bind_local_listener(path)
+ }
+
+ #[cfg(windows)]
+ {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
+ use interprocess::os::windows::local_socket::ListenerOptionsExt as _;
+ use interprocess::os::windows::security_descriptor::SecurityDescriptor;
+ use widestring::U16CString;
+
+ let sddl = U16CString::from_str("D:P(A;;GA;;;SY)(A;;GA;;;OW)")
+ .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
+ let security_descriptor = SecurityDescriptor::deserialize(&sddl)?;
+ let name = path.to_string_lossy().to_string();
+ let name = name.to_ns_name::<GenericNamespaced>()?;
+ let listener = ListenerOptions::new()
+ .name(name)
+ .reclaim_name(false)
+ .security_descriptor(security_descriptor)
+ .create_sync()?;
+ fs::write(path, windows_socket_marker())?;
+ Ok(listener)
+ }
+}
+
pub(crate) fn poll_local_stream_read(
stream: &mut LocalStream,
buf: &mut [u8],
) -> io::Result<LocalStreamRead> {
+ match poll_local_stream_read_count(stream, buf)? {
+ LocalStreamReadCount::Data(read) => {
+ let _ = read;
+ Ok(LocalStreamRead::Data)
+ }
+ LocalStreamReadCount::Pending => Ok(LocalStreamRead::Pending),
+ LocalStreamReadCount::Closed => Ok(LocalStreamRead::Closed),
+ }
+}
+
+pub(crate) fn poll_local_stream_read_count(
+ stream: &mut LocalStream,
+ buf: &mut [u8],
+) -> io::Result<LocalStreamReadCount> {
#[cfg(unix)]
{
match stream.read(buf) {
- Ok(0) => Ok(LocalStreamRead::Closed),
- Ok(_) => Ok(LocalStreamRead::Data),
- Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(LocalStreamRead::Pending),
+ Ok(0) => Ok(LocalStreamReadCount::Closed),
+ Ok(read) => Ok(LocalStreamReadCount::Data(read)),
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ Ok(LocalStreamReadCount::Pending)
+ }
Err(err) => Err(err),
}
}
@@ -142,12 +201,12 @@ pub(crate) fn poll_local_stream_read(
#[cfg(windows)]
{
match windows_named_pipe_available(stream)? {
- None => Ok(LocalStreamRead::Closed),
- Some(0) => Ok(LocalStreamRead::Pending),
+ None => Ok(LocalStreamReadCount::Closed),
+ Some(0) => Ok(LocalStreamReadCount::Pending),
Some(_) => match stream.read(buf) {
- Ok(0) => Ok(LocalStreamRead::Closed),
- Ok(_) => Ok(LocalStreamRead::Data),
- Err(err) if is_connection_closed_error(&err) => Ok(LocalStreamRead::Closed),
+ Ok(0) => Ok(LocalStreamReadCount::Closed),
+ Ok(read) => Ok(LocalStreamReadCount::Data(read)),
+ Err(err) if is_connection_closed_error(&err) => Ok(LocalStreamReadCount::Closed),
Err(err) => Err(err),
},
}
@@ -306,6 +365,31 @@ mod tests {
#[cfg(windows)]
#[test]
+ fn private_named_pipe_accepts_same_user() {
+ use std::io::Write as _;
+
+ let path = temp_socket_marker_path("private-pipe");
+ let _ = fs::remove_file(&path);
+ let listener = bind_private_local_listener(&path).unwrap();
+ let mut client = connect_local_stream(&path).unwrap();
+ let mut server = listener.accept().unwrap();
+ client.write_all(b"remote").unwrap();
+
+ let mut buffer = [0_u8; 16];
+ assert!(matches!(
+ poll_local_stream_read_count(&mut server, &mut buffer).unwrap(),
+ LocalStreamReadCount::Data(6)
+ ));
+ assert_eq!(&buffer[..6], b"remote");
+
+ drop(client);
+ drop(server);
+ drop(listener);
+ let _ = fs::remove_file(path);
+ }
+
+ #[cfg(windows)]
+ #[test]
fn remove_socket_file_if_owned_compares_windows_marker_contents() {
let path = temp_socket_marker_path("same-len-marker");
let _ = fs::remove_file(&path);
diff --git a/src/main.rs b/src/main.rs
index b6cc62b8..85ff264b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,7 +58,6 @@ mod agent_resume;
mod api;
mod app;
mod build_info;
-#[cfg(not(windows))]
mod checksum;
mod cli;
mod client;
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index 30bea5eb..f6380df8 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -3,6 +3,85 @@ use std::process::Command;
use super::{ClipboardImage, ForegroundJob, Signal};
+pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths {
+ super::RemoteSshConfigPaths {
+ user_config: std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .map(|home| home.join(".ssh").join("config")),
+ system_config: None,
+ multiplexing: false,
+ }
+}
+
+pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result<PathBuf> {
+ for attempt in 0..100 {
+ let dir = std::env::temp_dir().join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
+ match create_remote_private_dir(&dir) {
+ Ok(()) => return Ok(dir),
+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ }
+ }
+ Err(std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ "failed to create private herdr ssh config directory",
+ ))
+}
+
+pub(crate) fn create_remote_ssh_config_file(
+ path: &std::path::Path,
+) -> std::io::Result<std::fs::File> {
+ let mut options = std::fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ options.open(path)
+}
+
+pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> {
+ let mut builder = std::fs::DirBuilder::new();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::DirBuilderExt;
+ builder.mode(0o700);
+ }
+ builder.create(path)
+}
+
+pub(crate) fn remote_private_temp_base() -> PathBuf {
+ std::env::temp_dir()
+}
+
+pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, _short_name: &str) -> PathBuf {
+ std::env::temp_dir().join(readable_name)
+}
+
+pub(crate) fn remote_reattach_program(program: &str) -> String {
+ shell_quote(program)
+}
+
+pub(crate) fn remote_reattach_argument(value: &str) -> String {
+ shell_quote(value)
+}
+
+fn shell_quote(value: &str) -> String {
+ if !value.is_empty()
+ && value.chars().all(|ch| {
+ ch.is_ascii_alphanumeric()
+ || matches!(
+ ch,
+ '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
+ )
+ })
+ {
+ return value.to_string();
+ }
+ format!("'{}'", value.replace('\'', "'\\''"))
+}
+
/// Unsupported platform stub.
pub fn raise_server_nofile_limit() {}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 76568ad0..7932ae09 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -12,6 +12,12 @@ use super::{
LimitedRead, Signal,
};
+pub(crate) use super::unix_common::{
+ create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
+ remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
+ remote_reattach_program, remote_ssh_config_paths,
+};
+
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION";
const CHILD_GROUPS_SCAN_LIMIT: usize = 64;
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index e9c89ede..fdb1e2eb 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -12,6 +12,12 @@ use super::{
LimitedRead, Signal,
};
+pub(crate) use super::unix_common::{
+ create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
+ remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
+ remote_reattach_program, remote_ssh_config_paths,
+};
+
const PROC_PGRP_ONLY: u32 = 2;
const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192;
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 38d797d1..d8613be4 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -52,7 +52,6 @@ fn configure_background_command_platform(_command: &mut std::process::Command) {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PlatformCapabilities {
pub(crate) live_handoff: bool,
- pub(crate) remote_attach: bool,
pub(crate) direct_terminal_attach: bool,
pub(crate) preserve_legacy_doubled_escape_input: bool,
}
@@ -60,7 +59,6 @@ pub(crate) struct PlatformCapabilities {
pub(crate) const fn capabilities() -> PlatformCapabilities {
PlatformCapabilities {
live_handoff: cfg!(unix),
- remote_attach: cfg!(unix),
direct_terminal_attach: cfg!(unix),
preserve_legacy_doubled_escape_input: cfg!(target_os = "macos"),
}
@@ -137,14 +135,11 @@ pub struct ClipboardCommand {
}
#[derive(Debug, Clone, PartialEq, Eq)]
-// Windows does not wire clipboard-image bridging into semantic input yet.
-#[cfg_attr(windows, allow(dead_code))]
pub struct ClipboardImage {
pub bytes: Vec<u8>,
pub extension: &'static str,
}
-#[cfg(unix)]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LimitedRead {
Empty,
@@ -152,7 +147,6 @@ pub(crate) enum LimitedRead {
Oversized,
}
-#[cfg(unix)]
pub(crate) fn read_limited_reader(
mut reader: impl std::io::Read,
max_bytes: usize,
@@ -190,6 +184,16 @@ pub(crate) fn read_limited_reader(
}
}
+#[derive(Debug, Clone)]
+pub(crate) struct RemoteSshConfigPaths {
+ pub(crate) user_config: Option<std::path::PathBuf>,
+ pub(crate) system_config: Option<std::path::PathBuf>,
+ pub(crate) multiplexing: bool,
+}
+
+#[cfg(unix)]
+mod unix_common;
+
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs
new file mode 100644
index 00000000..754e2fc2
--- /dev/null
+++ b/src/platform/unix_common.rs
@@ -0,0 +1,131 @@
+use std::path::{Path, PathBuf};
+
+pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths {
+ super::RemoteSshConfigPaths {
+ user_config: std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .map(|home| home.join(".ssh").join("config")),
+ system_config: Some(PathBuf::from("/etc/ssh/ssh_config")),
+ multiplexing: true,
+ }
+}
+
+pub(crate) fn create_remote_ssh_config_dir(control_socket_name: &str) -> std::io::Result<PathBuf> {
+ use std::os::unix::fs::DirBuilderExt;
+
+ let mut bases = vec![std::env::temp_dir()];
+ let short_tmp = PathBuf::from("/tmp");
+ if bases.first() != Some(&short_tmp) {
+ bases.push(short_tmp);
+ }
+
+ let mut last_error = None;
+ let mut path_fits = false;
+ for base in bases {
+ for attempt in 0..100 {
+ let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
+ if !fits_unix_socket_path(&dir.join(control_socket_name)) {
+ continue;
+ }
+ path_fits = true;
+ match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
+ Ok(()) => return Ok(dir),
+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(err) => {
+ last_error = Some(err);
+ break;
+ }
+ }
+ }
+ }
+
+ if let Some(err) = last_error {
+ return Err(err);
+ }
+ let message = if path_fits {
+ "failed to create private herdr ssh config directory"
+ } else {
+ "SSH control socket path exceeds the Unix socket length limit"
+ };
+ Err(std::io::Error::new(
+ if path_fits {
+ std::io::ErrorKind::AlreadyExists
+ } else {
+ std::io::ErrorKind::InvalidInput
+ },
+ message,
+ ))
+}
+
+pub(crate) fn create_remote_ssh_config_file(path: &Path) -> std::io::Result<std::fs::File> {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(path)
+}
+
+pub(crate) fn create_remote_private_dir(path: &Path) -> std::io::Result<()> {
+ use std::os::unix::fs::DirBuilderExt;
+
+ std::fs::DirBuilder::new().mode(0o700).create(path)
+}
+
+pub(crate) fn remote_private_temp_base() -> PathBuf {
+ std::env::temp_dir()
+}
+
+pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, short_name: &str) -> PathBuf {
+ let tmp = std::env::temp_dir();
+ let readable = tmp.join(readable_name);
+ if fits_unix_socket_path(&readable) {
+ return readable;
+ }
+ let short = tmp.join(short_name);
+ if fits_unix_socket_path(&short) {
+ return short;
+ }
+ PathBuf::from("/tmp").join(short_name)
+}
+
+pub(crate) fn remote_reattach_program(program: &str) -> String {
+ shell_quote(if program.is_empty() { "herdr" } else { program })
+}
+
+pub(crate) fn remote_reattach_argument(value: &str) -> String {
+ shell_quote(value)
+}
+
+fn shell_quote(value: &str) -> String {
+ if !value.is_empty()
+ && value.chars().all(|ch| {
+ ch.is_ascii_alphanumeric()
+ || matches!(
+ ch,
+ '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
+ )
+ })
+ {
+ return value.to_string();
+ }
+ format!("'{}'", value.replace('\'', "'\\''"))
+}
+
+fn fits_unix_socket_path(path: &Path) -> bool {
+ use std::os::unix::ffi::OsStrExt;
+
+ path.as_os_str().as_bytes().len() <= 103
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn remote_ssh_config_dir_rejects_overlong_control_socket_name() {
+ let err = create_remote_ssh_config_dir(&"x".repeat(200)).unwrap_err();
+ assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
+ }
+}
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 55e0fd0b..0c9971fb 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -12,6 +12,8 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
+mod clipboard_image;
+
use windows_sys::{
Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
Win32::{
@@ -20,9 +22,14 @@ use windows_sys::{
NTSTATUS, STATUS_SUCCESS, UNICODE_STRING,
},
Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN},
+ Security::SECURITY_ATTRIBUTES,
+ Storage::FileSystem::CreateDirectoryW,
System::{
Console::GetConsoleWindow,
- DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData},
+ DataExchange::{
+ CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard,
+ RegisterClipboardFormatW, SetClipboardData,
+ },
Diagnostics::{
Debug::ReadProcessMemory,
ToolHelp::{
@@ -35,10 +42,10 @@ use windows_sys::{
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
Memory::{
- GlobalAlloc, GlobalLock, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
+ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
MEMORY_BASIC_INFORMATION,
},
- Ole::CF_UNICODETEXT,
+ Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT},
Threading::{
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess,
QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS,
@@ -83,6 +90,111 @@ static PROCESS_RUNTIME_MARKER_CACHE: LazyLock<Mutex<HashMap<u32, CachedProcessRu
static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
+pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths {
+ super::RemoteSshConfigPaths {
+ user_config: std::env::var_os("USERPROFILE")
+ .map(PathBuf::from)
+ .map(|home| home.join(".ssh").join("config")),
+ system_config: std::env::var_os("PROGRAMDATA")
+ .map(PathBuf::from)
+ .map(|dir| dir.join("ssh").join("ssh_config")),
+ multiplexing: false,
+ }
+}
+
+pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result<PathBuf> {
+ let base = remote_private_temp_base();
+ std::fs::create_dir_all(&base)?;
+ for attempt in 0..100 {
+ let dir = base.join(format!("ssh-{}-{attempt}", std::process::id()));
+ match create_remote_private_dir(&dir) {
+ Ok(()) => return Ok(dir),
+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ }
+ }
+ Err(std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ "failed to create private herdr ssh config directory",
+ ))
+}
+
+pub(crate) fn create_remote_ssh_config_file(
+ path: &std::path::Path,
+) -> std::io::Result<std::fs::File> {
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+}
+
+pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> {
+ use interprocess::os::windows::security_descriptor::{
+ AsSecurityDescriptorExt as _, SecurityDescriptor,
+ };
+ use widestring::U16CString;
+
+ let sddl = U16CString::from_str("D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;OW)")
+ .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
+ let security_descriptor = SecurityDescriptor::deserialize(&sddl)?;
+ let mut security_attributes = SECURITY_ATTRIBUTES {
+ nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
+ lpSecurityDescriptor: null_mut(),
+ bInheritHandle: 0,
+ };
+ security_descriptor.write_to_security_attributes(&mut security_attributes);
+ let path = extended_length_path(path)?;
+ if unsafe { CreateDirectoryW(path.as_ptr(), &security_attributes) } != 0 {
+ Ok(())
+ } else {
+ Err(std::io::Error::last_os_error())
+ }
+}
+
+fn extended_length_path(path: &std::path::Path) -> std::io::Result<Vec<u16>> {
+ use std::os::windows::ffi::OsStrExt as _;
+
+ let path = std::path::absolute(path)?;
+ let wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
+ let mut extended = if wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16])
+ || wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'.' as u16, b'\\' as u16])
+ {
+ wide
+ } else if wide.starts_with(&[b'\\' as u16, b'\\' as u16]) {
+ "\\\\?\\UNC\\"
+ .encode_utf16()
+ .chain(wide.into_iter().skip(2))
+ .collect()
+ } else {
+ "\\\\?\\".encode_utf16().chain(wide).collect()
+ };
+ extended.push(0);
+ Ok(extended)
+}
+
+pub(crate) fn remote_private_temp_base() -> PathBuf {
+ crate::config::state_dir().join("remote")
+}
+
+pub(crate) fn remote_bridge_endpoint_path(_readable_name: &str, short_name: &str) -> PathBuf {
+ remote_private_temp_base().join(short_name)
+}
+
+pub(crate) fn remote_reattach_program(program: &str) -> String {
+ let path = std::env::current_exe()
+ .ok()
+ .filter(|path| path.is_absolute())
+ .unwrap_or_else(|| PathBuf::from(program));
+ format!(
+ "& {}",
+ remote_reattach_argument(&path.display().to_string())
+ )
+}
+
+pub(crate) fn remote_reattach_argument(value: &str) -> String {
+ format!("'{}'", value.replace('\'', "''"))
+}
+
/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination.
pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
@@ -1242,12 +1354,76 @@ pub fn open_url(url: &str) -> std::io::Result<()> {
}
}
-// Windows does not wire clipboard-image bridging into semantic input yet.
-#[cfg_attr(windows, allow(dead_code))]
pub fn read_clipboard_image() -> Option<ClipboardImage> {
+ for attempt in 0..10 {
+ if unsafe { OpenClipboard(null_mut()) } != 0 {
+ let _clipboard = ClipboardGuard;
+ if let Some(bytes) = read_registered_png_clipboard() {
+ return Some(ClipboardImage {
+ bytes,
+ extension: "png",
+ });
+ }
+ for format in [CF_DIBV5 as u32, CF_DIB as u32] {
+ if let Some(bytes) =
+ clipboard_global_bytes(format, clipboard_image::MAX_CLIPBOARD_ALLOCATION)
+ {
+ if let Some(bytes) = clipboard_image::dib_to_png(&bytes) {
+ return Some(ClipboardImage {
+ bytes,
+ extension: "png",
+ });
+ }
+ }
+ }
+ return None;
+ }
+ if attempt < 9 {
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ }
None
}
+fn read_registered_png_clipboard() -> Option<Vec<u8>> {
+ static PNG_FORMAT: LazyLock<u32> = LazyLock::new(|| {
+ let name = wide_null("PNG");
+ unsafe { RegisterClipboardFormatW(name.as_ptr()) }
+ });
+ if *PNG_FORMAT == 0 {
+ return None;
+ }
+ let bytes = clipboard_global_bytes(
+ *PNG_FORMAT,
+ crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD + 64 * 1024,
+ )?;
+ clipboard_image::validated_png(&bytes)
+}
+
+fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option<Vec<u8>> {
+ let handle = unsafe { GetClipboardData(format) };
+ if handle.is_null() {
+ return None;
+ }
+ let data = unsafe { GlobalLock(handle) };
+ if data.is_null() {
+ return None;
+ }
+ let size = unsafe { GlobalSize(handle) };
+ if size == 0 || size > max_bytes {
+ unsafe {
+ GlobalUnlock(handle);
+ }
+ return None;
+ }
+ let mut bytes = vec![0_u8; size];
+ unsafe {
+ copy_nonoverlapping(data.cast::<u8>(), bytes.as_mut_ptr(), size);
+ GlobalUnlock(handle);
+ }
+ Some(bytes)
+}
+
pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
let title = title.to_owned();
let body = body.unwrap_or(&title).to_owned();
@@ -1788,6 +1964,21 @@ mod tests {
};
#[test]
+ fn private_remote_directory_supports_long_paths() {
+ let base = std::env::temp_dir().join(format!(
+ "herdr-private-remote-dir-test-{}",
+ std::process::id()
+ ));
+ fs::create_dir_all(&base).expect("create test base");
+ let private = base.join("x".repeat(240));
+
+ super::create_remote_private_dir(&private).expect("create private long-path directory");
+ fs::write(private.join("probe"), b"ok").expect("write inherited private file");
+
+ fs::remove_dir_all(base).expect("remove test directory");
+ }
+
+ #[test]
fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() {
let key = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Esc,
diff --git a/src/platform/windows/clipboard_image.rs b/src/platform/windows/clipboard_image.rs
new file mode 100644
index 00000000..f8e17aed
--- /dev/null
+++ b/src/platform/windows/clipboard_image.rs
@@ -0,0 +1,409 @@
+use std::io::{self, Cursor, Write as _};
+
+use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
+
+pub(super) const MAX_CLIPBOARD_ALLOCATION: usize = 64 * 1024 * 1024;
+
+const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
+const MAX_IMAGE_DIMENSION: u32 = 16_384;
+const MAX_IMAGE_PIXELS: usize = 16 * 1024 * 1024;
+const BI_RGB: u32 = 0;
+const BI_BITFIELDS: u32 = 3;
+const BI_ALPHABITFIELDS: u32 = 6;
+
+pub(super) fn validated_png(bytes: &[u8]) -> Option<Vec<u8>> {
+ let logical_len = png_logical_len(bytes)?;
+ if logical_len > MAX_CLIPBOARD_IMAGE_PAYLOAD {
+ return None;
+ }
+ let bytes = &bytes[..logical_len];
+ let mut decoder = png::Decoder::new_with_limits(
+ Cursor::new(bytes),
+ png::Limits {
+ bytes: MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ },
+ );
+ decoder.set_ignore_text_chunk(true);
+ decoder.set_ignore_iccp_chunk(true);
+ let mut reader = decoder.read_info().ok()?;
+ validate_dimensions(reader.info().width, reader.info().height)?;
+ if reader.info().animation_control.is_some() {
+ return None;
+ }
+ while reader.next_interlaced_row().ok()?.is_some() {}
+ reader.finish().ok()?;
+ Some(bytes.to_vec())
+}
+
+fn png_logical_len(bytes: &[u8]) -> Option<usize> {
+ if !bytes.starts_with(PNG_SIGNATURE) {
+ return None;
+ }
+ let mut offset = PNG_SIGNATURE.len();
+ while offset < bytes.len() {
+ let length = usize::try_from(read_u32_be(bytes, offset)?).ok()?;
+ let kind_start = offset.checked_add(4)?;
+ let data_start = kind_start.checked_add(4)?;
+ let data_end = data_start.checked_add(length)?;
+ let chunk_end = data_end.checked_add(4)?;
+ if chunk_end > bytes.len() {
+ return None;
+ }
+ if bytes.get(kind_start..data_start)? == b"IEND" {
+ return (length == 0).then_some(chunk_end);
+ }
+ offset = chunk_end;
+ }
+ None
+}
+
+pub(super) fn dib_to_png(bytes: &[u8]) -> Option<Vec<u8>> {
+ Dib::parse(bytes)?.encode_png()
+}
+
+struct Dib<'a> {
+ width: u32,
+ height: u32,
+ top_down: bool,
+ stride: usize,
+ pixels: &'a [u8],
+ format: PixelFormat,
+}
+
+#[derive(Clone, Copy)]
+enum PixelFormat {
+ Bgr24,
+ Bgrx32,
+ Masked32 {
+ red: ChannelMask,
+ green: ChannelMask,
+ blue: ChannelMask,
+ alpha: Option<ChannelMask>,
+ },
+}
+
+#[derive(Clone, Copy)]
+struct ChannelMask {
+ mask: u32,
+ shift: u32,
+ maximum: u32,
+}
+
+impl ChannelMask {
+ fn parse(mask: u32) -> Option<Self> {
+ if mask == 0 {
+ return None;
+ }
+ let shift = mask.trailing_zeros();
+ let maximum = mask >> shift;
+ if maximum & maximum.wrapping_add(1) != 0 {
+ return None;
+ }
+ Some(Self {
+ mask,
+ shift,
+ maximum,
+ })
+ }
+
+ fn extract(self, pixel: u32) -> u8 {
+ let value = (pixel & self.mask) >> self.shift;
+ ((u64::from(value) * 255 + u64::from(self.maximum) / 2) / u64::from(self.maximum)) as u8
+ }
+}
+
+impl<'a> Dib<'a> {
+ fn parse(bytes: &'a [u8]) -> Option<Self> {
+ let header_size = usize::try_from(read_u32_le(bytes, 0)?).ok()?;
+ if !matches!(header_size, 40 | 52 | 56 | 108 | 124) || bytes.len() < header_size {
+ return None;
+ }
+ let signed_width = read_i32_le(bytes, 4)?;
+ let signed_height = read_i32_le(bytes, 8)?;
+ if signed_width <= 0 || signed_height == 0 || signed_height == i32::MIN {
+ return None;
+ }
+ let width = u32::try_from(signed_width).ok()?;
+ let height = signed_height.unsigned_abs();
+ validate_dimensions(width, height)?;
+ if read_u16_le(bytes, 12)? != 1 {
+ return None;
+ }
+
+ let bit_count = read_u16_le(bytes, 14)?;
+ let compression = read_u32_le(bytes, 16)?;
+ let colors_used = usize::try_from(read_u32_le(bytes, 32)?).ok()?;
+ let (format, external_mask_bytes) =
+ pixel_format(bytes, header_size, bit_count, compression)?;
+ let palette_bytes = colors_used.checked_mul(4)?;
+ let pixel_offset = header_size
+ .checked_add(external_mask_bytes)?
+ .checked_add(palette_bytes)?;
+ let row_bits = usize::try_from(width)
+ .ok()?
+ .checked_mul(usize::from(bit_count))?;
+ let stride = row_bits.checked_add(31)?.checked_div(32)?.checked_mul(4)?;
+ let image_size = stride.checked_mul(usize::try_from(height).ok()?)?;
+ let pixels = bytes.get(pixel_offset..pixel_offset.checked_add(image_size)?)?;
+
+ Some(Self {
+ width,
+ height,
+ top_down: signed_height < 0,
+ stride,
+ pixels,
+ format,
+ })
+ }
+
+ fn encode_png(&self) -> Option<Vec<u8>> {
+ let mut output = LimitedWriter::new(MAX_CLIPBOARD_IMAGE_PAYLOAD);
+ {
+ let mut encoder = png::Encoder::new(&mut output, self.width, self.height);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header().ok()?;
+ let mut stream = writer.stream_writer().ok()?;
+ let mut rgba = vec![0_u8; usize::try_from(self.width).ok()?.checked_mul(4)?];
+ for output_row in 0..usize::try_from(self.height).ok()? {
+ let source_row = if self.top_down {
+ output_row
+ } else {
+ usize::try_from(self.height)
+ .ok()?
+ .checked_sub(output_row + 1)?
+ };
+ let start = source_row.checked_mul(self.stride)?;
+ let source = self.pixels.get(start..start.checked_add(self.stride)?)?;
+ self.decode_row(source, &mut rgba)?;
+ stream.write_all(&rgba).ok()?;
+ }
+ stream.finish().ok()?;
+ writer.finish().ok()?;
+ }
+ Some(output.into_inner())
+ }
+
+ fn decode_row(&self, source: &[u8], output: &mut [u8]) -> Option<()> {
+ match self.format {
+ PixelFormat::Bgr24 => {
+ for (pixel, rgba) in source
+ .chunks_exact(3)
+ .take(usize::try_from(self.width).ok()?)
+ .zip(output.chunks_exact_mut(4))
+ {
+ rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]);
+ }
+ }
+ PixelFormat::Bgrx32 => {
+ for (pixel, rgba) in source
+ .chunks_exact(4)
+ .take(usize::try_from(self.width).ok()?)
+ .zip(output.chunks_exact_mut(4))
+ {
+ rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]);
+ }
+ }
+ PixelFormat::Masked32 {
+ red,
+ green,
+ blue,
+ alpha,
+ } => {
+ for (pixel, rgba) in source
+ .chunks_exact(4)
+ .take(usize::try_from(self.width).ok()?)
+ .zip(output.chunks_exact_mut(4))
+ {
+ let pixel = u32::from_le_bytes(pixel.try_into().ok()?);
+ rgba.copy_from_slice(&[
+ red.extract(pixel),
+ green.extract(pixel),
+ blue.extract(pixel),
+ alpha.map_or(255, |mask| mask.extract(pixel)),
+ ]);
+ }
+ }
+ }
+ Some(())
+ }
+}
+
+fn pixel_format(
+ bytes: &[u8],
+ header_size: usize,
+ bit_count: u16,
+ compression: u32,
+) -> Option<(PixelFormat, usize)> {
+ match (bit_count, compression) {
+ (24, BI_RGB) => return Some((PixelFormat::Bgr24, 0)),
+ (32, BI_RGB) => return Some((PixelFormat::Bgrx32, 0)),
+ (32, BI_BITFIELDS | BI_ALPHABITFIELDS) => {}
+ _ => return None,
+ }
+
+ let (offset, external_mask_bytes) = if header_size == 40 {
+ let count = if compression == BI_ALPHABITFIELDS {
+ 4
+ } else {
+ 3
+ };
+ (40, count * 4)
+ } else {
+ if header_size < 52 || (compression == BI_ALPHABITFIELDS && header_size < 56) {
+ return None;
+ }
+ (40, 0)
+ };
+ let red = ChannelMask::parse(read_u32_le(bytes, offset)?)?;
+ let green = ChannelMask::parse(read_u32_le(bytes, offset + 4)?)?;
+ let blue = ChannelMask::parse(read_u32_le(bytes, offset + 8)?)?;
+ if red.mask & green.mask != 0 || red.mask & blue.mask != 0 || green.mask & blue.mask != 0 {
+ return None;
+ }
+ let alpha = if compression == BI_ALPHABITFIELDS || header_size >= 56 {
+ match read_u32_le(bytes, offset + 12).filter(|mask| *mask != 0) {
+ Some(mask) => Some(ChannelMask::parse(mask)?),
+ None => None,
+ }
+ } else {
+ None
+ };
+ if alpha.is_some_and(|alpha| alpha.mask & (red.mask | green.mask | blue.mask) != 0) {
+ return None;
+ }
+ Some((
+ PixelFormat::Masked32 {
+ red,
+ green,
+ blue,
+ alpha,
+ },
+ external_mask_bytes,
+ ))
+}
+
+fn validate_dimensions(width: u32, height: u32) -> Option<()> {
+ if width == 0 || height == 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
+ return None;
+ }
+ let pixels = usize::try_from(width)
+ .ok()?
+ .checked_mul(usize::try_from(height).ok()?)?;
+ (pixels <= MAX_IMAGE_PIXELS).then_some(())
+}
+
+struct LimitedWriter {
+ bytes: Vec<u8>,
+ limit: usize,
+}
+
+impl LimitedWriter {
+ fn new(limit: usize) -> Self {
+ Self {
+ bytes: Vec::new(),
+ limit,
+ }
+ }
+
+ fn into_inner(self) -> Vec<u8> {
+ self.bytes
+ }
+}
+
+impl io::Write for LimitedWriter {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
+ return Err(io::Error::new(
+ io::ErrorKind::FileTooLarge,
+ "encoded clipboard image exceeds protocol limit",
+ ));
+ }
+ self.bytes.extend_from_slice(bytes);
+ Ok(bytes.len())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+}
+
+fn read_u16_le(bytes: &[u8], offset: usize) -> Option<u16> {
+ Some(u16::from_le_bytes(
+ bytes.get(offset..offset + 2)?.try_into().ok()?,
+ ))
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_i32_le(bytes: &[u8], offset: usize) -> Option<i32> {
+ Some(i32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_u32_be(bytes: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_be_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn info_header(width: i32, height: i32, bit_count: u16, compression: u32) -> Vec<u8> {
+ let mut header = vec![0_u8; 40];
+ header[0..4].copy_from_slice(&40_u32.to_le_bytes());
+ header[4..8].copy_from_slice(&width.to_le_bytes());
+ header[8..12].copy_from_slice(&height.to_le_bytes());
+ header[12..14].copy_from_slice(&1_u16.to_le_bytes());
+ header[14..16].copy_from_slice(&bit_count.to_le_bytes());
+ header[16..20].copy_from_slice(&compression.to_le_bytes());
+ header
+ }
+
+ fn decode_rgba(bytes: &[u8]) -> Vec<u8> {
+ let mut decoder = png::Decoder::new(Cursor::new(bytes));
+ decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
+ let mut reader = decoder.read_info().unwrap();
+ let mut output = vec![0_u8; reader.output_buffer_size()];
+ let info = reader.next_frame(&mut output).unwrap();
+ output.truncate(info.buffer_size());
+ output
+ }
+
+ #[test]
+ fn converts_bottom_up_24_bit_dib_to_png() {
+ let mut dib = info_header(2, 2, 24, BI_RGB);
+ dib.extend_from_slice(&[
+ 255, 0, 0, 255, 255, 255, 0, 0, // bottom: blue, white, padding
+ 0, 0, 255, 0, 255, 0, 0, 0, // top: red, green, padding
+ ]);
+
+ let png = dib_to_png(&dib).unwrap();
+ assert_eq!(
+ decode_rgba(&png),
+ vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,]
+ );
+ }
+
+ #[test]
+ fn strips_registered_png_allocator_tail() {
+ let mut bytes = Vec::new();
+ let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header().unwrap();
+ writer.write_image_data(&[1, 2, 3, 255]).unwrap();
+ writer.finish().unwrap();
+ let expected = bytes.clone();
+ bytes.extend_from_slice(&[0; 32]);
+
+ assert_eq!(validated_png(&bytes), Some(expected));
+ }
+}
diff --git a/src/remote.rs b/src/remote.rs
index ce4ca91b..19904ef2 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -1,149 +1,15 @@
+mod attach;
#[cfg(unix)]
-mod unix;
+mod host_unix;
+pub(crate) use attach::*;
#[cfg(unix)]
-pub(crate) use unix::*;
-
-#[cfg(windows)]
-pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
-#[cfg(windows)]
-pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
-
-#[cfg(windows)]
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum RemoteKeybindings {
- Local,
- Server,
-}
-
-#[cfg(windows)]
-impl RemoteKeybindings {
- fn parse(value: &str) -> Result<Self, String> {
- match value {
- "local" => Ok(Self::Local),
- "server" => Ok(Self::Server),
- _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
- }
- }
-}
-
-#[cfg(windows)]
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct RemoteLaunch {
- pub(crate) target: String,
- pub(crate) keybindings: RemoteKeybindings,
- pub(crate) live_handoff: bool,
-}
-
-#[cfg(windows)]
-pub(crate) fn extract_remote_args(
- args: &[String],
-) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
- let mut cleaned = Vec::with_capacity(args.len());
- if let Some(program) = args.first() {
- cleaned.push(program.clone());
- }
-
- let mut remote_target = None;
- let mut keybindings = RemoteKeybindings::Local;
- let mut keybindings_seen = false;
- let mut live_handoff = false;
- let mut index = 1;
- while index < args.len() {
- let arg = &args[index];
- if arg == "--" {
- cleaned.extend_from_slice(&args[index..]);
- break;
- }
- if arg == "--handoff" {
- live_handoff = true;
- index += 1;
- continue;
- }
- if arg == "--remote" {
- if remote_target.is_some() {
- return Err("--remote can only be specified once".to_string());
- }
- let Some(value) = args.get(index + 1) else {
- return Err("missing value for --remote".to_string());
- };
- remote_target = Some(validate_remote_target(value)?.to_owned());
- index += 2;
- continue;
- }
- if let Some(value) = arg.strip_prefix("--remote=") {
- if remote_target.is_some() {
- return Err("--remote can only be specified once".to_string());
- }
- remote_target = Some(validate_remote_target(value)?.to_owned());
- index += 1;
- continue;
- }
- if arg == "--remote-keybindings" {
- if keybindings_seen {
- return Err("--remote-keybindings can only be specified once".to_string());
- }
- let Some(value) = args.get(index + 1) else {
- return Err("missing value for --remote-keybindings".to_string());
- };
- keybindings = RemoteKeybindings::parse(value)?;
- keybindings_seen = true;
- index += 2;
- continue;
- }
- if let Some(value) = arg.strip_prefix("--remote-keybindings=") {
- if keybindings_seen {
- return Err("--remote-keybindings can only be specified once".to_string());
- }
- keybindings = RemoteKeybindings::parse(value)?;
- keybindings_seen = true;
- index += 1;
- continue;
- }
-
- cleaned.push(arg.clone());
- index += 1;
- }
-
- let remote = remote_target.map(|target| RemoteLaunch {
- target,
- keybindings,
- live_handoff,
- });
- if remote.is_none() && keybindings_seen {
- return Err("--remote-keybindings requires --remote".to_string());
- }
- if remote.is_none() && live_handoff {
- cleaned.push("--handoff".to_string());
- }
-
- Ok((cleaned, remote))
-}
-
-#[cfg(windows)]
-fn validate_remote_target(target: &str) -> Result<&str, String> {
- if target.is_empty() {
- return Err("missing value for --remote".to_string());
- }
- if target.starts_with('-') {
- return Err("--remote target must not start with '-'".to_string());
- }
- Ok(target)
-}
-
-#[cfg(windows)]
-pub(crate) fn run_remote(_remote: RemoteLaunch) -> std::io::Result<()> {
- debug_assert!(!crate::platform::capabilities().remote_attach);
- Err(std::io::Error::other(
- "remote mode is not supported on Windows yet",
- ))
-}
+pub(crate) use host_unix::run_remote_client_bridge;
#[cfg(windows)]
pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> {
- debug_assert!(!crate::platform::capabilities().remote_attach);
Err(std::io::Error::other(
- "remote client bridge is not supported on Windows yet",
+ "remote Windows hosts are not supported yet",
))
}
diff --git a/src/remote/unix.rs b/src/remote/attach.rs
index 6fe927c1..4121d468 100644
--- a/src/remote/unix.rs
+++ b/src/remote/attach.rs
@@ -1,12 +1,17 @@
//! Remote thin-client launcher over SSH command stdio.
+use super::shell_quote;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, IsTerminal, Write as _};
-use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
+use interprocess::local_socket::traits::Listener as _;
+#[cfg(windows)]
+use interprocess::local_socket::traits::Stream as _;
+use interprocess::local_socket::ListenerNonblockingMode;
+use interprocess::TryClone as _;
use serde::Deserialize;
use std::sync::{
atomic::{AtomicBool, Ordering},
@@ -16,6 +21,8 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
+#[cfg(windows)]
+const BRIDGE_IO_POLL: Duration = Duration::from_millis(1);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -191,53 +198,6 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
run_client_process(&local_socket, &reattach_command, remote.keybindings)
}
-pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
- ensure_remote_server_running()?;
-
- let socket_path = crate::server::socket_paths::client_socket_path();
- let stream = UnixStream::connect(&socket_path).map_err(|err| {
- io::Error::new(
- err.kind(),
- format!(
- "failed to connect to remote Herdr client socket {}: {err}",
- socket_path.display()
- ),
- )
- })?;
-
- let mut stdout = io::stdout().lock();
- let mut socket_to_stdout = stream.try_clone()?;
- let mut stdin_to_socket = stream;
-
- let _upload = thread::spawn(move || {
- let mut stdin = io::stdin();
- let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
- let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
- });
-
- copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
-}
-
-fn ensure_remote_server_running() -> io::Result<()> {
- let socket_path = crate::server::socket_paths::client_socket_path();
- if crate::server::autodetect::is_server_listening() {
- let status = crate::api::read_runtime_status_at(
- &crate::api::socket_path(),
- Duration::from_millis(500),
- )?
- .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
- if status.protocol == Some(CURRENT_PROTOCOL) {
- return Ok(());
- }
- return Err(io::Error::other(
- "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
- ));
- }
-
- crate::server::autodetect::spawn_server_daemon()?;
- crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemotePlatform {
os: &'static str,
@@ -435,7 +395,7 @@ struct PreparedRemoteHerdr {
#[derive(Clone)]
struct ManagedSshOptions {
config_path: PathBuf,
- control_path: PathBuf,
+ control_path: Option<PathBuf>,
}
struct ManagedSshConfig {
@@ -613,9 +573,14 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String {
impl Drop for RemoteSsh {
fn drop(&mut self) {
- if self.managed_config.is_none() {
+ let Some(_options) = self
+ .managed_config
+ .as_ref()
+ .map(|config| &config.options)
+ .filter(|options| options.control_path.is_some())
+ else {
return;
- }
+ };
let _ = self
.base_command()
@@ -636,15 +601,16 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO
return;
};
- command
- .arg("-F")
- .arg(&options.config_path)
- .arg("-S")
- .arg(&options.control_path)
- .arg("-o")
- .arg("ControlMaster=auto")
- .arg("-o")
- .arg("ControlPersist=yes");
+ command.arg("-F").arg(&options.config_path);
+ if let Some(control_path) = &options.control_path {
+ command
+ .arg("-S")
+ .arg(control_path)
+ .arg("-o")
+ .arg("ControlMaster=auto")
+ .arg("-o")
+ .arg("ControlPersist=yes");
+ }
}
impl InstallSource {
@@ -1577,14 +1543,15 @@ fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
- let base = std::env::temp_dir();
+ let base = crate::platform::remote_private_temp_base();
+ fs::create_dir_all(&base)?;
for attempt in 0..100 {
let dir = base.join(format!(
"herdr-remote-{}-{}-{attempt}",
std::process::id(),
asset_key
));
- match fs::create_dir(&dir) {
+ match crate::platform::create_remote_private_dir(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
@@ -1651,8 +1618,9 @@ fn reattach_command(
keybindings: RemoteKeybindings,
live_handoff: bool,
) -> String {
- let program = if program.is_empty() { "herdr" } else { program };
- let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
+ let program = crate::platform::remote_reattach_program(program);
+ let target = crate::platform::remote_reattach_argument(target);
+ let mut command = format!("{program} --remote {target}");
if keybindings != RemoteKeybindings::Local {
command.push_str(" --remote-keybindings ");
command.push_str(keybindings.as_str());
@@ -1662,27 +1630,11 @@ fn reattach_command(
}
if session_name != crate::session::DEFAULT_SESSION_NAME {
command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
+ command.push_str(&crate::platform::remote_reattach_argument(session_name));
}
command
}
-fn shell_quote(value: &str) -> String {
- if !value.is_empty()
- && value.chars().all(|ch| {
- ch.is_ascii_alphanumeric()
- || matches!(
- ch,
- '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
- )
- })
- {
- return value.to_string();
- }
-
- format!("'{}'", value.replace('\'', "'\\''"))
-}
-
fn command_failed(context: &str, output: &Output) -> io::Error {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
@@ -1695,6 +1647,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error {
struct SshStdioBridge {
local_socket: PathBuf,
+ socket_identity: crate::ipc::SocketFileIdentity,
should_stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
@@ -1707,10 +1660,21 @@ impl SshStdioBridge {
session_name: String,
ssh_options: Option<&ManagedSshOptions>,
) -> io::Result<Self> {
- let _ = std::fs::remove_file(&local_socket);
- let listener = UnixListener::bind(&local_socket)?;
- crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
- listener.set_nonblocking(true)?;
+ crate::ipc::prepare_socket_path(&local_socket, |path| {
+ format!("remote bridge is already listening at {}", path.display())
+ })?;
+ let listener = crate::ipc::bind_private_local_listener(&local_socket)?;
+ let socket_identity = crate::ipc::socket_file_identity(&local_socket)?;
+ if let Err(err) =
+ crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)
+ {
+ let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity);
+ return Err(err);
+ }
+ if let Err(err) = listener.set_nonblocking(ListenerNonblockingMode::Accept) {
+ let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity);
+ return Err(err);
+ }
let should_stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&should_stop);
@@ -1718,19 +1682,14 @@ impl SshStdioBridge {
let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Acquire) {
match listener.accept() {
- Ok((stream, _addr)) => {
- if let Err(err) = stream.set_nonblocking(false) {
- eprintln!(
- "herdr: remote bridge failed to prepare client socket: {err}"
- );
- continue;
- }
+ Ok(stream) => {
if let Err(err) = bridge_connection(
stream,
&target,
&remote_herdr,
&session_name,
thread_ssh_options.as_ref(),
+ &thread_stop,
) {
eprintln!("herdr: remote bridge failed: {err}");
}
@@ -1748,6 +1707,7 @@ impl SshStdioBridge {
Ok(Self {
local_socket,
+ socket_identity,
should_stop,
thread: Some(thread),
})
@@ -1757,100 +1717,64 @@ impl SshStdioBridge {
impl Drop for SshStdioBridge {
fn drop(&mut self) {
self.should_stop.store(true, Ordering::Release);
- let _ = std::fs::remove_file(&self.local_socket);
+ #[cfg(unix)]
+ let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
+ #[cfg(windows)]
+ let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity);
}
}
-/// Creates a fresh user-only (`0700`) directory for the generated ssh config
-/// and control socket, returning its path.
-///
-/// Using a private directory created with fail-if-exists semantics — rather
-/// than a predictable file in the world-writable temp dir — stops a local user
-/// from pre-planting a symlink or world-writable file that herdr would write
-/// and `ssh -F` would then read.
-fn private_ssh_config_dir() -> io::Result<PathBuf> {
- use std::os::unix::fs::DirBuilderExt;
-
- let mut bases = vec![std::env::temp_dir()];
- let short_tmp = PathBuf::from("/tmp");
- if bases.first() != Some(&short_tmp) {
- bases.push(short_tmp);
- }
-
- let mut last_error = None;
- for base in bases {
- for attempt in 0..100 {
- let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
- if !fits_unix_socket_path(&dir.join(SSH_CONTROL_SOCKET_NAME)) {
- continue;
- }
- match fs::DirBuilder::new().mode(0o700).create(&dir) {
- Ok(()) => return Ok(dir),
- Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(err) => {
- last_error = Some(err);
- break;
- }
- }
- }
- }
-
- Err(last_error.unwrap_or_else(|| {
- io::Error::new(
- io::ErrorKind::AlreadyExists,
- "failed to create private herdr ssh config directory",
- )
- }))
-}
-
-/// Quotes a path for an ssh_config `Include` so a path containing spaces (or
-/// glob metacharacters) is treated as one literal token instead of being split
-/// or expanded by ssh — otherwise the user's config might not be Included and
-/// herdr's fallback would wrongly take effect.
fn ssh_config_quote(path: &str) -> String {
format!("\"{path}\"")
}
-/// Builds a temporary ssh config for remote attach commands without overriding
-/// the user's own settings, returning its path.
-///
-/// The file `Include`s the user's real ssh config first, so ssh's
-/// first-value-wins rule keeps any `ServerAlive*` the user set there (including
-/// an explicit `0` to disable it). Herdr's keepalive values apply only when
-/// the user has none.
-fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
- use std::os::unix::fs::OpenOptionsExt;
+fn ssh_config_include_path(path: &Path) -> String {
+ let path = path.to_string_lossy();
+ if std::path::MAIN_SEPARATOR == '\\' {
+ ssh_config_quote(&path.replace('\\', "/"))
+ } else {
+ ssh_config_quote(&path)
+ }
+}
- let dir = private_ssh_config_dir()?;
+/// Builds a temporary ssh config that includes the user's settings first, so
+/// OpenSSH's first-value-wins behavior preserves explicit user keepalives.
+fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
+ let paths = crate::platform::remote_ssh_config_paths();
+ let dir = crate::platform::create_remote_ssh_config_dir(SSH_CONTROL_SOCKET_NAME)?;
let path = dir.join("config");
- let control_path = dir.join(SSH_CONTROL_SOCKET_NAME);
+ let control_path = paths
+ .multiplexing
+ .then(|| dir.join(SSH_CONTROL_SOCKET_NAME));
let mut contents = String::new();
- if let Some(home) = std::env::var_os("HOME") {
- let user_config = PathBuf::from(home).join(".ssh").join("config");
- if user_config.is_file() {
- contents.push_str(&format!(
- "Include {}\n",
- ssh_config_quote(&user_config.to_string_lossy())
- ));
- }
+ if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) {
+ contents.push_str(&format!(
+ "Include {}\n",
+ ssh_config_include_path(&user_config)
+ ));
}
- if Path::new("/etc/ssh/ssh_config").is_file() {
- contents.push_str("Include /etc/ssh/ssh_config\n");
+ if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) {
+ contents.push_str(&format!(
+ "Include {}\n",
+ ssh_config_include_path(&system_config)
+ ));
}
contents.push_str("Host *\n");
contents.push_str(" ServerAliveInterval 15\n");
contents.push_str(" ServerAliveCountMax 4\n");
- let mut file = fs::OpenOptions::new()
- .write(true)
- .create_new(true)
- .mode(BRIDGE_SOCKET_PERMISSION_MODE)
- .open(&path)?;
- file.write_all(contents.as_bytes())?;
+ let write_result = (|| {
+ let mut file = crate::platform::create_remote_ssh_config_file(&path)?;
+ file.write_all(contents.as_bytes())
+ })();
+ if let Err(err) = write_result {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(err);
+ }
Ok(ManagedSshConfig {
options: ManagedSshOptions {
config_path: path,
@@ -1859,20 +1783,21 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
})
}
+#[cfg(unix)]
fn bridge_connection(
- stream: UnixStream,
+ stream: crate::ipc::LocalStream,
target: &str,
remote_herdr: &RemoteHerdr,
session_name: &str,
ssh_options: Option<&ManagedSshOptions>,
+ _bridge_stop: &Arc<AtomicBool>,
) -> io::Result<()> {
let mut command = Command::new("ssh");
apply_managed_ssh_options(&mut command, ssh_options);
command
.arg("-T")
.arg(target)
- .arg(remote_bridge_command(remote_herdr, session_name));
- command
+ .arg(remote_bridge_command(remote_herdr, session_name))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
@@ -1896,7 +1821,7 @@ fn bridge_connection(
});
let download = thread::spawn(move || {
let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
- let _ = child_to_stream.shutdown(std::net::Shutdown::Write);
+ let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream);
});
let status = child.wait()?;
@@ -1913,6 +1838,157 @@ fn bridge_connection(
}
}
+#[cfg(windows)]
+fn bridge_connection(
+ stream: crate::ipc::LocalStream,
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ session_name: &str,
+ ssh_options: Option<&ManagedSshOptions>,
+ bridge_stop: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ let mut command = Command::new("ssh");
+ apply_managed_ssh_options(&mut command, ssh_options);
+ command
+ .arg("-T")
+ .arg(target)
+ .arg(remote_bridge_command(remote_herdr, session_name))
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit());
+
+ let mut child = command
+ .spawn()
+ .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
+ let mut child_stdin = match child.stdin.take() {
+ Some(stdin) => stdin,
+ None => return terminate_bridge_child(child, "ssh bridge stdin missing"),
+ };
+ let mut child_stdout = match child.stdout.take() {
+ Some(stdout) => stdout,
+ None => return terminate_bridge_child(child, "ssh bridge stdout missing"),
+ };
+ let stream_to_child = match stream.try_clone() {
+ Ok(stream) => stream,
+ Err(err) => {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(err);
+ }
+ };
+ if let Err(err) = stream.set_nonblocking(true) {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(err);
+ }
+ let mut child_to_stream = stream;
+
+ let connection_stop = Arc::new(AtomicBool::new(false));
+ let upload_stop = Arc::new(AtomicBool::new(false));
+ let upload_failed = Arc::new(AtomicBool::new(false));
+ let download_done = Arc::new(AtomicBool::new(false));
+ let client_closed = Arc::new(AtomicBool::new(false));
+ let upload_cancel = Arc::clone(&upload_stop);
+ let upload_bridge_stop = Arc::clone(bridge_stop);
+ let upload_failed_worker = Arc::clone(&upload_failed);
+ let upload_client_closed = Arc::clone(&client_closed);
+ let upload = thread::spawn(move || {
+ let result = copy_local_stream_to_writer(
+ stream_to_child,
+ &mut child_stdin,
+ &upload_cancel,
+ &upload_bridge_stop,
+ &upload_client_closed,
+ );
+ upload_failed_worker.store(result.is_err(), Ordering::Release);
+ result
+ });
+ let download_stop = Arc::clone(&connection_stop);
+ let download_bridge_stop = Arc::clone(bridge_stop);
+ let download_done_worker = Arc::clone(&download_done);
+ let download_upload_stop = Arc::clone(&upload_stop);
+ let download = thread::spawn(move || {
+ let result = copy_reader_to_local_stream(
+ &mut child_stdout,
+ &mut child_to_stream,
+ &download_stop,
+ &download_bridge_stop,
+ );
+ download_done_worker.store(true, Ordering::Release);
+ download_upload_stop.store(true, Ordering::Release);
+ result
+ });
+
+ let mut stopped_at = None;
+ let (status_result, child_exited) = loop {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ upload_stop.store(true, Ordering::Release);
+ break (Ok(status), true);
+ }
+ Ok(None) => {}
+ Err(err) => {
+ connection_stop.store(true, Ordering::Release);
+ upload_stop.store(true, Ordering::Release);
+ let _ = child.kill();
+ let _ = child.wait();
+ break (Err(err), false);
+ }
+ }
+ if bridge_stop.load(Ordering::Acquire) {
+ connection_stop.store(true, Ordering::Release);
+ upload_stop.store(true, Ordering::Release);
+ let _ = child.kill();
+ break (child.wait(), false);
+ }
+ if client_closed.load(Ordering::Acquire)
+ || upload_failed.load(Ordering::Acquire)
+ || download_done.load(Ordering::Acquire)
+ {
+ upload_stop.store(true, Ordering::Release);
+ let stopped_at = stopped_at.get_or_insert_with(Instant::now);
+ if stopped_at.elapsed() >= Duration::from_millis(250) {
+ connection_stop.store(true, Ordering::Release);
+ let _ = child.kill();
+ break (child.wait(), false);
+ }
+ }
+ thread::sleep(BRIDGE_ACCEPT_POLL);
+ };
+ upload_stop.store(true, Ordering::Release);
+ if !child_exited {
+ connection_stop.store(true, Ordering::Release);
+ }
+ let upload_result = upload
+ .join()
+ .map_err(|_| io::Error::other("remote bridge upload worker panicked"))?;
+ let download_result = download
+ .join()
+ .map_err(|_| io::Error::other("remote bridge download worker panicked"))?;
+ let status = status_result?;
+
+ let stopping = bridge_stop.load(Ordering::Acquire);
+ let client_closed = client_closed.load(Ordering::Acquire);
+ if !stopping && !client_closed {
+ upload_result.map_err(|err| {
+ io::Error::new(err.kind(), format!("remote bridge upload failed: {err}"))
+ })?;
+ download_result.map_err(|err| {
+ io::Error::new(err.kind(), format!("remote bridge download failed: {err}"))
+ })?;
+ }
+
+ if status.success() || stopping || client_closed {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ format!("ssh bridge exited with {status}"),
+ ))
+ }
+}
+
+#[cfg(unix)]
fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
let mut buffer = [0_u8; 16 * 1024];
let mut total = 0;
@@ -1931,6 +2007,80 @@ fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::
}
}
+#[cfg(windows)]
+fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> {
+ let _ = child.kill();
+ let _ = child.wait();
+ Err(io::Error::new(io::ErrorKind::BrokenPipe, message))
+}
+
+#[cfg(windows)]
+fn copy_reader_to_local_stream<R: io::Read>(
+ reader: &mut R,
+ stream: &mut crate::ipc::LocalStream,
+ connection_stop: &AtomicBool,
+ bridge_stop: &AtomicBool,
+) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+
+ loop {
+ let read = match reader.read(&mut buffer) {
+ Ok(0) => return Ok(total),
+ Ok(read) => read,
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(err) => return Err(err),
+ };
+ let mut written = 0;
+ while written < read {
+ if connection_stop.load(Ordering::Acquire) || bridge_stop.load(Ordering::Acquire) {
+ return Ok(total);
+ }
+ let chunk_len = (read - written).min(4 * 1024);
+ match stream.write(&buffer[written..written + chunk_len]) {
+ Ok(0) => thread::sleep(BRIDGE_IO_POLL),
+ Ok(count) => written += count,
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ thread::sleep(BRIDGE_IO_POLL);
+ }
+ Err(err) => return Err(err),
+ }
+ }
+ stream.flush()?;
+ total += read as u64;
+ }
+}
+
+#[cfg(windows)]
+fn copy_local_stream_to_writer<W: io::Write>(
+ mut stream: crate::ipc::LocalStream,
+ writer: &mut W,
+ connection_stop: &AtomicBool,
+ bridge_stop: &AtomicBool,
+ client_closed: &AtomicBool,
+) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+
+ while !connection_stop.load(Ordering::Acquire) && !bridge_stop.load(Ordering::Acquire) {
+ match crate::ipc::poll_local_stream_read_count(&mut stream, &mut buffer)? {
+ crate::ipc::LocalStreamReadCount::Data(read) => {
+ writer.write_all(&buffer[..read])?;
+ writer.flush()?;
+ total += read as u64;
+ }
+ crate::ipc::LocalStreamReadCount::Pending => thread::sleep(BRIDGE_IO_POLL),
+ crate::ipc::LocalStreamReadCount::Closed => {
+ client_closed.store(true, Ordering::Release);
+ break;
+ }
+ }
+ }
+
+ Ok(total)
+}
+
fn run_client_process(
local_socket: &Path,
reattach_command: &str,
@@ -1966,37 +2116,18 @@ fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
let pid = std::process::id();
let target_clean = sanitize_path_component(target);
let session_clean = sanitize_path_component(session_name);
-
- let tmpdir = std::env::temp_dir();
- let readable = tmpdir.join(format!(
- "herdr-remote-{pid}-{target_clean}-{session_clean}.sock"
- ));
- if fits_unix_socket_path(&readable) {
- return readable;
- }
-
- // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the
- // readable name past sun_path's 104-byte ceiling. Fall back to a hashed
- // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself
- // is longer than the budget. The hash covers the full unsanitized
- // target/session so uniqueness does not depend on the prefix truncation;
- // the prefix is kept only for debuggability.
+ let readable_name = format!("herdr-remote-{pid}-{target_clean}-{session_clean}.sock");
let target_prefix: String = target_clean.chars().take(8).collect();
let hash = short_socket_hash(target, session_name);
let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock");
- let short_in_tmp = tmpdir.join(&short_name);
- if fits_unix_socket_path(&short_in_tmp) {
- return short_in_tmp;
- }
- PathBuf::from("/tmp").join(short_name)
+ crate::platform::remote_bridge_endpoint_path(&readable_name, &short_name)
}
+#[cfg(all(test, unix))]
fn fits_unix_socket_path(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
- // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve
- // 1 byte for the trailing NUL and use the smaller cap for portability.
- const MAX: usize = 103;
- path.as_os_str().as_bytes().len() <= MAX
+
+ path.as_os_str().as_bytes().len() <= 103
}
fn short_socket_hash(target: &str, session: &str) -> String {
@@ -2028,6 +2159,7 @@ fn sanitize_path_component(input: &str) -> String {
mod tests {
use super::*;
+ #[cfg(unix)]
#[test]
fn bridge_socket_is_user_only() {
use std::os::unix::fs::PermissionsExt;
@@ -2056,13 +2188,42 @@ mod tests {
let _ = std::fs::remove_file(socket);
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_bridge_drop_while_waiting_for_client_is_bounded() {
+ let socket = local_forward_socket_path("drop-test", "default");
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let bridge = SshStdioBridge::start(
+ "example".to_string(),
+ remote_herdr,
+ socket.clone(),
+ "default".to_string(),
+ None,
+ )
+ .expect("start bridge listener");
+ let started = Instant::now();
+
+ drop(bridge);
+
+ assert!(started.elapsed() < Duration::from_secs(1));
+ assert!(!socket.exists());
+ }
+
+ #[cfg(unix)]
#[test]
fn managed_ssh_config_includes_user_config_then_fallback() {
use std::os::unix::fs::PermissionsExt;
let managed_config = write_managed_ssh_config().expect("write managed config");
let path = managed_config.options.config_path.clone();
- let control_path = managed_config.options.control_path.clone();
+ let control_path = managed_config
+ .options
+ .control_path
+ .clone()
+ .expect("Unix managed config has a control path");
let contents = std::fs::read_to_string(&path).expect("read keepalive config");
// herdr's fallback transport settings are present...
@@ -2124,11 +2285,16 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn remote_ssh_command_uses_managed_config_when_present() {
let managed_config = write_managed_ssh_config().expect("write managed config");
let config_path = managed_config.options.config_path.clone();
- let control_path = managed_config.options.control_path.clone();
+ let control_path = managed_config
+ .options
+ .control_path
+ .clone()
+ .expect("Unix managed config has a control path");
let ssh = RemoteSsh {
target: "example".to_string(),
managed_config: Some(managed_config),
@@ -2157,6 +2323,45 @@ mod tests {
);
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_managed_ssh_config_uses_keepalives_without_control_socket() {
+ let managed_config = write_managed_ssh_config().expect("write managed config");
+ let config_path = managed_config.options.config_path.clone();
+ assert!(managed_config.options.control_path.is_none());
+ let contents = std::fs::read_to_string(&config_path).expect("read managed config");
+ assert!(contents.contains("ServerAliveInterval 15"));
+ assert!(contents.contains("ServerAliveCountMax 4"));
+
+ let ssh = RemoteSsh {
+ target: "example".to_string(),
+ managed_config: Some(managed_config),
+ };
+ let args = ssh
+ .command()
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ args,
+ vec![
+ "-F".to_string(),
+ config_path.to_string_lossy().into_owned(),
+ "-T".to_string(),
+ "example".to_string(),
+ ]
+ );
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_ssh_config_include_uses_forward_slashes() {
+ assert_eq!(
+ ssh_config_include_path(Path::new(r"C:\Users\A B\.ssh\config")),
+ r#""C:/Users/A B/.ssh/config""#
+ );
+ }
+
#[test]
fn remote_ssh_command_is_plain_without_managed_config() {
let ssh = RemoteSsh {
@@ -2379,6 +2584,7 @@ mod tests {
assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
}
+ #[cfg(unix)]
#[test]
fn reattach_command_includes_remote_and_session() {
assert_eq!(
@@ -2423,6 +2629,25 @@ mod tests {
);
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_reattach_command_uses_current_executable() {
+ let executable = std::env::current_exe().expect("current test executable");
+ assert_eq!(
+ reattach_command(
+ r"C:\Program Files\Herdr\herdr.exe",
+ "host'name",
+ "work'name",
+ RemoteKeybindings::Local,
+ false,
+ ),
+ format!(
+ "& '{}' --remote 'host''name' --session 'work''name'",
+ executable.display().to_string().replace('\'', "''")
+ )
+ );
+ }
+
#[test]
fn remote_bridge_command_uses_installed_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
@@ -3009,16 +3234,29 @@ mod tests {
assert!(source.temporary_dir.is_none());
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_local_forward_endpoint_uses_private_state_dir() {
+ let path = local_forward_socket_path("user@example.com", "work");
+ assert!(path.starts_with(crate::platform::remote_private_temp_base()));
+ assert!(path
+ .file_name()
+ .is_some_and(|name| name.to_string_lossy().starts_with("herdr-r-")));
+ }
+
+ #[cfg(unix)]
fn remote_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
+ #[cfg(unix)]
fn socket_path_byte_len(path: &Path) -> usize {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().len()
}
+ #[cfg(unix)]
#[test]
fn local_forward_socket_path_uses_readable_name_when_it_fits() {
let _guard = remote_env_lock().lock().unwrap();
@@ -3043,6 +3281,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn local_forward_socket_path_fits_in_sun_path() {
let _guard = remote_env_lock().lock().unwrap();
@@ -3060,6 +3299,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
let _guard = remote_env_lock().lock().unwrap();
diff --git a/src/remote/host_unix.rs b/src/remote/host_unix.rs
new file mode 100644
index 00000000..e04b0e00
--- /dev/null
+++ b/src/remote/host_unix.rs
@@ -0,0 +1,69 @@
+//! Unix remote-host side of the SSH stdio bridge.
+
+use std::io;
+use std::os::unix::net::UnixStream;
+use std::thread;
+use std::time::Duration;
+
+pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
+ ensure_remote_server_running()?;
+
+ let socket_path = crate::server::socket_paths::client_socket_path();
+ let stream = UnixStream::connect(&socket_path).map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!(
+ "failed to connect to remote Herdr client socket {}: {err}",
+ socket_path.display()
+ ),
+ )
+ })?;
+
+ let mut stdout = io::stdout().lock();
+ let mut socket_to_stdout = stream.try_clone()?;
+ let mut stdin_to_socket = stream;
+
+ let _upload = thread::spawn(move || {
+ let mut stdin = io::stdin();
+ let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
+ let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
+ });
+
+ copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
+}
+
+fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+ loop {
+ let read = match reader.read(&mut buffer) {
+ Ok(0) => return Ok(total),
+ Ok(read) => read,
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(err) => return Err(err),
+ };
+ writer.write_all(&buffer[..read])?;
+ writer.flush()?;
+ total += read as u64;
+ }
+}
+
+fn ensure_remote_server_running() -> io::Result<()> {
+ let socket_path = crate::server::socket_paths::client_socket_path();
+ if crate::server::autodetect::is_server_listening() {
+ let status = crate::api::read_runtime_status_at(
+ &crate::api::socket_path(),
+ Duration::from_millis(500),
+ )?
+ .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
+ if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) {
+ return Ok(());
+ }
+ return Err(io::Error::other(
+ "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
+ ));
+ }
+
+ crate::server::autodetect::spawn_server_daemon()?;
+ crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))
+}
diff --git a/src/update.rs b/src/update.rs
index 3455841d..2120fb3c 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -1830,6 +1830,11 @@ pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool {
|| is_nix_store_exe_path_following_links(path)
}
+#[cfg(not(unix))]
+pub(crate) fn is_package_manager_managed_exe_path(_path: &Path) -> bool {
+ false
+}
+
fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool {
if is_homebrew_managed_exe_path(path) {
return true;