外部 CLI 支持 Windows:命名管道、独立控制台程序 shellrs-cli 与用户 PATH 安装

- 新增 shellrs-cli 二进制(各平台都构建);Windows 发布版的 GUI 改为
  windows_subsystem = "windows",命令由 shellrs-cli.exe 承担
- Windows 上经命名管道 \\.\pipe\shellrs-cli-<数据目录散列> 与应用通信:
  DACL 只允许当前用户、拒绝远程客户端、首个实例独占名字;客户端以
  SECURITY_IDENTIFICATION 打开并校验管道属主,防止抢注冒充
- 设置 → 外部 CLI 在 Windows 上把 shellrs-cli.exe 复制成
  %LOCALAPPDATA%\ShellRS\bin\shellrs.exe 并加入用户 PATH;旧副本启动时
  自动更新,正在使用的副本也能替换;新增「已安装旧版本」「不在 PATH」
  「找不到 shellrs-cli.exe」状态
- 客户端拆成按平台的 connect 与共用的读帧循环;进度行改用空格覆盖;
  Windows 控制台上非法 UTF-8 输出替换为 U+FFFD;命令文本去 BOM、CRLF 转 LF
- 新增 tests/cli_bin.rs 进程级端到端测试与 Windows CI 工作流
- SKILL.md 增加 Windows / PowerShell 说明,README、CLAUDE.md 同步

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
XuzandClaude committed 2026-09-25 14:00:21 +08:00
1 parent 9704a8cc20
commit d6e1ed69ec
24 files changed
+1563 -143

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# The Windows build, which the Macs this is developed on cannot compile:
# the whole crate through clippy, and the external CLI's tests, including
# the named pipe between two processes.
name: windows
on:
push:
workflow_dispatch:
jobs:
cli:
runs-on: windows-latest
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
# rust-toolchain.toml picks the toolchain; this installs it with the
# components it lists before the cache key is computed.
- run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
# In case aws-lc-sys wants to assemble rather than use its prebuilt
# objects.
- uses: ilammy/setup-nasm@v1
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --lib cli::
- run: cargo test --test cli_bin
+12 -3
View File
@@ -21,6 +21,7 @@ cargo test # 单元测试(模块内)+ tests
cargo test --lib # 只跑单元测试
cargo test --test ui # 只跑 UI 集成测试
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
@@ -48,11 +49,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(`<data_dir>/cli.sock`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
- `server.rs`:`CliServer` 与可注入的 `CliBackend` trait;`backend.rs` 是真实实现 `SshCliBackend`(exec 走 `ssh::run_command`,传输走 `sftp_provider`)。
- `install.rs`:把 `shellrs` 链接进 PATH、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`integration.rs`:设置页用的状态实体 `CliIntegration`。
- `install.rs`:把 `shellrs` 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`install_windows.rs`:Windows 的做法(复制 + 用户 PATH),`cfg(any(windows, test))`,所以在 Mac 上也跑它的测试。`integration.rs`:设置页用的状态实体 `CliIntegration`。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。
关键流程与不变量:
@@ -76,6 +78,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **外部 CLI 经由正在运行的应用执行。** 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。
- **服务端**:生产路径 `Workspace::new` 用终端同一个 `SshConnector`(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 `CliServer`;`new_with_services`(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 `sync_cli_server`:会话快照(`CliTarget::all`,带分组全路径)和「启用外部 CLI」开关。
- **一直监听,按开关拒绝**:开关关着时回 `not_enabled`,Agent 才分得清「没运行」和「没开」。套接字 `0600`,每个连接再用 `peer_cred` 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
- **Windows 用命名管道,两端互相确认。** 管道名是整机的命名空间,不在用户自己的目录里,所以:
- 服务端每个实例都带 DACL `D:P(A;;GA;;;<当前用户 SID>)` 和 `PIPE_REJECT_REMOTE_CLIENTS`,第一个实例带 `FILE_FLAG_FIRST_PIPE_INSTANCE`(名字已被占用就启动失败,报 `AddrInUse`)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
- 客户端用 `SECURITY_IDENTIFICATION` 打开(抢注者不能冒充当前用户),发请求之前用 `GetSecurityInfo` 确认管道属主是当前用户或 BUILTIN\Administrators(提权运行的 ShellRS 建出来的属主是后者),否则拒绝。
- 用同步的 Win32 管道,不用 tokio 的:请求线程要阻塞的 `File`,而 tokio 的管道是 overlapped 句柄,拿去做同步 IO 时 std 会中止进程。
- 监听线程先建好下一个实例再把已连接的交给请求线程,否则那一刻来的客户端会看到「没运行」。请求线程结束时直接关句柄,**不要** `DisconnectNamedPipe`(会丢掉客户端还没读的数据)也不要 `FlushFileBuffers`(客户端挂住时会一直阻塞)。停止时置标志、反复以客户端身份连一下唤醒 `ConnectNamedPipe`,直到线程结束。
- **Windows 的 PATH 是副本加用户 PATH。** `IntegrationPaths::system()` 在 Windows 上:`exe` 是 `shellrs.exe` 旁边的 `shellrs-cli.exe`,`bin_link` 是 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`(副本,不是链接),`user_path` 是 `UserPath::Registry`(`HKCU\Environment\Path`,写回 `REG_EXPAND_SZ` 后广播 `WM_SETTINGCHANGE`;只有之后启动的程序看得到)。副本按长度加修改时间判断是否本版(安装时用 `set_modified` 把时间设成源文件的)。正在运行的副本不能覆盖但能改名,所以安装、移除都先把旧的改名成 `shellrs.exe.<uuid>.old` 再尽量删掉。生产环境启动时 `CliIntegration::update_outdated` 在后台更新旧副本,否则 Agent 会一直碰到 `version_mismatch`。测试一律 `user_path: None` 或 `UserPath::File`,绝不碰注册表。开发时 `cargo run` 只构建 `shellrs`,要 `cargo build` 才有 `shellrs-cli.exe`,否则设置页显示找不到。
- **Windows 只在 CI 上编译和测试**(`.github/workflows/windows.yml`:clippy 全 crate、`cargo test --lib cli::`、`cargo test --test cli_bin`),本机没有 Windows 工具链;改 `pipe_windows.rs`、`install_windows.rs` 的 Windows 部分和 `cfg(windows)` 代码后要看 CI。
- **每个请求一个线程**,后端方法都是阻塞的;SSH 句柄绑在创建它的 runtime 上,所以 `ssh::run_command` 和 SFTP 工作线程各自建 current-thread runtime。
- **exec 不许挂住**:`SshPrompts` 非交互;陌生主机在 events 回调里直接返回 `false`(`ask` 没人回答会永远等下去,因为超时不计提示时间),缺凭据由 `MissingCredential` 失败。`exec(true)` 后立刻 `eof()`,远程没有 stdin;读循环读到 `Close` / `None` 为止,退出码可能在 EOF 之后才到。输出回调写失败(Agent 断开)就放弃命令、断开连接。
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
Generated
+2
View File
@@ -7531,6 +7531,8 @@ dependencies = [
"trash",
"unicode-width",
"uuid",
"windows-registry 0.6.1",
"windows-sys 0.61.2",
"zeroize",
]
+25
View File
@@ -4,6 +4,19 @@ version = "0.1.0"
edition = "2024"
publish = false
description = "SSH session manager with local terminal support"
default-run = "shellrs"
# The app. On macOS and Linux it doubles as the `shellrs` command when
# started with arguments.
[[bin]]
name = "shellrs"
path = "src/main.rs"
# The `shellrs` command on its own, as a console program: Windows needs it,
# because the app is a GUI program there and has no console to print to.
[[bin]]
name = "shellrs-cli"
path = "src/bin/shellrs-cli.rs"
[dependencies]
gpui-kit = "0.6.1"
@@ -30,6 +43,18 @@ zeroize = "1"
keyring = "4"
trash = "5"
[target.'cfg(windows)'.dependencies]
windows-registry = "0.6"
windows-sys = { version = "0.61", features = [
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
tokio = { version = "1", features = ["process", "test-util"] }
gpui-kit = { version = "0.6.1", features = ["test-support"] }
+8 -4
View File
@@ -19,12 +19,16 @@
## 外部 CLI
`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 用 ShellRS 里已保存的会话执行远程命令、传输文件。命令本身不接触密码和私钥,也不读数据库:它把请求通过本机套接字(数据目录下的 `cli.sock`)交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。
`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 用 ShellRS 里已保存的会话执行远程命令、传输文件。命令本身不接触密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。
在 设置 → 外部 CLI 中:
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的会话;套接字只对当前用户可读写,连接时还会核对对方的用户身份。
- **CLI 二进制**:把 `shellrs` 链接进 PATH。macOS 上是 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框;Linux 上是 `~/.local/bin/shellrs`。「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的会话。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
- **CLI 二进制**:把 `shellrs` 放进 PATH。
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
- Linux:链接到 `~/.local/bin/shellrs`。
- macOS、Linux 上「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
- Windows:图形程序没有控制台,命令是单独的控制台程序 `shellrs-cli.exe`(与 `shellrs.exe` 放在一起)。「安装」把它复制成 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`,并把这个文件夹加入当前用户的 PATH(不需要管理员),重新打开的终端和 Agent 才能找到它。ShellRS 升级后启动时会自动更新这份副本;更新不了时显示「更新」。副本正被使用时也能更新。「移除」删掉副本并把文件夹移出 PATH。
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
命令(`shellrs --help` 查看完整说明):
@@ -40,7 +44,7 @@ shellrs download <ID> <远程路径> <本地路径>
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`session_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- 目前只支持 macOS 和 Linux。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
## 标签页
+3 -1
View File
@@ -6,7 +6,9 @@ mod paths;
pub use actions::*;
pub use assets::{AppAssets, CatalogIcon};
pub use paths::{cli_socket_path, data_dir, database_path, known_hosts_path, settings_path};
pub use paths::{
cli_endpoint, cli_socket_path, data_dir, database_path, known_hosts_path, settings_path,
};
use gpui_kit::component::{Theme, dock::ToggleZoom};
use gpui_kit::*;
+52 -2
View File
@@ -1,6 +1,6 @@
//! Where ShellRS keeps its data on disk.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Overrides the data directory. Set by tests and handy during development.
const DATA_DIR_ENV: &str = "SHELLRS_DATA_DIR";
@@ -35,7 +35,35 @@ pub fn settings_path() -> std::io::Result<PathBuf> {
/// Where the running app listens for the `shellrs` command. The command
/// and the app both ask here, so both follow `SHELLRS_DATA_DIR`.
pub fn cli_socket_path() -> PathBuf {
data_dir().join("cli.sock")
cli_endpoint(&data_dir())
}
/// Where an app keeping its data in `dir` listens for the `shellrs`
/// command: a socket in that directory, or on Windows a named pipe.
///
/// Pipe names are one namespace for the whole machine, so the name is made
/// from the data directory. That lives in the user's profile, so each user,
/// and each `SHELLRS_DATA_DIR`, gets a pipe of its own.
pub fn cli_endpoint(dir: &Path) -> PathBuf {
if cfg!(windows) {
PathBuf::from(pipe_name(dir))
} else {
dir.join("cli.sock")
}
}
/// `\\.\pipe\shellrs-cli-` and a hash of the directory. Windows paths
/// ignore case, so the hash does too.
fn pipe_name(dir: &Path) -> String {
use sha2::{Digest as _, Sha256};
let dir = std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf());
let digest = Sha256::digest(dir.to_string_lossy().to_lowercase().as_bytes());
let hex: String = digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!(r"\\.\pipe\shellrs-cli-{hex}")
}
/// ShellRS's private host-key trust store. It deliberately does not read or
@@ -45,3 +73,25 @@ pub fn known_hosts_path() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir.join("known_hosts"))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::pipe_name;
#[test]
fn each_data_directory_gets_a_pipe_of_its_own() {
let pipe = pipe_name(Path::new("/Users/me/AppData/Roaming/shellrs"));
assert!(pipe.starts_with(r"\\.\pipe\shellrs-cli-"));
assert_eq!(pipe.len(), r"\\.\pipe\shellrs-cli-".len() + 16);
assert_eq!(
pipe,
pipe_name(Path::new("/USERS/me/appdata/Roaming/SHELLRS"))
);
assert_ne!(
pipe,
pipe_name(Path::new("/Users/other/AppData/Roaming/shellrs"))
);
}
}
+7
View File
@@ -0,0 +1,7 @@
//! The `shellrs` command as a program of its own. Windows puts a copy of it
//! on the PATH (设置 → 外部 CLI → 安装), because the app itself is a GUI
//! program there and a shell would neither wait for it nor see its output.
fn main() {
std::process::exit(shellrs::cli::main(std::env::args_os().skip(1).collect()));
}
+21
View File
@@ -39,6 +39,26 @@ shellrs download <session-id> /var/log/nginx ./logs/nginx
shellrs download <session-id> /tmp/app.tar.gz ./latest.tar.gz
```
## Windows
The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths.
In PowerShell, pass multi-line commands and commands containing quotes through stdin with a here-string instead of a heredoc. Windows PowerShell 5.1 breaks double quotes inside arguments, so do not rely on them there:
```powershell
@'
<command>
'@ | shellrs exec <session-id> --stdin
```
Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as Chinese session names and remote output) turns into `?` or mojibake:
```powershell
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
```
The exit code is in `$LASTEXITCODE`.
## Rules
- Do not ask the user for SSH passwords, private keys, or passphrases. ShellRS holds those credentials.
@@ -57,3 +77,4 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
- `session_not_found`: run `shellrs list` again and use an `id` from its output.
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the session once in ShellRS and save the password.
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
+85 -30
View File
@@ -37,15 +37,17 @@ impl Console<'_> {
}
}
/// Send `request` to the app listening at `socket` and print the answer;
/// Send `request` to the app listening at `endpoint` and print the answer;
/// the exit code.
pub fn run(socket: &Path, request: Request, console: &mut Console) -> i32 {
match exchange(socket, request, console) {
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
let result = connect(endpoint).and_then(|stream| talk(&stream, request, console));
match result {
Ok(code) => code,
Err(Failure::NotRunning) => console.error(
ErrorCode::NotRunning,
"ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」",
),
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message),
Err(Failure::Broken(error)) => console.error(
ErrorCode::ConnectFailed,
&format!("与 ShellRS 的连接意外中断:{error}"),
@@ -55,6 +57,8 @@ pub fn run(socket: &Path, request: Request, console: &mut Console) -> i32 {
enum Failure {
NotRunning,
/// Not allowed to talk to the app, or not willing to.
Refused(&'static str),
Broken(io::Error),
}
@@ -64,31 +68,59 @@ impl From<io::Error> for Failure {
}
}
#[cfg(unix)]
fn exchange(socket: &Path, request: Request, console: &mut Console) -> Result<i32, Failure> {
use std::os::unix::net::UnixStream;
/// What a sandbox that forbids local connections looks like.
const NO_PERMISSION: &str =
"没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信";
let stream = match UnixStream::connect(socket) {
Ok(stream) => stream,
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
) =>
{
return Err(Failure::NotRunning);
}
Err(error) => return Err(error.into()),
};
#[cfg(unix)]
fn connect(socket: &Path) -> Result<std::os::unix::net::UnixStream, Failure> {
std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
_ => error.into(),
})
}
/// Open the app's pipe, and make sure it is the app's before saying
/// anything: the name is the machine's, so someone else could hold it.
#[cfg(windows)]
fn connect(pipe: &Path) -> Result<std::fs::File, Failure> {
use super::pipe_windows;
let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() {
io::ErrorKind::NotFound => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
_ => error.into(),
})?;
if !pipe_windows::owned_by_current_user(&pipe)? {
return Err(Failure::Refused(
"外部 CLI 的管道不属于当前用户,已拒绝连接",
));
}
Ok(pipe)
}
#[cfg(not(any(unix, windows)))]
fn connect(_: &Path) -> Result<std::fs::File, Failure> {
Err(Failure::Refused("此系统暂不支持外部 CLI"))
}
/// Send the request and print the replies until the last one.
fn talk<S>(stream: S, request: Request, console: &mut Console) -> Result<i32, Failure>
where
S: Copy + io::Read + Write,
{
let mut writer = stream;
write_json(
&mut &stream,
&mut writer,
&Envelope {
version: PROTOCOL_VERSION,
request,
},
)?;
let mut reader = BufReader::new(&stream);
let mut progress_shown = false;
let mut reader = BufReader::new(stream);
// The progress line on screen, to be blanked before anything else.
let mut progress = ProgressLine::default();
loop {
let Some((kind, payload)) = read_frame(&mut reader)? else {
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
@@ -104,16 +136,13 @@ fn exchange(socket: &Path, request: Request, console: &mut Console) -> Result<i3
}
FrameKind::Json => {
let reply: Reply = parse_json(&payload)?;
if progress_shown && !matches!(reply, Reply::Progress(_)) {
// Off the progress line before anything else is said.
let _ = write!(console.stderr, "\r\x1b[2K");
if !matches!(reply, Reply::Progress(_)) {
progress.clear(console.stderr);
}
match reply {
Reply::Progress(counters) => {
if console.stderr_is_terminal {
let _ = write!(console.stderr, "\r\x1b[2K{}", progress_line(counters));
let _ = console.stderr.flush();
progress_shown = true;
progress.show(console.stderr, &progress_line(counters));
}
}
Reply::Sessions { sessions } => {
@@ -129,9 +158,35 @@ fn exchange(socket: &Path, request: Request, console: &mut Console) -> Result<i3
}
}
#[cfg(not(unix))]
fn exchange(_: &Path, _: Request, console: &mut Console) -> Result<i32, Failure> {
Ok(console.error(ErrorCode::BadRequest, "此系统暂不支持外部 CLI"))
/// A line rewritten in place with a carriage return. Shorter text is
/// padded with spaces rather than cleared with an escape sequence, which
/// the older Windows console does not understand.
#[derive(Default)]
struct ProgressLine {
/// Columns the line on screen takes up.
width: usize,
}
impl ProgressLine {
fn show(&mut self, out: &mut dyn Write, text: &str) {
let width = text.width();
let padding = self.width.saturating_sub(width);
let _ = write!(out, "\r{text}{}", " ".repeat(padding));
if padding > 0 {
// Back to the end of the text.
let _ = write!(out, "\r{text}");
}
let _ = out.flush();
self.width = width;
}
fn clear(&mut self, out: &mut dyn Write) {
if self.width > 0 {
let _ = write!(out, "\r{}\r", " ".repeat(self.width));
let _ = out.flush();
self.width = 0;
}
}
}
fn print_sessions(sessions: &[SessionInfo], console: &mut Console) -> io::Result<()> {
+129 -40
View File
@@ -3,10 +3,9 @@
//! thread. Every path comes from [`IntegrationPaths`], so tests point it at
//! a temporary directory instead of the user's home.
use std::{
fs, io,
path::{Path, PathBuf},
};
#[cfg(not(windows))]
use std::path::Path;
use std::{fs, io, path::PathBuf};
use serde::Deserialize;
@@ -63,13 +62,14 @@ impl AgentKind {
}
}
/// The agent's skills directory, relative to the home directory.
fn skills_dir(self) -> &'static str {
/// The agent's skills directory, relative to the home directory, one
/// name at a time so Windows shows it with its own separators.
fn skills_dir(self) -> &'static [&'static str] {
match self {
AgentKind::Generic => ".agents/skills",
AgentKind::Codex => ".codex/skills",
AgentKind::ClaudeCode => ".claude/skills",
AgentKind::OpenCode => ".config/opencode/skills",
AgentKind::Generic => &[".agents", "skills"],
AgentKind::Codex => &[".codex", "skills"],
AgentKind::ClaudeCode => &[".claude", "skills"],
AgentKind::OpenCode => &[".config", "opencode", "skills"],
}
}
}
@@ -79,23 +79,51 @@ impl AgentKind {
pub struct IntegrationPaths {
/// Holds the agents' skill directories.
pub home: PathBuf,
/// The link that puts `shellrs` on the PATH.
/// What puts `shellrs` on the PATH: a link to [`Self::exe`] on macOS
/// and Linux, a copy of it on Windows.
pub bin_link: PathBuf,
/// The program the link points at: this one.
/// The command itself: this program on macOS and Linux, the
/// `shellrs-cli.exe` beside it on Windows.
pub exe: PathBuf,
/// Windows: the PATH that [`Self::bin_link`]'s folder is put on. `None`
/// leaves every PATH alone; tests use it, and macOS and Linux install
/// into a folder that is on the PATH already.
pub user_path: Option<UserPath>,
}
/// A PATH the command's folder can be added to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UserPath {
/// The user's `Path` in the registry, which consoles opened from now on
/// start with.
Registry,
/// A file holding the value, so tests leave the registry alone.
File(PathBuf),
}
impl IntegrationPaths {
/// The real locations, or `None` where the command cannot be installed.
/// macOS gets `/usr/local/bin`, which is on every PATH and may need an
/// administrator; Linux gets `~/.local/bin`, which needs nobody.
/// Windows gets a folder of ShellRS's own, added to the user's PATH.
pub fn system() -> Option<Self> {
let home = dirs::home_dir()?;
let exe = std::env::current_exe().ok()?;
let bin_link = if cfg!(target_os = "macos") {
PathBuf::from("/usr/local/bin").join(COMMAND_NAME)
let (bin_link, exe, user_path) = if cfg!(target_os = "macos") {
(
PathBuf::from("/usr/local/bin").join(COMMAND_NAME),
exe,
None,
)
} else if cfg!(unix) {
home.join(".local/bin").join(COMMAND_NAME)
(home.join(".local/bin").join(COMMAND_NAME), exe, None)
} else if cfg!(windows) {
let bin = dirs::data_local_dir()?.join("ShellRS").join("bin");
(
bin.join(format!("{COMMAND_NAME}.exe")),
exe.with_file_name("shellrs-cli.exe"),
Some(UserPath::Registry),
)
} else {
return None;
};
@@ -103,21 +131,21 @@ impl IntegrationPaths {
home,
bin_link,
exe,
user_path,
})
}
pub fn skill_file(&self, agent: AgentKind) -> PathBuf {
self.home
.join(agent.skills_dir())
.join(COMMAND_NAME)
.join(SKILL_FILE)
let mut file = self.home.clone();
file.extend(agent.skills_dir());
file.join(COMMAND_NAME).join(SKILL_FILE)
}
}
/// What is at [`IntegrationPaths::bin_link`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BinaryStatus {
/// A link to this program.
/// A link to this program, or on Windows a current copy on the PATH.
Installed,
Missing,
/// A link to another copy of `shellrs`, or to something no longer
@@ -130,9 +158,52 @@ pub enum BinaryStatus {
Occupied {
target: Option<PathBuf>,
},
/// Windows: a copy from another build. Installing updates it.
Outdated,
/// Windows: the copy is there but its folder is not on the PATH.
NotOnPath,
/// Windows: there is no `shellrs-cli.exe` beside the app to install.
Unavailable,
}
pub fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
#[cfg(windows)]
return super::install_windows::binary_status(paths);
#[cfg(not(windows))]
link_status(paths)
}
/// Put `shellrs` on the PATH. Where the directory is not writable, macOS
/// asks for an administrator the way other apps install their command; the
/// user can cancel that.
pub fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
#[cfg(windows)]
return super::install_windows::install_binary(paths);
#[cfg(not(windows))]
install_link(paths)
}
/// Take `shellrs` off the PATH, but only ours: someone else's file of the
/// same name stays.
pub fn remove_binary(paths: &IntegrationPaths) -> io::Result<()> {
#[cfg(windows)]
return super::install_windows::remove_binary(paths);
#[cfg(not(windows))]
remove_link(paths)
}
/// Bring an installed command up to this build, when it is a copy from
/// another one; whether it was. A link always leads to the program it
/// points at, so this is Windows's alone.
pub fn update_outdated_binary(paths: &IntegrationPaths) -> io::Result<bool> {
if binary_status(paths) != BinaryStatus::Outdated {
return Ok(false);
}
install_binary(paths).map(|()| true)
}
#[cfg(not(windows))]
fn link_status(paths: &IntegrationPaths) -> BinaryStatus {
let link = &paths.bin_link;
let Ok(metadata) = fs::symlink_metadata(link) else {
return BinaryStatus::Missing;
@@ -154,15 +225,15 @@ pub fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
}
/// Whether a link target is a `shellrs` binary, this build or another.
#[cfg(not(windows))]
fn is_ours(target: &Path) -> bool {
target.file_name().is_some_and(|name| name == COMMAND_NAME)
}
/// Link `shellrs` to this program. Where the directory is not writable,
/// macOS asks for an administrator the way other apps install their
/// command; the user can cancel that.
pub fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
if let BinaryStatus::Occupied { .. } = binary_status(paths) {
/// Link `shellrs` to this program.
#[cfg(not(windows))]
fn install_link(paths: &IntegrationPaths) -> io::Result<()> {
if let BinaryStatus::Occupied { .. } = link_status(paths) {
return Err(occupied(&paths.bin_link));
}
match link_binary(paths) {
@@ -176,6 +247,7 @@ pub fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
}
}
#[cfg(not(windows))]
fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
if let Some(dir) = paths.bin_link.parent() {
fs::create_dir_all(dir)?;
@@ -193,23 +265,22 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
))
}
/// Remove the link, but only a link to `shellrs`; someone else's file of
/// the same name stays.
pub fn remove_binary(paths: &IntegrationPaths) -> io::Result<()> {
match binary_status(paths) {
/// Remove the link, but only a link to `shellrs`.
#[cfg(not(windows))]
fn remove_link(paths: &IntegrationPaths) -> io::Result<()> {
match link_status(paths) {
BinaryStatus::Missing => Ok(()),
BinaryStatus::Occupied { .. } => Err(occupied(&paths.bin_link)),
BinaryStatus::Installed | BinaryStatus::Stale { .. } => {
match fs::remove_file(&paths.bin_link) {
Err(error) if needs_administrator(&error) => {
run_as_administrator(&format!("rm -f {}", shell_quote(&paths.bin_link)))
}
result => result,
_ => match fs::remove_file(&paths.bin_link) {
Err(error) if needs_administrator(&error) => {
run_as_administrator(&format!("rm -f {}", shell_quote(&paths.bin_link)))
}
}
result => result,
},
}
}
#[cfg(not(windows))]
fn occupied(link: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::AlreadyExists,
@@ -217,12 +288,14 @@ fn occupied(link: &Path) -> io::Error {
)
}
#[cfg(not(windows))]
fn needs_administrator(error: &io::Error) -> bool {
cfg!(target_os = "macos") && error.kind() == io::ErrorKind::PermissionDenied
}
/// Run a shell command as an administrator through the system's own
/// password prompt.
#[cfg(not(windows))]
fn run_as_administrator(command: &str) -> io::Result<()> {
let output = std::process::Command::new("osascript")
.arg("-e")
@@ -241,12 +314,14 @@ fn run_as_administrator(command: &str) -> io::Result<()> {
/// `command` as an AppleScript `do shell script` with administrator
/// privileges: a string literal, so backslashes and quotes are escaped.
#[cfg(not(windows))]
fn administrator_script(command: &str) -> String {
let literal = command.replace('\\', "\\\\").replace('"', "\\\"");
format!("do shell script \"{literal}\" with administrator privileges")
}
/// A path as one single-quoted shell word.
#[cfg(not(windows))]
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
@@ -309,6 +384,7 @@ mod tests {
home: root.join("home"),
bin_link: root.join("bin").join("shellrs"),
exe,
user_path: None,
}
}
@@ -319,11 +395,18 @@ mod tests {
let home = &paths.home;
assert_eq!(
paths.skill_file(AgentKind::Generic),
home.join(".agents/skills/shellrs/SKILL.md")
home.join(".agents")
.join("skills")
.join("shellrs")
.join("SKILL.md")
);
assert_eq!(
paths.skill_file(AgentKind::OpenCode),
home.join(".config/opencode/skills/shellrs/SKILL.md")
home.join(".config")
.join("opencode")
.join("skills")
.join("shellrs")
.join("SKILL.md")
);
}
@@ -343,12 +426,17 @@ mod tests {
install_skill(&paths, agent).unwrap();
assert_eq!(skill_status(&paths, agent), SkillStatus::Installed);
let other = paths.home.join(".claude/skills/other/SKILL.md");
let other = paths
.home
.join(".claude")
.join("skills")
.join("other")
.join("SKILL.md");
fs::create_dir_all(other.parent().unwrap()).unwrap();
fs::write(&other, "someone else's").unwrap();
remove_skill(&paths, agent).unwrap();
assert_eq!(skill_status(&paths, agent), SkillStatus::Missing);
assert!(!paths.home.join(".claude/skills/shellrs").exists());
assert!(!paths.skill_file(agent).parent().unwrap().exists());
assert!(other.exists());
// Removing what is not there is not an error.
remove_skill(&paths, agent).unwrap();
@@ -412,6 +500,7 @@ mod tests {
assert_eq!(fs::read_to_string(&paths.bin_link).unwrap(), "script");
}
#[cfg(not(windows))]
#[test]
fn administrator_commands_survive_spaces_and_quotes() {
assert_eq!(
+374
View File
@@ -0,0 +1,374 @@
//! Putting `shellrs` on the PATH the Windows way. A link would need an
//! administrator or developer mode there, and the app is a GUI program
//! anyway, so the command is a copy of the console program
//! `shellrs-cli.exe` in a folder of ShellRS's own, and that folder goes on
//! the user's PATH. Plain file work apart from the registry, so the tests
//! run everywhere, with the PATH kept in a file.
use std::{
fs, io,
path::{Path, PathBuf},
time::SystemTime,
};
use super::install::{BinaryStatus, IntegrationPaths, UserPath};
pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
let source = fs::metadata(&paths.exe).ok();
let Ok(copy) = fs::metadata(&paths.bin_link) else {
return match source {
Some(_) => BinaryStatus::Missing,
None => BinaryStatus::Unavailable,
};
};
// Without the program beside the app there is nothing to compare, but
// the copy can still be removed.
if source.is_some_and(|source| !same_build(&source, &copy)) {
return BinaryStatus::Outdated;
}
match on_path(paths) {
Ok(true) => BinaryStatus::Installed,
_ => BinaryStatus::NotOnPath,
}
}
/// A copy keeps its source's size and modification time: installing sets
/// the time on purpose.
fn same_build(source: &fs::Metadata, copy: &fs::Metadata) -> bool {
source.len() == copy.len() && modified(source) == modified(copy)
}
fn modified(metadata: &fs::Metadata) -> Option<SystemTime> {
metadata.modified().ok()
}
fn on_path(paths: &IntegrationPaths) -> io::Result<bool> {
let Some(user_path) = &paths.user_path else {
return Ok(true);
};
Ok(path_contains(&read(user_path)?, folder(paths)))
}
/// Copy the command into place and put its folder on the PATH. A copy
/// that is running, because an agent is using it right now, cannot be
/// overwritten but can be moved aside.
pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
if !paths.exe.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("找不到 {}", paths.exe.display()),
));
}
let folder = folder(paths);
fs::create_dir_all(folder)?;
let staged = sibling(&paths.bin_link, "new");
fs::copy(&paths.exe, &staged)?;
let source_time = fs::metadata(&paths.exe)?.modified()?;
fs::File::options()
.write(true)
.open(&staged)?
.set_modified(source_time)?;
set_aside(&paths.bin_link)?;
fs::rename(&staged, &paths.bin_link)?;
clear_aside(&paths.bin_link);
if let Some(user_path) = &paths.user_path {
let value = read(user_path)?;
if !path_contains(&value, folder) {
write(user_path, &path_with(&value, folder))?;
}
}
Ok(())
}
/// Delete the copy and take its folder off the PATH.
pub(super) fn remove_binary(paths: &IntegrationPaths) -> io::Result<()> {
set_aside(&paths.bin_link)?;
clear_aside(&paths.bin_link);
let folder = folder(paths);
if let Some(user_path) = &paths.user_path {
let value = read(user_path)?;
if path_contains(&value, folder) {
write(user_path, &path_without(&value, folder))?;
}
}
// Fails while a copy set aside is still running, which is fine.
let _ = fs::remove_dir(folder);
Ok(())
}
fn folder(paths: &IntegrationPaths) -> &Path {
paths.bin_link.parent().unwrap_or(Path::new("."))
}
/// `file` with `.suffix` added to its name.
fn sibling(file: &Path, suffix: &str) -> PathBuf {
let mut name = file.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(suffix);
file.with_file_name(name)
}
/// Move `file` out of the way under a name of its own, since an older one
/// set aside may still be running too.
fn set_aside(file: &Path) -> io::Result<()> {
let aside = sibling(file, &format!("{}.old", uuid::Uuid::new_v4().simple()));
match fs::rename(file, aside) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
}
/// Delete every copy set aside that is no longer running.
fn clear_aside(file: &Path) {
let (Some(dir), Some(name)) = (file.parent(), file.file_name()) else {
return;
};
let prefix = format!("{}.", name.to_string_lossy());
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let entry_name = entry.file_name().to_string_lossy().into_owned();
if entry_name.starts_with(&prefix) && entry_name.ends_with(".old") {
let _ = fs::remove_file(entry.path());
}
}
}
/// The PATH's current value, empty when it has none.
fn read(user_path: &UserPath) -> io::Result<String> {
match user_path {
UserPath::File(file) => match fs::read_to_string(file) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
result => result,
},
#[cfg(windows)]
UserPath::Registry => registry::read(),
#[cfg(not(windows))]
UserPath::Registry => Err(io::Error::from(io::ErrorKind::Unsupported)),
}
}
fn write(user_path: &UserPath, value: &str) -> io::Result<()> {
match user_path {
UserPath::File(file) => fs::write(file, value),
#[cfg(windows)]
UserPath::Registry => registry::write(value),
#[cfg(not(windows))]
UserPath::Registry => Err(io::Error::from(io::ErrorKind::Unsupported)),
}
}
/// The user's `Path` under `HKEY_CURRENT_USER\Environment`.
#[cfg(windows)]
mod registry {
use std::io;
use windows_sys::Win32::UI::WindowsAndMessaging::{
HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
};
/// `HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)`: the value is not there.
const NOT_FOUND: i32 = 0x8007_0002_u32 as i32;
fn environment() -> io::Result<windows_registry::Key> {
Ok(windows_registry::CURRENT_USER.create("Environment")?)
}
pub(super) fn read() -> io::Result<String> {
match environment()?.get_string("Path") {
Ok(value) => Ok(value),
Err(error) if error.code().0 == NOT_FOUND => Ok(String::new()),
Err(error) => Err(error.into()),
}
}
/// Save the value, expandable as Windows keeps it, and tell running
/// programs the environment changed. Only programs started after this
/// see it; an open console keeps its old PATH.
pub(super) fn write(value: &str) -> io::Result<()> {
let key = environment()?;
if value.is_empty() {
key.remove_value("Path")?;
} else {
key.set_expand_string("Path", value)?;
}
let environment: Vec<u16> = "Environment\0".encode_utf16().collect();
let mut result = 0;
// SAFETY: a NUL-terminated string that outlives the call.
unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
environment.as_ptr() as isize,
SMTO_ABORTIFHUNG,
5000,
&mut result,
)
};
Ok(())
}
}
/// One PATH entry, as Windows compares them: without case or a trailing
/// separator.
fn entry_key(entry: &str) -> String {
entry.trim().trim_end_matches(['\\', '/']).to_lowercase()
}
fn path_contains(value: &str, folder: &Path) -> bool {
let folder = entry_key(&folder.to_string_lossy());
value.split(';').any(|entry| entry_key(entry) == folder)
}
/// `value` with `folder` at the end.
fn path_with(value: &str, folder: &Path) -> String {
let folder = folder.to_string_lossy();
if value.is_empty() || value.ends_with(';') {
format!("{value}{folder}")
} else {
format!("{value};{folder}")
}
}
/// `value` without `folder`, everything else as it was.
fn path_without(value: &str, folder: &Path) -> String {
let folder = entry_key(&folder.to_string_lossy());
value
.split(';')
.filter(|entry| entry_key(entry) != folder)
.collect::<Vec<_>>()
.join(";")
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::*;
#[test]
fn the_folder_is_found_added_once_and_removed_by_windows_rules() {
let folder = Path::new(r"C:\Users\me\AppData\Local\ShellRS\bin");
let value = r"C:\Windows;c:\users\ME\appdata\local\shellrs\BIN\;D:\tools";
assert!(path_contains(value, folder));
assert!(!path_contains(
r"C:\Windows;C:\Users\me\AppData\Local\ShellRS",
folder
));
assert_eq!(
path_with(r"C:\Windows", folder),
r"C:\Windows;C:\Users\me\AppData\Local\ShellRS\bin"
);
assert_eq!(
path_with(r"C:\Windows;", folder),
r"C:\Windows;C:\Users\me\AppData\Local\ShellRS\bin"
);
assert_eq!(
path_with("", folder),
r"C:\Users\me\AppData\Local\ShellRS\bin"
);
assert_eq!(path_without(value, folder), r"C:\Windows;D:\tools");
// Someone else's empty entries stay.
assert_eq!(
path_without(r"C:\a;;C:\Users\me\AppData\Local\ShellRS\bin", folder),
r"C:\a;"
);
assert_eq!(path_without(r"C:\a", folder), r"C:\a");
}
fn paths(root: &Path) -> IntegrationPaths {
let exe = root.join("app").join("shellrs-cli.exe");
fs::create_dir_all(exe.parent().unwrap()).unwrap();
fs::write(&exe, "build 1").unwrap();
IntegrationPaths {
home: root.join("home"),
bin_link: root.join("ShellRS").join("bin").join("shellrs.exe"),
exe,
user_path: Some(UserPath::File(root.join("PATH.txt"))),
}
}
fn user_path(paths: &IntegrationPaths) -> String {
let Some(UserPath::File(file)) = &paths.user_path else {
unreachable!()
};
fs::read_to_string(file).unwrap_or_default()
}
#[test]
fn the_command_is_copied_onto_the_path_and_taken_off_again() {
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
let Some(UserPath::File(file)) = &paths.user_path else {
unreachable!()
};
fs::write(file, r"C:\Windows").unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Missing);
install_binary(&paths).unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Installed);
assert_eq!(fs::read_to_string(&paths.bin_link).unwrap(), "build 1");
let folder = paths.bin_link.parent().unwrap().display().to_string();
assert_eq!(user_path(&paths), format!(r"C:\Windows;{folder}"));
// Again changes nothing.
install_binary(&paths).unwrap();
assert_eq!(user_path(&paths), format!(r"C:\Windows;{folder}"));
// Taken off the PATH by hand.
fs::write(file, r"C:\Windows").unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::NotOnPath);
install_binary(&paths).unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Installed);
remove_binary(&paths).unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Missing);
assert_eq!(user_path(&paths), r"C:\Windows");
assert!(!paths.bin_link.parent().unwrap().exists());
assert!(paths.exe.exists());
// Removing what is not there is not an error.
remove_binary(&paths).unwrap();
}
#[test]
fn a_new_build_replaces_the_copy_even_while_it_is_open() {
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
install_binary(&paths).unwrap();
fs::write(&paths.exe, "build 2, a little longer").unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Outdated);
// An agent running the old copy holds it open.
let running = fs::File::open(&paths.bin_link).unwrap();
install_binary(&paths).unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Installed);
assert_eq!(
fs::read_to_string(&paths.bin_link).unwrap(),
"build 2, a little longer"
);
drop(running);
// Nothing left over once the old copy is closed and the next
// install tidies up.
install_binary(&paths).unwrap();
let names: Vec<String> = fs::read_dir(paths.bin_link.parent().unwrap())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["shellrs.exe"]);
}
#[test]
fn without_the_console_program_there_is_nothing_to_install() {
let root = tempfile::tempdir().unwrap();
let paths = paths(root.path());
fs::remove_file(&paths.exe).unwrap();
assert_eq!(binary_status(&paths), BinaryStatus::Unavailable);
assert!(install_binary(&paths).is_err());
assert!(!paths.bin_link.exists());
}
}
+18 -1
View File
@@ -2,6 +2,7 @@ use gpui_kit::*;
use super::install::{
AgentKind, BinaryStatus, IntegrationPaths, SkillStatus, binary_status, skill_status,
update_outdated_binary,
};
/// What the settings page shows about the `shellrs` command and the agent
@@ -67,6 +68,17 @@ impl CliIntegration {
/// Look at the file system again, off the main thread.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.check(false, cx);
}
/// Bring a copy of the command left by an older build up to this one,
/// then look again. A copy that old answers every request with
/// `version_mismatch`. Quietly: if it fails, the page offers 更新.
pub fn update_outdated(&mut self, cx: &mut Context<Self>) {
self.check(true, cx);
}
fn check(&mut self, update: bool, cx: &mut Context<Self>) {
let Some(paths) = self.paths.clone() else {
self._check = None;
cx.notify();
@@ -74,7 +86,12 @@ impl CliIntegration {
};
self._check = Some(cx.spawn(async move |this, cx| {
let status = cx
.background_spawn(async move { IntegrationStatus::read(&paths) })
.background_spawn(async move {
if update {
let _ = update_outdated_binary(&paths);
}
IntegrationStatus::read(&paths)
})
.await;
let _ = this.update(cx, |this, cx| {
this.status = Some(status);
+97 -8
View File
@@ -6,7 +6,11 @@
mod backend;
mod client;
mod install;
#[cfg(any(windows, test))]
mod install_windows;
mod integration;
#[cfg(windows)]
mod pipe_windows;
mod protocol;
mod server;
#[cfg(test)]
@@ -14,7 +18,7 @@ mod tests;
use std::{
ffi::OsString,
io::{IsTerminal as _, Read as _},
io::{self, IsTerminal as _, Read as _, Write},
path::PathBuf,
};
@@ -23,8 +27,9 @@ use clap::{Parser, Subcommand};
pub use backend::SshCliBackend;
pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT};
pub use install::{
AgentKind, BinaryStatus, IntegrationPaths, SKILL, SkillStatus, binary_status, install_binary,
install_skill, remove_binary, remove_skill, skill_status,
AgentKind, BinaryStatus, IntegrationPaths, SKILL, SkillStatus, UserPath, binary_status,
install_binary, install_skill, remove_binary, remove_skill, skill_status,
update_outdated_binary,
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use protocol::{CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary};
@@ -118,20 +123,24 @@ pub fn main(args: Vec<OsString>) -> i32 {
return error.exit_code();
}
};
let (mut stdout, mut stderr) = (std::io::stdout(), std::io::stderr());
let (stdout, stderr) = (io::stdout(), io::stderr());
let json = !stdout.is_terminal();
let stderr_is_terminal = stderr.is_terminal();
let mut stdout = ConsoleText::new(stdout, !json);
let mut stderr = ConsoleText::new(stderr, stderr_is_terminal);
let mut console = Console {
stdout: &mut stdout,
stderr: &mut stderr,
json,
stderr_is_terminal,
};
let request = match request(cli.command, &mut console) {
Ok(request) => request,
Err(code) => return code,
let code = match request(cli.command, &mut console) {
Ok(request) => client::run(&crate::app::cli_socket_path(), request, &mut console),
Err(code) => code,
};
client::run(&crate::app::cli_socket_path(), request, &mut console)
let _ = stdout.finish();
let _ = stderr.finish();
code
}
/// The request a command line asks for, or the exit code of why it
@@ -154,6 +163,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
} else {
command.unwrap_or_default()
};
let command = normalize_command(&command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
}
@@ -191,6 +201,16 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
})
}
/// A command as the remote shell should see it. PowerShell ends every line
/// it pipes to a program with CRLF, and a POSIX shell would take the CR as
/// part of the command; a BOM would be the start of the first word.
fn normalize_command(command: &str) -> String {
command
.strip_prefix('\u{feff}')
.unwrap_or(command)
.replace("\r\n", "\n")
}
/// The app runs in another directory, so local paths travel absolute.
fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
std::path::absolute(&path).map_err(|error| {
@@ -201,6 +221,75 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
})
}
/// Standard output or error, made safe for a Windows console: remote output
/// may not be UTF-8 (a GBK server, a binary file), and the Windows console
/// refuses bytes that are not. Invalid bytes become U+FFFD there; a
/// character split across two writes is held until its second half comes.
/// Anywhere else, bytes pass through untouched.
struct ConsoleText<W: Write> {
inner: W,
lossy: bool,
/// The start of a character whose rest has not been written yet.
pending: Vec<u8>,
}
impl<W: Write> ConsoleText<W> {
fn new(inner: W, is_terminal: bool) -> Self {
Self {
inner,
lossy: cfg!(windows) && is_terminal,
pending: Vec::new(),
}
}
/// Write out a character cut off at the very end, and flush.
fn finish(&mut self) -> io::Result<()> {
if !self.pending.is_empty() {
self.pending.clear();
self.inner.write_all("\u{fffd}".as_bytes())?;
}
self.inner.flush()
}
}
impl<W: Write> Write for ConsoleText<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.lossy {
return self.inner.write(bytes);
}
self.pending.extend_from_slice(bytes);
let mut text = String::new();
let mut rest: &[u8] = &self.pending;
while !rest.is_empty() {
match std::str::from_utf8(rest) {
Ok(valid) => {
text.push_str(valid);
rest = &[];
}
Err(error) => {
let (valid, after) = rest.split_at(error.valid_up_to());
text.push_str(&String::from_utf8_lossy(valid));
match error.error_len() {
Some(invalid) => {
text.push('\u{fffd}');
rest = &after[invalid..];
}
// The rest of the character is still to come.
None => break,
}
}
}
}
self.pending = rest.to_vec();
self.inner.write_all(text.as_bytes())?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// Whether the process was started as the command rather than the app:
/// with arguments, apart from the one macOS adds when launching an app.
pub fn command_line_arguments() -> Option<Vec<OsString>> {
+263
View File
@@ -0,0 +1,263 @@
//! The Win32 calls behind the CLI's named pipe. The pipe's name belongs to
//! the whole machine rather than to a folder of the user's, so both ends
//! make sure of each other: the app's pipe lets only the same user in, and
//! the command talks only to a pipe that user (or an administrator) owns.
use std::{
ffi::OsStr,
fs::{File, OpenOptions},
io,
os::windows::{
ffi::OsStrExt as _,
fs::OpenOptionsExt as _,
io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
},
path::Path,
ptr::null_mut,
};
use windows_sys::Win32::{
Foundation::{
ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, ERROR_SUCCESS,
INVALID_HANDLE_VALUE, LocalFree,
},
Security::{
Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
GetSecurityInfo, SDDL_REVISION_1, SE_KERNEL_OBJECT,
},
EqualSid, GetTokenInformation, IsWellKnownSid, OWNER_SECURITY_INFORMATION,
PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser,
WinBuiltinAdministratorsSid,
},
Storage::FileSystem::{
FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX, SECURITY_IDENTIFICATION,
},
System::{
Pipes::{
ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS,
PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, WaitNamedPipeW,
},
Threading::{GetCurrentProcess, OpenProcessToken},
},
};
/// Each direction's buffer. Output larger than this simply waits for the
/// command to read it.
const BUFFER: u32 = 64 * 1024;
/// How many times the command waits for a free instance before giving up.
const BUSY_RETRIES: u32 = 5;
/// How long each of those waits may take, in milliseconds.
const BUSY_WAIT: u32 = 1000;
/// A new instance of the app's pipe, waiting for a caller. The first one
/// fails if the name is already taken, by another ShellRS or anyone else.
pub(super) fn create_instance(name: &Path, first: bool) -> io::Result<OwnedHandle> {
let descriptor = SecurityDescriptor::current_user_only()?;
let attributes = SECURITY_ATTRIBUTES {
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let name = wide(name.as_os_str());
let mut open_mode = PIPE_ACCESS_DUPLEX;
if first {
open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE;
}
// SAFETY: `name` is NUL-terminated and `attributes` points at a
// descriptor that outlives the call.
let handle = unsafe {
CreateNamedPipeW(
name.as_ptr(),
open_mode,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
PIPE_UNLIMITED_INSTANCES,
BUFFER,
BUFFER,
0,
&attributes,
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
// SAFETY: a valid handle nobody else owns.
Ok(unsafe { OwnedHandle::from_raw_handle(handle) })
}
/// Whether creating the first instance failed because the name is taken.
pub(super) fn is_taken(error: &io::Error) -> bool {
error.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32)
}
/// Block until a caller connects to `pipe`. An error means the caller
/// came and went already; the instance is spent either way.
pub(super) fn wait_for_client(pipe: &OwnedHandle) -> io::Result<()> {
// SAFETY: a valid pipe handle, opened for synchronous use.
if unsafe { ConnectNamedPipe(pipe.as_raw_handle(), null_mut()) } != 0 {
return Ok(());
}
let error = io::Error::last_os_error();
// Connected between creating the instance and waiting on it.
if error.raw_os_error() == Some(ERROR_PIPE_CONNECTED as i32) {
return Ok(());
}
Err(error)
}
/// The command's end: open the app's pipe, waiting briefly while every
/// instance is busy. Identification only, so a program that took the name
/// cannot act as the caller.
pub(super) fn open(name: &Path) -> io::Result<File> {
let mut waits = 0;
loop {
match OpenOptions::new()
.read(true)
.write(true)
.security_qos_flags(SECURITY_IDENTIFICATION)
.open(name)
{
Err(error)
if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) && waits < BUSY_RETRIES =>
{
waits += 1;
// SAFETY: a NUL-terminated name.
unsafe { WaitNamedPipeW(wide(name.as_os_str()).as_ptr(), BUSY_WAIT) };
}
result => return result,
}
}
}
/// Whether the pipe at the other end belongs to the current user, or to
/// the administrators (whom an elevated ShellRS creates it as). Nobody
/// else can make either of them a pipe's owner.
pub(super) fn owned_by_current_user(pipe: &File) -> io::Result<bool> {
let mut owner: PSID = null_mut();
let mut descriptor: PSECURITY_DESCRIPTOR = null_mut();
// SAFETY: `owner` points into `descriptor`, which is freed after the
// last use of `owner` below.
let status = unsafe {
GetSecurityInfo(
pipe.as_raw_handle(),
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut owner,
null_mut(),
null_mut(),
null_mut(),
&mut descriptor,
)
};
if status != ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(status as i32));
}
let descriptor = SecurityDescriptor(descriptor);
let user = CurrentUser::read()?;
// SAFETY: both SIDs are valid for as long as `descriptor` and `user`.
let owned = unsafe {
EqualSid(owner, user.sid()) != 0 || IsWellKnownSid(owner, WinBuiltinAdministratorsSid) != 0
};
drop(descriptor);
Ok(owned)
}
/// The user this process runs as.
struct CurrentUser {
/// A `TOKEN_USER` and the SID it points into; `usize`s so the struct is
/// aligned.
buffer: Vec<usize>,
}
impl CurrentUser {
fn read() -> io::Result<Self> {
let mut token = null_mut();
// SAFETY: the current process's pseudo handle needs no closing; the
// token handle is owned right away.
let token = unsafe {
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
return Err(io::Error::last_os_error());
}
OwnedHandle::from_raw_handle(token)
};
let mut length = 0;
// SAFETY: asking for the size only; this call fails by design.
unsafe {
GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut length)
};
let mut buffer = vec![0usize; (length as usize).div_ceil(size_of::<usize>())];
// SAFETY: `buffer` holds at least `length` bytes.
if unsafe {
GetTokenInformation(
token.as_raw_handle(),
TokenUser,
buffer.as_mut_ptr().cast(),
length,
&mut length,
)
} == 0
{
return Err(io::Error::last_os_error());
}
Ok(Self { buffer })
}
fn sid(&self) -> PSID {
// SAFETY: `buffer` holds the `TOKEN_USER` that `read` asked for.
unsafe { (*self.buffer.as_ptr().cast::<TOKEN_USER>()).User.Sid }
}
/// The SID as text, such as `S-1-5-21-…`.
fn sid_string(&self) -> io::Result<String> {
let mut text = null_mut();
// SAFETY: a valid SID; the string is freed with LocalFree.
unsafe {
if ConvertSidToStringSidW(self.sid(), &mut text) == 0 {
return Err(io::Error::last_os_error());
}
let mut length = 0;
while *text.add(length) != 0 {
length += 1;
}
let sid = String::from_utf16_lossy(std::slice::from_raw_parts(text, length));
LocalFree(text.cast());
Ok(sid)
}
}
}
/// A security descriptor Windows allocated, freed when dropped.
struct SecurityDescriptor(PSECURITY_DESCRIPTOR);
impl SecurityDescriptor {
/// Full access for the current user and nobody else, not inherited
/// from anywhere.
fn current_user_only() -> io::Result<Self> {
let sddl = format!("D:P(A;;GA;;;{})", CurrentUser::read()?.sid_string()?);
let mut descriptor = null_mut();
// SAFETY: a NUL-terminated string; the result is freed on drop.
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide(OsStr::new(&sddl)).as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
null_mut(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
Ok(Self(descriptor))
}
}
impl Drop for SecurityDescriptor {
fn drop(&mut self) {
// SAFETY: allocated by Windows with LocalAlloc.
unsafe { LocalFree(self.0) };
}
}
fn wide(text: &OsStr) -> Vec<u16> {
text.encode_wide().chain(std::iter::once(0)).collect()
}
+2 -1
View File
@@ -14,7 +14,8 @@ use std::{
use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// Bumped when a request or reply changes shape. The command and the app
/// are the same program, so they differ only while an older copy still runs.
/// come from the same build, so they differ only while an older copy still
/// runs, or on Windows while the copy on the PATH has not been updated.
pub const PROTOCOL_VERSION: u32 = 1;
/// Frames larger than this are refused, so a confused peer cannot make the
+155 -41
View File
@@ -12,6 +12,7 @@ use std::{
thread::JoinHandle,
};
#[cfg(unix)]
use tokio::sync::watch;
use super::protocol::{
@@ -132,28 +133,23 @@ struct Shared {
/// running" and can tell the user which of the two to fix.
pub struct CliServer {
shared: Arc<Shared>,
socket: PathBuf,
shutdown: watch::Sender<bool>,
listener: Option<JoinHandle<()>>,
_listener: Listener,
}
impl CliServer {
/// Take over `socket`, which must not be another running ShellRS's.
/// Only the user running the app can connect: the socket is theirs
/// alone, and every caller's user id is checked again.
pub fn start(socket: PathBuf, backend: Arc<dyn CliBackend>) -> io::Result<Self> {
/// Take over `endpoint` (from [`crate::app::cli_endpoint`]), which must
/// not be another running ShellRS's. Only the user running the app can
/// connect.
pub fn start(endpoint: PathBuf, backend: Arc<dyn CliBackend>) -> io::Result<Self> {
let shared = Arc::new(Shared {
enabled: AtomicBool::new(false),
targets: RwLock::new(Vec::new()),
backend,
});
let (shutdown, stop) = watch::channel(false);
let listener = listen(&socket, shared.clone(), stop)?;
let listener = listen(endpoint, shared.clone())?;
Ok(Self {
shared,
socket,
shutdown,
listener: Some(listener),
_listener: listener,
})
}
@@ -170,44 +166,50 @@ impl CliServer {
}
}
impl Drop for CliServer {
/// The listening thread, stopped when dropped. On Unix, the socket file
/// goes with it.
#[cfg(unix)]
struct Listener {
socket: PathBuf,
shutdown: watch::Sender<bool>,
thread: Option<JoinHandle<()>>,
}
#[cfg(unix)]
impl Drop for Listener {
fn drop(&mut self) {
self.shutdown.send_replace(true);
if let Some(listener) = self.listener.take() {
let _ = listener.join();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
let _ = std::fs::remove_file(&self.socket);
}
}
/// The socket is the user's alone, and every caller's user id is checked
/// again.
#[cfg(unix)]
fn listen(
socket: &Path,
shared: Arc<Shared>,
mut stop: watch::Receiver<bool>,
) -> io::Result<JoinHandle<()>> {
fn listen(socket: PathBuf, shared: Arc<Shared>) -> io::Result<Listener> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::os::unix::net::{UnixListener, UnixStream};
if std::fs::symlink_metadata(socket).is_ok() {
if UnixStream::connect(socket).is_ok() {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
"另一个 ShellRS 已在提供外部 CLI",
));
if std::fs::symlink_metadata(&socket).is_ok() {
if UnixStream::connect(&socket).is_ok() {
return Err(another_app());
}
// Left behind by a ShellRS that did not get to clean up.
std::fs::remove_file(socket)?;
std::fs::remove_file(&socket)?;
}
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(socket)?;
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
let owner = std::fs::metadata(socket)?.uid();
let listener = UnixListener::bind(&socket)?;
std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600))?;
let owner = std::fs::metadata(&socket)?.uid();
listener.set_nonblocking(true)?;
std::thread::Builder::new()
let (shutdown, mut stop) = watch::channel(false);
let thread = std::thread::Builder::new()
.name("shellrs-cli".into())
.spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
@@ -231,15 +233,12 @@ fn listen(
}
}
});
})
}
#[cfg(not(unix))]
fn listen(_: &Path, _: Arc<Shared>, _: watch::Receiver<bool>) -> io::Result<JoinHandle<()>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持外部 CLI",
))
})?;
Ok(Listener {
socket,
shutdown,
thread: Some(thread),
})
}
/// Hand a connection from the same user to a thread of its own.
@@ -269,6 +268,120 @@ fn accept(stream: tokio::net::UnixStream, owner: u32, shared: &Arc<Shared>) {
});
}
/// The listening thread, stopped when dropped.
#[cfg(windows)]
struct Listener {
pipe: PathBuf,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
#[cfg(windows)]
impl Drop for Listener {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let Some(thread) = self.thread.take() else {
return;
};
// The thread waits in ConnectNamedPipe; a caller wakes it. Until it
// has gone: between two pipe instances there is nothing to connect
// to, so one knock may not be heard.
while !thread.is_finished() {
let _ = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&self.pipe);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let _ = thread.join();
}
}
/// A named pipe only the current user can open, and only from this
/// machine. Its name is the machine's, not a file of the user's, so it is
/// created as the first instance: if anyone already holds it, the server
/// does not start.
#[cfg(windows)]
fn listen(pipe: PathBuf, shared: Arc<Shared>) -> io::Result<Listener> {
use super::pipe_windows;
let first = pipe_windows::create_instance(&pipe, true).map_err(|error| {
if pipe_windows::is_taken(&error) {
another_app()
} else {
error
}
})?;
let stop = Arc::new(AtomicBool::new(false));
let thread = std::thread::Builder::new()
.name("shellrs-cli".into())
.spawn({
let (pipe, stop) = (pipe.clone(), stop.clone());
move || {
let mut waiting = first;
loop {
let connected = pipe_windows::wait_for_client(&waiting);
if stop.load(Ordering::Acquire) {
break;
}
// The next instance first, so a caller arriving now
// finds the pipe busy rather than gone.
let next = loop {
match pipe_windows::create_instance(&pipe, false) {
Ok(next) => break Some(next),
Err(_) if !stop.load(Ordering::Acquire) => {
std::thread::sleep(std::time::Duration::from_millis(100))
}
Err(_) => break None,
}
};
let Some(next) = next else {
break;
};
let current = std::mem::replace(&mut waiting, next);
// Otherwise the caller came and went already.
if connected.is_ok() {
answer(std::fs::File::from(current), &shared);
}
}
}
})?;
Ok(Listener {
pipe,
stop,
thread: Some(thread),
})
}
/// Answer a connected caller on a thread of its own. Closing the pipe
/// afterwards keeps what the caller has not read yet: only
/// `DisconnectNamedPipe` would throw it away.
#[cfg(windows)]
fn answer(pipe: std::fs::File, shared: &Arc<Shared>) {
let shared = shared.clone();
let _ = std::thread::Builder::new()
.name("shellrs-cli-request".into())
.spawn(move || {
// A caller that hangs up mid-answer is no one's problem.
let _ = serve(&mut BufReader::new(&pipe), &mut &pipe, &shared);
});
}
#[cfg(not(any(unix, windows)))]
struct Listener;
#[cfg(not(any(unix, windows)))]
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持外部 CLI",
))
}
fn another_app() -> io::Error {
io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI")
}
/// Read one request and answer it.
fn serve(
reader: &mut impl io::Read,
@@ -292,7 +405,8 @@ fn serve(
writer,
CliError::new(
ErrorCode::VersionMismatch,
"shellrs 命令与正在运行的 ShellRS 版本不同:请重新启动 ShellRS",
"shellrs 命令与正在运行的 ShellRS 版本不同:\
请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI",
),
);
}
+103 -8
View File
@@ -15,6 +15,8 @@ use super::protocol::{
};
use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use crate::app::cli_endpoint;
use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId};
use crate::ssh::ExecStream;
@@ -99,7 +101,7 @@ struct Fixture {
fn fixture() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
let socket = cli_endpoint(dir.path());
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(socket.clone(), backend.clone()).unwrap();
let web = session(1, "web-01", "10.0.1.12");
@@ -121,6 +123,15 @@ fn fixture() -> Fixture {
/// Run one request the way the command does; its exit code, stdout and
/// stderr.
fn run(socket: &Path, request: Request, json: bool) -> (i32, String, Vec<u8>) {
run_on(socket, request, json, false)
}
fn run_on(
socket: &Path,
request: Request,
json: bool,
stderr_is_terminal: bool,
) -> (i32, String, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = client::run(
socket,
@@ -129,12 +140,17 @@ fn run(socket: &Path, request: Request, json: bool) -> (i32, String, Vec<u8>) {
stdout: &mut stdout,
stderr: &mut stderr,
json,
stderr_is_terminal: false,
stderr_is_terminal,
},
);
(code, String::from_utf8(stdout).unwrap(), stderr)
}
/// A local path the app accepts: absolute on this system.
fn local(name: &str) -> PathBuf {
std::env::temp_dir().join(name)
}
fn id(session: &Session) -> String {
session.public_id.to_string()
}
@@ -284,7 +300,7 @@ fn with_the_switch_off_nothing_reaches_the_backend() {
fn without_the_app_the_command_says_it_is_not_running() {
let dir = tempfile::tempdir().unwrap();
let (code, _, stderr) = run(
&dir.path().join("cli.sock"),
&cli_endpoint(dir.path()),
Request::List { query: None },
true,
);
@@ -317,7 +333,7 @@ fn a_transfer_reports_its_summary_and_exits_1_when_items_failed() {
&fixture.socket,
Request::Upload {
session: id(&fixture.web),
source: "/tmp/dist".into(),
source: local("dist"),
destination: "~/dist".into(),
},
true,
@@ -328,7 +344,32 @@ fn a_transfer_reports_its_summary_and_exits_1_when_items_failed() {
assert_eq!(summary.failures, ["无法上传:permission denied"]);
assert_eq!(
fixture.backend.calls.lock().unwrap().as_slice(),
["upload web-01 /tmp/dist ~/dist"]
[format!("upload web-01 {} ~/dist", local("dist").display())]
);
}
#[test]
fn progress_is_rewritten_in_place_without_escape_sequences() {
let fixture = fixture();
let (code, _, stderr) = run_on(
&fixture.socket,
Request::Upload {
session: id(&fixture.web),
source: local("dist"),
destination: "~/dist".into(),
},
false,
true,
);
assert_eq!(code, 1);
let stderr = String::from_utf8(stderr).unwrap();
// Shown, then blanked with spaces before the failures are listed; the
// old Windows console would print an escape sequence as it is.
let line = "1/2 个文件,10 B / 20 B";
let blank = " ".repeat(unicode_width::UnicodeWidthStr::width(line));
assert_eq!(
stderr,
format!("\r{line}\r{blank}\r无法上传:permission denied\n")
);
}
@@ -352,9 +393,19 @@ fn the_socket_belongs_to_one_app_at_a_time_and_goes_with_it() {
let socket = fixture.socket.clone();
drop(fixture.server);
assert!(!socket.exists());
// A socket left behind by a crash is taken over.
std::fs::write(&socket, "").unwrap();
let (code, _, stderr) = run(&socket, Request::List { query: None }, true);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
#[cfg(unix)]
{
assert!(!socket.exists());
// A socket left behind by a crash is taken over.
std::fs::write(&socket, "").unwrap();
}
let restarted = CliServer::start(socket.clone(), Arc::new(FakeBackend::default()));
assert!(restarted.is_ok());
}
@@ -380,3 +431,47 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
// Split words are a mistake, not a longer command.
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
}
#[test]
fn a_command_reaches_the_remote_shell_with_unix_line_ends() {
assert_eq!(
normalize_command("\u{feff}cd /srv\r\nls -l\r\n"),
"cd /srv\nls -l\n"
);
assert_eq!(normalize_command("printf 'a\\r\\n'"), "printf 'a\\r\\n'");
assert_eq!(normalize_command("uname -a"), "uname -a");
}
/// What a Windows console gets for `bytes` written in the given pieces.
fn console_text(pieces: &[&[u8]]) -> String {
let mut text = ConsoleText {
inner: Vec::new(),
lossy: true,
pending: Vec::new(),
};
for piece in pieces {
io::Write::write_all(&mut text, piece).unwrap();
}
text.finish().unwrap();
String::from_utf8(text.inner).unwrap()
}
#[test]
fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
let word = "生产".as_bytes();
// A character cut in two by the frames it came in.
assert_eq!(console_text(&[&word[..2], &word[2..]]), "生产");
// GBK for 中, and a stray byte.
assert_eq!(
console_text(&[b"a\xd6\xd0b", b"\xff"]),
"a\u{fffd}\u{fffd}b\u{fffd}"
);
// Cut off for good at the end.
assert_eq!(console_text(&[b"ok", &word[..1]]), "ok\u{fffd}");
// Anywhere but a Windows console, bytes are passed on as they are.
let mut raw = ConsoleText::new(Vec::new(), cfg!(not(windows)));
io::Write::write_all(&mut raw, b"\xff\xd6").unwrap();
raw.finish().unwrap();
assert_eq!(raw.inner, b"\xff\xd6");
}
+8 -1
View File
@@ -1,3 +1,7 @@
// A release build on Windows is a GUI program, so opening it does not bring
// up a console window as well. Debug builds keep theirs for the logs.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
use gpui_kit::component::{Root, WindowExt as _, notification::Notification};
use gpui_kit::*;
@@ -5,7 +9,10 @@ use shellrs::session::{SessionDatabase, SessionStore};
use shellrs::settings::SettingsStore;
fn main() {
// `shellrs list`, `shellrs exec …`: the command, not the app.
// `shellrs list`, `shellrs exec …`: the command, not the app. Not in a
// Windows release build, which has nowhere to print: there the command
// is `shellrs-cli.exe`, which 设置 → 外部 CLI puts on the PATH.
#[cfg(any(not(windows), debug_assertions))]
if let Some(args) = shellrs::cli::command_line_arguments() {
std::process::exit(shellrs::cli::main(args));
}
+21 -1
View File
@@ -340,7 +340,12 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
)]),
SettingGroup::new()
.title("CLI 二进制")
.description("把 shellrs 命令加入 PATH,方便在终端和 Agent 中直接调用。")
.description(if cfg!(windows) {
"把 shellrs 命令加入当前用户的 PATH,方便在终端和 Agent 中直接调用。\
重新打开的终端和 Agent 才能找到它。"
} else {
"把 shellrs 命令加入 PATH,方便在终端和 Agent 中直接调用。"
})
.items([binary_item(bin_link, status.clone())]),
SettingGroup::new()
.title("Agent Skills")
@@ -388,6 +393,21 @@ fn binary_item(bin_link: Option<String>, status: Option<IntegrationStatus>) -> S
}),
None,
),
(Some(path), Some(BinaryStatus::Outdated)) => (
RowStatus::Warning(format!("已安装旧版本({path})")),
Some(("更新", true)),
),
(Some(path), Some(BinaryStatus::NotOnPath)) => (
RowStatus::Warning(format!("{path} 所在的文件夹不在 PATH 中")),
Some(("安装", true)),
),
(Some(_), Some(BinaryStatus::Unavailable)) => (
RowStatus::Warning(
"找不到 shellrs-cli.exe:它应在 ShellRS 程序旁边(开发时请先 cargo build)"
.into(),
),
None,
),
};
h_flex()
.w_full()
+2 -1
View File
@@ -9,7 +9,6 @@ use crate::{
use anyhow::{Context as _, Result, anyhow, bail};
use russh::keys::{
HashAlg, PrivateKeyWithHashAlg, PublicKey,
agent::client::AgentClient,
known_hosts::{known_host_keys_path, learn_known_hosts_path},
load_secret_key, parse_public_key_base64,
};
@@ -566,6 +565,8 @@ where
{
#[cfg(unix)]
{
use russh::keys::agent::client::AgentClient;
let Ok(mut agent) = AgentClient::connect_env().await else {
return Ok(None);
};
+2 -1
View File
@@ -187,7 +187,8 @@ impl Workspace {
cx,
);
this.cli_integration.update(cx, |integration, cx| {
integration.set_paths(IntegrationPaths::system(), cx)
integration.set_paths(IntegrationPaths::system(), cx);
integration.update_outdated(cx);
});
match CliServer::start(
crate::app::cli_socket_path(),
+146
View File
@@ -0,0 +1,146 @@
//! The `shellrs-cli` program run as an agent runs it: a separate process
//! with piped stdio, finding the app through `SHELLRS_DATA_DIR`. A fake
//! backend stands in for SSH, so this runs on every platform, including the
//! Windows CI where it is the one test of the named pipe between processes.
use std::{
io::{self, Write as _},
path::Path,
process::{Command, Output, Stdio},
sync::{Arc, Mutex},
};
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, SessionInfo, TransferCounters, TransferSummary,
};
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId};
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
/// command has read it all.
const LARGE_OUTPUT: usize = 256 * 1024;
#[derive(Default)]
struct FakeBackend {
commands: Mutex<Vec<String>>,
}
impl CliBackend for FakeBackend {
fn exec(
&self,
_: &Session,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
self.commands.lock().unwrap().push(command.to_string());
if command == "large" {
let line = [b'x'; 1023]
.iter()
.chain(b"\n")
.copied()
.collect::<Vec<_>>();
for _ in 0..LARGE_OUTPUT / line.len() {
output(ExecStream::Stdout, &line).unwrap();
}
return Ok(0);
}
output(ExecStream::Stdout, "输出\n".as_bytes()).unwrap();
output(ExecStream::Stderr, b"warning\n").unwrap();
Ok(7)
}
fn upload(
&self,
_: &Session,
_: &Path,
_: &str,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
fn download(
&self,
_: &Session,
_: &str,
_: &Path,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
}
/// Run the command against the app whose data lives in `data_dir`.
fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_shellrs-cli"))
.args(args)
.env("SHELLRS_DATA_DIR", data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin).unwrap();
child.wait_with_output().unwrap()
}
#[test]
fn without_the_app_the_command_says_so_and_exits_255() {
let data_dir = tempfile::tempdir().unwrap();
let output = shellrs(data_dir.path(), &["list"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
}
#[test]
fn the_command_finds_the_app_through_its_data_directory() {
let data_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
let web = Session::new(
SessionId(1),
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Auto, None),
);
server.set_targets(vec![CliTarget::new(&web, None)]);
server.set_enabled(true);
let id = web.public_id.to_string();
// A pipe for stdout means JSON.
let output = shellrs(data_dir.path(), &["list"], b"");
assert_eq!(output.status.code(), Some(0));
let sessions: Vec<SessionInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(sessions[0].id, id);
// What PowerShell pipes in: a BOM and CRLF line ends.
let output = shellrs(
data_dir.path(),
&["exec", &id, "--stdin"],
"\u{feff}cd /srv\r\nls\r\n".as_bytes(),
);
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stdout, "输出\n".as_bytes());
assert_eq!(output.stderr, b"warning\n");
assert_eq!(
backend.commands.lock().unwrap().last().unwrap(),
"cd /srv\nls\n"
);
// Everything arrives, though the app is done long before.
let output = shellrs(data_dir.path(), &["exec", &id, "large"], b"");
assert_eq!(output.status.code(), Some(0));
assert_eq!(output.stdout.len(), LARGE_OUTPUT);
server.set_enabled(false);
let output = shellrs(data_dir.path(), &["exec", &id, "true"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
}
+1
View File
@@ -2035,6 +2035,7 @@ fn agent_skills_install_where_each_agent_looks_and_come_off_again(cx: &mut TestA
home: root.path().join("home"),
bin_link: root.path().join("bin").join("shellrs"),
exe,
user_path: None,
};
let codex = paths.skill_file(AgentKind::Codex);
workspace.update(cx, |workspace, cx| {