Skip to content

feat(windows): support remote attach to unix hosts - #2329

Open
ogulcancelik wants to merge 1 commit into
masterfrom
feature/windows-remote-attach
Open

feat(windows): support remote attach to unix hosts#2329
ogulcancelik wants to merge 1 commit into
masterfrom
feature/windows-remote-attach

Conversation

@ogulcancelik

Copy link
Copy Markdown
Collaborator

Summary

  • enable native Windows clients to use herdr --remote with Linux and macOS hosts
  • share the SSH/bootstrap launcher while keeping Unix remote-host bridging platform-specific
  • add protected Windows named-pipe and temporary-directory security with bounded bridge shutdown
  • bridge Windows clipboard images and Windows Terminal image-file drops through the existing remote image protocol
  • document Windows remote support and OpenSSH behavior in English, Japanese, and Simplified Chinese

Context: #1464 (reply in thread)

Validation

  • just check (3,213 tests after rebasing onto current origin/master)
  • just website-build
  • native Windows clippy and focused remote attach, named-pipe, clipboard-image, file-drop, and long-path tests
  • manual Windows-to-Linux remote attach through OpenSSH, reaching the remote Herdr TUI

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change extends remote attach to Windows clients. It adds cross-platform IPC, SSH handling, clipboard image transfer, and Unix host bridging. It also updates multilingual remote-attach documentation.

Changes

Remote Attach

