From d6e1ed69ec22f4cff13291d93ddb620b42716337 Mon Sep 17 00:00:00 2001 From: Xuz Date: Fri, 25 Sep 2026 14:00:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=96=E9=83=A8=20CLI=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Windows=EF=BC=9A=E5=91=BD=E5=90=8D=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E3=80=81=E7=8B=AC=E7=AB=8B=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=20shellrs-cli=20=E4=B8=8E=E7=94=A8=E6=88=B7=20PATH=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .github/workflows/windows.yml | 27 +++ CLAUDE.md | 15 +- Cargo.lock | 2 + Cargo.toml | 25 +++ README.md | 12 +- src/app/mod.rs | 4 +- src/app/paths.rs | 54 ++++- src/bin/shellrs-cli.rs | 7 + src/cli/SKILL.md | 21 ++ src/cli/client.rs | 115 +++++++--- src/cli/install.rs | 169 +++++++++++---- src/cli/install_windows.rs | 374 ++++++++++++++++++++++++++++++++ src/cli/integration.rs | 19 +- src/cli/mod.rs | 105 ++++++++- src/cli/pipe_windows.rs | 263 ++++++++++++++++++++++ src/cli/protocol.rs | 3 +- src/cli/server.rs | 196 +++++++++++++---- src/cli/tests.rs | 111 +++++++++- src/main.rs | 9 +- src/settings/settings_panel.rs | 22 +- src/ssh/connection.rs | 3 +- src/workspace/workspace_view.rs | 3 +- tests/cli_bin.rs | 146 +++++++++++++ tests/ui.rs | 1 + 24 files changed, 1563 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 src/bin/shellrs-cli.rs create mode 100644 src/cli/install_windows.rs create mode 100644 src/cli/pipe_windows.rs create mode 100644 tests/cli_bin.rs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..702a642 --- /dev/null +++ b/.github/workflows/windows.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b59b26a..ba8fb3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`,写 `/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()`(`/cli.sock`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。 + - `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `/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..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`。 diff --git a/Cargo.lock b/Cargo.lock index 4e46280..1c26ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7531,6 +7531,8 @@ dependencies = [ "trash", "unicode-width", "uuid", + "windows-registry 0.6.1", + "windows-sys 0.61.2", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 0a6ff58..7610f68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 46e4fca..9c88869 100644 --- a/README.md +++ b/README.md @@ -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 <远程路径> <本地路径> - `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 --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 ## 标签页 diff --git a/src/app/mod.rs b/src/app/mod.rs index 334ef51..70699ef 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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::*; diff --git a/src/app/paths.rs b/src/app/paths.rs index 303714a..f1a9d4e 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -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 { /// 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 { 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")) + ); + } +} diff --git a/src/bin/shellrs-cli.rs b/src/bin/shellrs-cli.rs new file mode 100644 index 0000000..ccded6d --- /dev/null +++ b/src/bin/shellrs-cli.rs @@ -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())); +} diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index 9594020..dc76057 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -39,6 +39,26 @@ shellrs download /var/log/nginx ./logs/nginx shellrs download /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 +@' + +'@ | shellrs exec --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. diff --git a/src/cli/client.rs b/src/cli/client.rs index 08ce50b..a71d8a3 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -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 for Failure { } } -#[cfg(unix)] -fn exchange(socket: &Path, request: Request, console: &mut Console) -> Result { - 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::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 { + 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 { + Err(Failure::Refused("此系统暂不支持外部 CLI")) +} + +/// Send the request and print the replies until the last one. +fn talk(stream: S, request: Request, console: &mut Console) -> Result +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 { 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 Result { - 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<()> { diff --git a/src/cli/install.rs b/src/cli/install.rs index a35dfaa..ed40ceb 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -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, +} + +/// 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 { 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, }, + /// 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 { + 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!( diff --git a/src/cli/install_windows.rs b/src/cli/install_windows.rs new file mode 100644 index 0000000..f017861 --- /dev/null +++ b/src/cli/install_windows.rs @@ -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 { + metadata.modified().ok() +} + +fn on_path(paths: &IntegrationPaths) -> io::Result { + 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 { + 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 { + Ok(windows_registry::CURRENT_USER.create("Environment")?) + } + + pub(super) fn read() -> io::Result { + 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 = "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::>() + .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 = 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()); + } +} diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 8e71b84..b38d7b1 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -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.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.check(true, cx); + } + + fn check(&mut self, update: bool, cx: &mut Context) { 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); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index aff24c0..b3f0e60 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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) -> 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 { } 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 { }) } +/// 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 { std::path::absolute(&path).map_err(|error| { @@ -201,6 +221,75 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result { }) } +/// 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 { + inner: W, + lossy: bool, + /// The start of a character whose rest has not been written yet. + pending: Vec, +} + +impl ConsoleText { + 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 Write for ConsoleText { + fn write(&mut self, bytes: &[u8]) -> io::Result { + 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> { diff --git a/src/cli/pipe_windows.rs b/src/cli/pipe_windows.rs new file mode 100644 index 0000000..0315e9f --- /dev/null +++ b/src/cli/pipe_windows.rs @@ -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 { + let descriptor = SecurityDescriptor::current_user_only()?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() 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 { + 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 { + 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, +} + +impl CurrentUser { + fn read() -> io::Result { + 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::())]; + // 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::()).User.Sid } + } + + /// The SID as text, such as `S-1-5-21-…`. + fn sid_string(&self) -> io::Result { + 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 { + 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 { + text.encode_wide().chain(std::iter::once(0)).collect() +} diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index 2740c3f..5c26a9f 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -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 diff --git a/src/cli/server.rs b/src/cli/server.rs index 2333c10..7e0838e 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -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, - socket: PathBuf, - shutdown: watch::Sender, - listener: Option>, + _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) -> io::Result { + /// 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) -> io::Result { 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, + thread: Option>, +} + +#[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, - mut stop: watch::Receiver, -) -> io::Result> { +fn listen(socket: PathBuf, shared: Arc) -> io::Result { 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, _: watch::Receiver) -> io::Result> { - 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) { }); } +/// The listening thread, stopped when dropped. +#[cfg(windows)] +struct Listener { + pipe: PathBuf, + stop: Arc, + thread: Option>, +} + +#[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) -> io::Result { + 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) { + 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) -> io::Result { + 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", ), ); } diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 5727c10..d4ced74 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -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) { + run_on(socket, request, json, false) +} + +fn run_on( + socket: &Path, + request: Request, + json: bool, + stderr_is_terminal: bool, +) -> (i32, String, Vec) { 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) { 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"); +} diff --git a/src/main.rs b/src/main.rs index 35e4e57..e4a4720 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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)); } diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index df68690..93b34b3 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -340,7 +340,12 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec { )]), 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, status: Option) -> 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() diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index 10d8c32..0772b83 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -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); }; diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index ac295ee..d6a4f97 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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(), diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs new file mode 100644 index 0000000..10afdb3 --- /dev/null +++ b/tests/cli_bin.rs @@ -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>, +} + +impl CliBackend for FakeBackend { + fn exec( + &self, + _: &Session, + command: &str, + output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>, + ) -> Result { + self.commands.lock().unwrap().push(command.to_string()); + if command == "large" { + let line = [b'x'; 1023] + .iter() + .chain(b"\n") + .copied() + .collect::>(); + 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 { + Ok(TransferSummary::default()) + } + + fn download( + &self, + _: &Session, + _: &str, + _: &Path, + _: &mut dyn FnMut(TransferCounters) -> io::Result<()>, + ) -> Result { + 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 = 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]") + ); +} diff --git a/tests/ui.rs b/tests/ui.rs index 7a1b660..f0618d9 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -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| {