外部 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:
1 parent
9704a8cc20
commit
d6e1ed69ec
24 files changed
+1563
-143
No files matched your search
@@ -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
|
||||
@@ -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
@@ -7531,6 +7531,8 @@ dependencies = [
|
||||
"trash",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"windows-registry 0.6.1",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
+25
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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, ©)) {
|
||||
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
@@ -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
@@ -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>> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]")
|
||||
);
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user