Layer / File(s) Summary
Platform primitives and IPC contracts
Cargo.toml, src/ipc.rs, src/platform/*, src/main.rs, src/update.rs
Platform helpers provide private paths, SSH configuration, endpoints, reattach commands, and Windows named-pipe support.
Windows image input and client state
src/platform/windows.rs, src/platform/windows/clipboard_image.rs, src/client/mod.rs
Windows clipboard images are converted to bounded PNG data. Remote image triggers, file drops, timeouts, and configuration reloads work across platforms.
Cross-platform remote bridge lifecycle
src/remote.rs, src/remote/attach.rs
Remote attachment uses platform listeners, optional control sockets, nonblocking I/O, bounded shutdown, and ownership-aware cleanup.
Unix host bridge and remote support documentation
src/remote/host_unix.rs, docs/next/website/src/content/docs/*
Unix hosts validate or start the remote server and forward terminal streams. Documentation describes Windows clients connecting to Linux/macOS hosts and remote image transfer.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: native Windows support for remote attach to Unix hosts.
Description check ✅ Passed The description directly covers Windows remote attach, platform-specific implementation, security, image transfer, documentation, and validation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/windows-remote-attach

Comment @coderabbitai help to get the list of available commands.

@kangal-bot kangal-bot added the ai-review Trigger automated AI reviews for pull requests admitted by the PR gate label Aug 4, 2026
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR enables native Windows clients to attach to Unix Herdr hosts over SSH.

  • Refactors remote attach into shared launcher and Unix-host bridge modules.
  • Adds protected Windows named-pipe transport and Windows clipboard/file-drop image handling.
  • Extends platform abstractions and localized documentation for Windows remote support.

Confidence Score: 5/5

The PR appears safe to merge based on the eligible follow-up findings.

No blocking failure remains in the available follow-up review record.

Important Files Changed

Filename Overview
src/remote/attach.rs Refactors the SSH bootstrap and stdio bridge into a cross-platform launcher with managed configuration, binary discovery, and bounded shutdown.
src/remote/host_unix.rs Isolates the Unix remote-host bridge that connects SSH stdio to the selected remote server socket.
src/ipc.rs Adds private Windows named-pipe creation, polling, and ownership-aware marker cleanup.
src/client/mod.rs Extends the client loop and handshake behavior needed by Windows remote attach and remote image input.
src/platform/windows.rs Adds Windows platform support for secure temporary resources, clipboard images, and terminal file-drop handling.
src/platform/windows/clipboard_image.rs Implements bounded PNG validation and DIB-to-PNG conversion for Windows clipboard images.
src/platform/unix_common.rs Consolidates Unix-only remote-host and platform helpers shared by Linux and macOS.
src/platform/mod.rs Expands the platform abstraction so remote attach and image bridging can be selected consistently by operating system.

Sequence Diagram

sequenceDiagram
  participant User
  participant Client as Windows Herdr client
  participant Pipe as Private named pipe
  participant SSH as SSH stdio bridge
  participant Host as Unix remote bridge
  participant Server as Remote Herdr server
  User->>Client: herdr --remote host
  Client->>SSH: Detect/bootstrap matching Herdr
  SSH->>Host: Start remote-client-bridge
  Host->>Server: Connect to session socket
  Client->>Pipe: Start local client connection
  Pipe->>SSH: Relay framed protocol traffic
  SSH->>Host: Relay over stdin/stdout
  Host->>Server: Forward client messages
  Server-->>Client: Stream rendered frames
  Client->>Server: Forward input and image payloads
Loading

Reviews (2): Last reviewed commit: "feat(windows): support remote attach to ..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/remote/attach.rs (1)

1618-1631: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Windows reattach commands mix POSIX and PowerShell quoting.

crate::platform::remote_reattach_program escapes an embedded single quote for PowerShell by doubling it (''') in src/platform/windows.rs. Lines 1619 and 1629 then quote target and session_name with shell_quote, which uses the POSIX form (''\''). PowerShell does not accept the POSIX form.

If a target or session name contains a single quote, the generated Windows reattach command is malformed. Herdr does not execute this string, so the impact is a broken command that the user cannot paste and run.

Route the argument quoting through the platform layer as well, so the program and its arguments use one quoting style per platform.

🧹 Nitpick comments (5)
src/platform/windows.rs (1)

1396-1415: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Read GlobalSize after GlobalLock.

The current order queries the size, then locks the handle. The documented pattern locks the handle first and then reads the size, which removes any window between the size check and the copy. The reordering keeps the same bound and the same early returns.

♻️ Proposed reordering
 fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option<Vec<u8>> {
     let handle = unsafe { GetClipboardData(format) };
     if handle.is_null() {
         return None;
     }
-    let size = unsafe { GlobalSize(handle) };
-    if size == 0 || size > max_bytes {
-        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)
 }
src/platform/windows/clipboard_image.rs (1)

380-409: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for the Masked32 and top-down DIB paths.

The two current tests cover the 24-bit bottom-up path and the PNG tail stripping. The Masked32 branch carries the most intricate logic: ChannelMask::parse contiguity rejection, the rounded channel scaling in ChannelMask::extract, alpha presence rules per header size, and the mask-overlap rejections. The top-down path (signed_height < 0) is also untested.

Add cases for:

  • A BI_BITFIELDS 32-bit DIB with 5-6-5 masks, to lock the rounded scaling.
  • A BITMAPV5HEADER (124) DIB with an alpha mask, to confirm alpha extraction.
  • A negative height, to confirm top-down row order.
  • A non-contiguous mask and an overlapping mask, to confirm both are rejected.
src/client/mod.rs (1)

1922-1934: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why the Windows path skips unescape_terminal_drop_path.

image_path_from_terminal_drop at line 1970 calls unescape_terminal_drop_path, but read_image_file_from_client_events does not. That difference is required: Windows paths use \ as the separator, so backslash unescaping would corrupt C:\Users\.... The omission looks like a copy gap without a comment, and a future refactor that unifies the two paths would break Windows drops.

Add a short comment recording the constraint.

♻️ Proposed comment
 #[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)?;
+    // Do not unescape backslashes here. Windows paths use `\` as the path
+    // separator, so the Unix `unescape_terminal_drop_path` step would corrupt
+    // paths such as `C:\Users\me\shot.png`.
     let (path, extension) =
         image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?;
     read_image_file(path, extension)
 }
src/ipc.rs (1)

133-150: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why set_local_stream_nonblocking differs from set_local_stream_polling.

set_local_stream_polling at Line 120 no-ops on Windows. set_local_stream_nonblocking applies set_nonblocking on every platform. The two names do not express that difference. A future caller can pick the wrong helper and change blocking behavior on Windows without noticing.

Add a short doc comment on each function that states the intended use. Also consider naming the Windows chunk limit instead of the inline 4 * 1024.

docs/next/website/src/content/docs/ja/persistence-remote.mdx (1)

66-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mention both user and system SSH configurations. The managed temporary SSH config includes both before Herdr’s fallback settings. Align this paragraph with configuration.mdx.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 855fea91-fa95-490f-a02a-b48d4a0e439d

📥 Commits

Reviewing files that changed from the base of the PR and between d4f2540 and e9f4423.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • Cargo.toml
  • docs/next/website/src/content/docs/configuration.mdx
  • docs/next/website/src/content/docs/ja/configuration.mdx
  • docs/next/website/src/content/docs/ja/persistence-remote.mdx
  • docs/next/website/src/content/docs/ja/windows-beta.mdx
  • docs/next/website/src/content/docs/persistence-remote.mdx
  • docs/next/website/src/content/docs/windows-beta.mdx
  • docs/next/website/src/content/docs/zh-cn/configuration.mdx
  • docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
  • docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
  • src/client/mod.rs
  • src/ipc.rs
  • src/main.rs
  • src/platform/fallback.rs
  • src/platform/linux.rs
  • src/platform/macos.rs
  • src/platform/mod.rs
  • src/platform/unix_common.rs
  • src/platform/windows.rs
  • src/platform/windows/clipboard_image.rs
  • src/remote.rs
  • src/remote/attach.rs
  • src/remote/host_unix.rs
  • src/update.rs
💤 Files with no reviewable changes (1)
  • src/main.rs

Comment thread Cargo.toml
Comment thread src/remote/attach.rs Outdated
Comment thread src/remote/host_unix.rs
@ogulcancelik
ogulcancelik force-pushed the feature/windows-remote-attach branch from e9f4423 to 4fab160 Compare August 4, 2026 22:40
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread src/remote/host_unix.rs
@ogulcancelik

Copy link
Copy Markdown
Collaborator Author

Addressed the outside-diff Windows reattach finding in 4fab160 by routing target/session quoting through platform-specific helpers, including PowerShell apostrophe escaping. Also reordered clipboard GlobalLock/GlobalSize handling and documented why semantic Windows paths skip Unix backslash unescaping.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
src/platform/windows.rs (1)

1966-1979: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the test fixture even when an assertion fails.

private_remote_directory_supports_long_paths calls fs::remove_dir_all(base) only after both assertions pass. If create_remote_private_dir or the probe write fails, the long-path fixture stays in %TEMP% and later runs of the same PID reuse it. Delete the fixture with a guard, or remove base before creating it.

♻️ Proposed change
         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");
+        let result = (|| -> std::io::Result<()> {
+            super::create_remote_private_dir(&private)?;
+            fs::write(private.join("probe"), b"ok")
+        })();
+
+        fs::remove_dir_all(&base).expect("remove test directory");
+        result.expect("create private long-path directory and write inherited file");
     }
src/ipc.rs (1)

133-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the difference between set_local_stream_polling and set_local_stream_nonblocking.

The two functions now differ only on Windows. set_local_stream_polling is a no-op on Windows, while set_local_stream_nonblocking calls stream.set_nonblocking on every platform. A future caller can pick the wrong one and get blocking reads on Windows. Add a short doc comment to each function that states the intended caller and the Windows behavior.

local_stream_zero_write_is_pending and local_stream_write_chunk_len encode the PIPE_NOWAIT write semantics that set_local_stream_nonblocking enables. Name that coupling in the doc comment so the three helpers stay consistent.

♻️ Proposed change
+/// Enables read polling for callers that keep blocking Windows pipe reads and
+/// poll with `windows_named_pipe_available` instead. No-op on Windows.
 pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool) -> io::Result<()> {
+/// Puts the stream in nonblocking mode on every platform. On Windows this sets
+/// `PIPE_NOWAIT`, so writes can return `Ok(0)`; pair this with
+/// `local_stream_zero_write_is_pending` and `local_stream_write_chunk_len`.
 pub(crate) fn set_local_stream_nonblocking(
src/remote/attach.rs (3)

573-580: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the unused _options binding with a direct condition.

The let ... else form binds _options and never uses it. A boolean check states the intent more directly.

♻️ Proposed change
-        let Some(_options) = self
+        let has_control_socket = self
             .managed_config
             .as_ref()
             .map(|config| &config.options)
-            .filter(|options| options.control_path.is_some())
-        else {
+            .is_some_and(|options| options.control_path.is_some());
+        if !has_control_socket {
             return;
-        };
+        }

1660-1674: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Remove the bound endpoint when socket_file_identity fails.

Line 1664 runs before any cleanup handler exists. If socket_file_identity returns an error, start returns while the socket file or the Windows marker file created by bind_private_local_listener stays on disk. The later error paths at lines 1665-1674 already clean up. Add the same cleanup for this call, or read the identity before binding is not possible, so wrap this call in the same failure handling.

🛡️ Proposed change
         let listener = crate::ipc::bind_private_local_listener(&local_socket)?;
-        let socket_identity = crate::ipc::socket_file_identity(&local_socket)?;
+        let socket_identity = match crate::ipc::socket_file_identity(&local_socket) {
+            Ok(identity) => identity,
+            Err(err) => {
+                let _ = fs::remove_file(&local_socket);
+                return Err(err);
+            }
+        };

1780-1927: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shutdown ordering now drains child_stdout; confirm the intent stays encoded.

On Ok(Some(status)) at line 1863 the loop sets only upload_stop and leaves connection_stop clear. Line 1897-1899 then sets connection_stop only when !child_exited. The download worker therefore reads child_stdout to EOF and forwards the final bridge output. This resolves the earlier drain concern.

The ordering is load-bearing and easy to break. Add a short comment at line 1863 that states why connection_stop must stay clear on the normal child-exit path.

src/remote/host_unix.rs (1)

23-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename _upload to make the detached thread explicit.

The _upload binding suggests the handle is kept, but it drops at the end of the function and the thread is detached. When the socket-to-stdout copy ends, the stdin reader can still block in read. The process exits right after this function returns, so the thread is reclaimed. State that with drop(thread::spawn(...)) or a comment, so a later caller does not assume the thread is joined.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ca7a19d7-7095-4b39-848d-74c493c52fb0

📥 Commits

Reviewing files that changed from the base of the PR and between d57cefb and 4fab160.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • Cargo.toml
  • docs/next/website/src/content/docs/configuration.mdx
  • docs/next/website/src/content/docs/ja/configuration.mdx
  • docs/next/website/src/content/docs/ja/persistence-remote.mdx
  • docs/next/website/src/content/docs/ja/windows-beta.mdx
  • docs/next/website/src/content/docs/persistence-remote.mdx
  • docs/next/website/src/content/docs/windows-beta.mdx
  • docs/next/website/src/content/docs/zh-cn/configuration.mdx
  • docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
  • docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
  • src/client/mod.rs
  • src/ipc.rs
  • src/main.rs
  • src/platform/fallback.rs
  • src/platform/linux.rs
  • src/platform/macos.rs
  • src/platform/mod.rs
  • src/platform/unix_common.rs
  • src/platform/windows.rs
  • src/platform/windows/clipboard_image.rs
  • src/remote.rs
  • src/remote/attach.rs
  • src/remote/host_unix.rs
  • src/update.rs
💤 Files with no reviewable changes (1)
  • src/main.rs
🚧 Files skipped from review as they are similar to previous changes (19)
  • src/update.rs
  • docs/next/website/src/content/docs/ja/configuration.mdx
  • Cargo.toml
  • src/platform/linux.rs
  • docs/next/website/src/content/docs/zh-cn/configuration.mdx
  • docs/next/website/src/content/docs/configuration.mdx
  • docs/next/website/src/content/docs/windows-beta.mdx
  • docs/next/website/src/content/docs/persistence-remote.mdx
  • src/remote.rs
  • docs/next/website/src/content/docs/ja/persistence-remote.mdx
  • src/platform/mod.rs
  • src/platform/macos.rs
  • docs/next/website/src/content/docs/ja/windows-beta.mdx
  • src/platform/unix_common.rs
  • src/platform/fallback.rs
  • src/platform/windows/clipboard_image.rs
  • docs/next/website/src/content/docs/zh-cn/windows-beta.mdx
  • docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx
  • src/client/mod.rs

Comment thread src/remote/host_unix.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Trigger automated AI reviews for pull requests admitted by the PR gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants