diff --git a/.github/workflows/shells.yml b/.github/workflows/shells.yml new file mode 100644 index 0000000..c1d2781 --- /dev/null +++ b/.github/workflows/shells.yml @@ -0,0 +1,47 @@ +# `shellrs exec --terminal` against the shells it types into: bash 4.2 to +# 5.2, dash, busybox ash and a bash started as sh, each in a container on a +# real terminal (`terminal::run::tests::shells`). The Macs this is +# developed on run it only with Docker Desktop up. +# +# A push runs it when the run's code, the test helpers or the dependencies +# change; run it by hand otherwise (Actions › shells › Run workflow). +name: shells + +on: + push: + branches: ["**"] + paths: + - "src/terminal/**" + - "src/testing.rs" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - ".github/workflows/shells.yml" + workflow_dispatch: + +jobs: + shells: + runs-on: ubuntu-22.04 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + - name: Build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \ + libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev + # Pulled first, so that no test waits on a download. + - name: Images + run: | + for image in bash:4.2 bash:4.4 bash:5.0 bash:5.1 bash:5.2 \ + debian:stable-slim alpine:3 rockylinux:9; do + docker pull --quiet "$image" + done + # 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 + - run: cargo test --lib terminal::run -- --include-ignored diff --git a/CLAUDE.md b/CLAUDE.md index 5a889a4..714a74b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。 - 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。 - `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。 + - `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。 - **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。 - **右侧栏**:窗口最右一列竖排的工具切换。 - 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。 @@ -97,6 +98,7 @@ cargo test --lib # 只跑单元测试 cargo test --test ui # 只跑 UI 集成测试 cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试 cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑) +cargo test --lib terminal::run -- --include-ignored # exec --terminal 对 Docker 里的真实 shell(CI:shells.yml) cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认 cargo clippy --all-targets -- -D warnings # 必须无警告 cargo fmt --check @@ -324,6 +326,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。 - `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。 - UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。 +- **`exec --terminal`**(`terminal/run.rs`,纯逻辑,状态都在 `TerminalRun` 的锁里): + - 流程:引擎发 ^C → 请求线程等 500 ms(同时到的字节可能被 tty 随 ^C 冲掉)输入探测行 → 解析线程见到标记 P 就输入命令行 → 标记 B(上下文)、输出、标记 E(退出码)。时限在请求线程(`TerminalRun::drive`):P 5 秒(`terminal_busy`),B 15 秒(发 ^C 再报,可能被堡垒机命令过滤或复核拦下);执行中没有时限,每秒一个空 Stdout 帧,写失败就是调用方没了,发 ^C。 + - 标记是 OSC 6973 带每次随机的 NONCE(单独的参数,回显里的 `\033` 只是文字,伪造不了)。P 带 `readlink /proc/$PPID/exe`(去掉 ` (deleted)`;不用 `comm`,以 sh 名义启动的 bash 的 comm 是 sh)。E 由外层 sh 在管道之后打:`trap : INT` 挺过 ^C,状态经 fd 3 带出,被打断按 130 报,所以 ^C 后也一定有 E。 + - 命令按 `printf %b` 编码(控制字符、`'`、`\`、`!`、`"`、≥ 0x80 写成 `\0NNN`),打出的行里没有引号和控制字符,命令仍大体可读(审计、命令过滤看得见,所以不用 base64)。从不用 bracketed paste:命令行是在探测还在跑、tty 处于规范模式时预输入的,readline 那时没开 2004,一行最多 4095 字节,所以 bash、dash 上限 4000,busybox 的行编辑 1000。 + - 运行槽归运行时(`TerminalRuntime::run`):解析线程在 `notices.scan` 旁边喂字节,循环结束时报断开。服务端另有 `runs` 队列,工作区另起 100 ms 的 `serve_cli_runs`(不能排在逐条等改动的 `serve_cli` 后面)。 + - 真实 shell 的测试在 `run_tests.rs` 的 `shells`(`#[ignore]`,Docker 里 bash 4.2–5.2、dash、busybox、Rocky 的 sh),CI 是 `shells.yml`。 - **Windows**: - 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。 - `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。 diff --git a/docs/manual.md b/docs/manual.md index 0098712..4d120af 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -124,7 +124,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 在 设置 → 外部 CLI 中: -- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。 +- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据,还能在已打开的远程终端里输入命令(`exec --terminal`,包括已切到 root 的终端),都没有另外的开关。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。 - **CLI 二进制**:把 `shellrs` 放进 PATH。 - macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。 - Linux:链接到 `~/.local/bin/shellrs`。 @@ -139,6 +139,7 @@ shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是 shellrs hosts show [--json] # 一台主机的全部配置 shellrs exec "<命令>" # 或 --stdin 从标准输入读命令 shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON +shellrs exec --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文 shellrs upload <本地路径> <远程路径> shellrs download <远程路径> <本地路径> shellrs sync <本地目录> <远程目录> [--delete] @@ -148,6 +149,11 @@ shellrs credentials list | show | create | update | delete - `` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。 - `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。 +- `exec --terminal`(`exec --json` 里写 `"terminal": true`)不重新登录,而是把命令打进这台主机在 ShellRS 里已打开的远程终端,给只允许登录一次的堡垒机用(见「从堡垒机打开(外部连接)」)。它先发 Ctrl-C,打断终端里正在运行的程序、清掉输入行里没提交的内容,再输入一行探测和一行命令;这两行和命令的输出都显示在终端里。命令在终端当前的用户、目录和机器上执行(`su`、`sudo -i`、嵌套 `ssh` 之后也是),`--json` 的结果多一项 `context`(`user`、`host`、`cwd`、`shell`、`interp`)写明在哪里。命令的输出经管道,程序看不到终端:没有颜色和分页器,stderr 并进 stdout;命令没有标准输入,要密码的程序会停在终端里等。用哪个终端:右侧栏正对着的终端是这台主机的就用它,否则用这台主机最新的、已连接的终端。 + - 只支持 Linux 上的 bash、dash 和 busybox ash(否则报 `unsupported_shell`)。编码后的命令行,bash 和 dash 最多 4000 字节,busybox 最多 1000 字节(否则报 `too_long`)。 + - 以下情况报 `terminal_busy`,命令没有执行:全屏程序(vim、less、top、tmux 等)占着终端;上一条 `--terminal` 命令还没结束;5 秒内 shell 没有回应(前台程序不理 Ctrl-C,如 python、mysql);15 秒内 shell 没有开始执行(可能被堡垒机的命令过滤或复核拦下,这时会再发一次 Ctrl-C)。终端没打开或没连接报 `no_terminal`。 + - 执行过程中没有时限;命令行工具中途被结束时(如 Agent 超时),终端里的命令收到 Ctrl-C。 + - 那两行行首有空格,bash 设了 `ignorespace` 时不记进历史,否则会记下。停在不带提示的 `read -s`、或 vi 编辑模式的命令模式时认不出,会报「没有回应」。 - 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。 - `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。 - `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法: @@ -157,7 +163,7 @@ shellrs credentials list | show | create | update | delete - 临时连接和外部连接能 `show`,不能修改或删除。 - `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。 - 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。 -- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令)。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 +- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令),以及 `exec --terminal` 才有的 `no_terminal`、`terminal_busy`、`unsupported_shell`、`too_long`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 - `exec --json` 从标准输入读 `{"host": "", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。 - 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = ''; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 @@ -182,7 +188,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。 - macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。 - Linux:AppImage 本身。 -- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 +- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝,这时改用 `shellrs exec --terminal`,在外部连接已打开的终端里执行命令(见「外部 CLI」)。 - 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。 ## 标签页 @@ -439,7 +445,7 @@ ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径, 前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。 -`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。 +`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`exec --terminal`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。 - 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。 - Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。 diff --git a/locales/cli.yml b/locales/cli.yml index 0fba612..b1b8952 100644 --- a/locales/cli.yml +++ b/locales/cli.yml @@ -345,6 +345,46 @@ cli.change.failures: en: "%{before}; %{next}" # Running commands and moving files. +cli.exec.try_terminal: + zh-CN: "%{message}(堡垒机可能只允许登录一次:改用 shellrs exec --terminal,在 ShellRS 已打开的终端里执行)" + en: "%{message} (The bastion host may allow one login only: use shellrs exec --terminal to run the command in the terminal open in ShellRS.)" + +# `exec --terminal`: why the command could not be typed into the terminal, +# or did not finish there. +cli.terminal.timed_out: + zh-CN: ShellRS 没有及时处理这条命令,命令没有执行:请稍后再试 + en: ShellRS didn't get to the command in time, so it didn't run. Try again later. +cli.terminal.no_terminal: + zh-CN: 这台主机在 ShellRS 里没有打开的终端:请先在 ShellRS 里打开它的终端 + en: This host has no terminal open in ShellRS. Open its terminal in ShellRS first. +cli.terminal.not_connected: + zh-CN: 这台主机在 ShellRS 里的终端没有连接:请先在 ShellRS 里重新连接 + en: This host's terminal in ShellRS isn't connected. Reconnect it in ShellRS first. +cli.terminal.unknown_shell: + zh-CN: 认不出终端里的 shell:只支持 Linux 上的 bash、dash 和 busybox ash + en: Couldn't tell which shell the terminal runs. Only bash, dash and busybox ash on Linux are supported. +cli.terminal.unsupported_shell: + zh-CN: 终端里的 shell 是 %{shell}:只支持 bash、dash 和 busybox ash + en: The terminal runs %{shell}. Only bash, dash and busybox ash are supported. +cli.terminal.too_long: + zh-CN: 命令太长:这个终端里,编码后的命令行最多 %{limit} 字节。请拆成几条 + en: The command is too long. In this terminal the encoded command line takes %{limit} bytes at most; split it up. +cli.terminal.full_screen: + zh-CN: 终端里正在运行全屏程序(如 vim、less、top、tmux):请先在 ShellRS 里退出它 + en: A full-screen program (such as vim, less, top or tmux) has the terminal. Quit it in ShellRS first. +cli.terminal.another_run: + zh-CN: 这个终端里上一条 shellrs exec --terminal 命令还在执行 + en: The previous shellrs exec --terminal command is still running in this terminal. +cli.terminal.no_answer: + zh-CN: 终端里的 shell 没有回应:前台程序可能不响应 Ctrl-C(如 python、mysql),或者终端里不是 Linux 上的 bash、dash 或 busybox ash + en: The shell in the terminal didn't answer. The program in the foreground may ignore Ctrl-C (such as python or mysql), or the terminal doesn't run bash, dash or busybox ash on Linux. +cli.terminal.not_started: + zh-CN: shell 没有开始执行这条命令,可能被堡垒机的命令过滤或命令复核拦下了;已发送 Ctrl-C + en: The shell didn't start the command; the bastion host's command filter or review may have held it back. Ctrl-C was sent. +cli.terminal.disconnected: + zh-CN: 命令执行过程中终端断开了,命令可能已执行了一部分 + en: The terminal disconnected while the command ran; it may have partly run. + cli.exec.signal: zh-CN: 远程命令被信号 %{signal} 终止 en: The remote command was killed by signal %{signal}. diff --git a/src/analytics/model.rs b/src/analytics/model.rs index f37c668..14ea583 100644 --- a/src/analytics/model.rs +++ b/src/analytics/model.rs @@ -82,6 +82,7 @@ pub enum Counter { NoticeKeyword, /// A `shellrs` command served. CliExec, + CliExecTerminal, CliUpload, CliDownload, CliSync, @@ -95,7 +96,7 @@ pub enum Counter { } impl Counter { - pub const ALL: [Counter; 43] = [ + pub const ALL: [Counter; 44] = [ Self::Ssh, Self::SshJump, Self::SshProxy, @@ -130,6 +131,7 @@ impl Counter { Self::NoticeBell, Self::NoticeKeyword, Self::CliExec, + Self::CliExecTerminal, Self::CliUpload, Self::CliDownload, Self::CliSync, @@ -177,6 +179,7 @@ impl Counter { Self::NoticeBell => "notice_bell", Self::NoticeKeyword => "notice_keyword", Self::CliExec => "cli_exec", + Self::CliExecTerminal => "cli_exec_terminal", Self::CliUpload => "cli_upload", Self::CliDownload => "cli_download", Self::CliSync => "cli_sync", diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index 5d21417..462c8e7 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -32,6 +32,23 @@ shellrs exec --stdin <<'EOF' EOF ``` +## Hosts Opened From a Bastion Host + +A host listed with `"temporary": true` may have been opened by a bastion host's link that allows one login. Then `exec`, `upload` and `download`, which log in again, fail with `missing_credential` or `connect_failed`, and the message says so. Run the command in the host's terminal open in ShellRS instead: + +```bash +shellrs exec --terminal "" +shellrs exec --terminal --stdin <<'EOF' + +EOF +``` + +- It types into the user's own terminal tab, in sight of the user. It sends Ctrl-C first, which interrupts whatever runs there: use it only on a tab the user handed to you, not on one they are working in. +- The command runs where that terminal is now: its user, folder and machine, after any `su`, `sudo -i` or nested `ssh`. With `--json`, the result's `context` says where: `{"user", "host", "cwd", "shell", "interp"}`. +- stderr comes as stdout. One command at a time per terminal. Only bash, dash and busybox ash. +- Programs that ask for a password wait in the user's tab: use `sudo -n` and other non-interactive flags. Give your own tool a timeout; when the command is given up, it gets Ctrl-C. +- Files cannot be transferred this way; write a small text file with a heredoc in the command. + ## File Transfer ```bash @@ -94,6 +111,12 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new() '@ | shellrs exec --stdin ``` +For `--terminal`, add `terminal = $true`: + +```powershell +@{ host = ''; command = 'df -h'; terminal = $true } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json +``` + The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake. To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产): @@ -125,6 +148,10 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E - `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`). - `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message. - `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS. -- `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 host once in ShellRS and save the password. +- `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 host once in ShellRS and save the password. For a host opened from a bastion host, use `--terminal` (see above). - `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it. +- `no_terminal` (`--terminal`): the host has no connected terminal in ShellRS. Ask the user to open or reconnect it. +- `terminal_busy` (`--terminal`): a full-screen program has the terminal, another `--terminal` command still runs there, the shell did not answer, or it did not start the command (a bastion host's command filter may hold it back). Tell the user the message. +- `unsupported_shell` (`--terminal`): the terminal runs a shell other than bash, dash or busybox ash. Tell the user. +- `too_long` (`--terminal`): split the command into shorter ones. - `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/backend.rs b/src/cli/backend.rs index 63d2399..92f046b 100644 --- a/src/cli/backend.rs +++ b/src/cli/backend.rs @@ -145,15 +145,27 @@ impl CliBackend for SshCliBackend { ); Ok(signal_exit_code(&signal)) } - Err(error) => Err(CliError::new( - match error.kind { - ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown, - ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged, - ExecErrorKind::MissingCredential => ErrorCode::MissingCredential, - ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed, - }, - error.message, - )), + Err(error) => { + // A bastion host's link logs in once; its terminal is open. + let message = if target.login().shell_only + && matches!( + error.kind, + ExecErrorKind::MissingCredential | ExecErrorKind::Connect + ) { + t!("cli.exec.try_terminal", message = error.message).to_string() + } else { + error.message + }; + Err(CliError::new( + match error.kind { + ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown, + ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged, + ExecErrorKind::MissingCredential => ErrorCode::MissingCredential, + ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed, + }, + message, + )) + } } } diff --git a/src/cli/client.rs b/src/cli/client.rs index d1a0af2..c3998dc 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -15,7 +15,8 @@ use super::link::OpenLink; use super::protocol::{ AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode, FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request, - RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json, + RouteDetails, TerminalContext, TransferCounters, TransferSummary, parse_json, read_frame, + write_json, }; use crate::host::HostOs; use crate::i18n::{t, tn}; @@ -60,6 +61,9 @@ struct ExecResult<'a> { exit_code: i32, stdout: std::borrow::Cow<'a, str>, stderr: std::borrow::Cow<'a, str>, + /// Where an `exec --terminal` command ran. + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, } /// `value` as JSON in ASCII alone: every other character escaped, which @@ -233,6 +237,7 @@ where let mut progress = ProgressLine::default(); // What `exec --json` holds until the command ends. let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let mut context = None; loop { let Some((kind, payload)) = read_frame(&mut reader)? else { return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); @@ -286,11 +291,14 @@ where print_credential_deleted(&deleted, console)?; return Ok(0); } + // Only `--json` prints where the command ran. + Reply::Context(found) => context = Some(found), Reply::Exit { code } if console.exec_json => { let result = ExecResult { exit_code: code, stdout: String::from_utf8_lossy(&stdout), stderr: String::from_utf8_lossy(&stderr), + context: context.take(), }; writeln!(console.stdout, "{}", ascii_json(&result))?; return Ok(code); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 64af145..69e2927 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -40,13 +40,14 @@ pub use link::{OpenLink, link_arguments}; pub use manage::CliChange; pub(crate) use manage::{ CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets, - find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential, - plan_host, with_saved_credential_secrets, with_saved_passwords, + find_credential, find_host, find_saved_host, host_details, host_info, host_secrets, + plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords, }; pub use protocol::{ AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields, CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice, - Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary, + Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters, + TransferSummary, }; pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse}; @@ -135,9 +136,17 @@ enum Command { /// {"exit_code", "stdout", "stderr"} once the command ends, errors as /// {"error": {"code", "message"}}. The output is ASCII, everything /// else escaped: no shell quoting, and no console code page can - /// garble it. + /// garble it. {"terminal": true} is the same as --terminal. #[arg(long, conflicts_with_all = ["id", "command", "stdin"])] json: bool, + /// Type the command into the host's terminal open in ShellRS + /// instead of logging in again, for a bastion host that allows one + /// login. It sends Ctrl-C first, which interrupts whatever runs + /// there. Its stderr comes as stdout; with --json, the result also + /// has "context": the user, host, folder and shell it ran in. Bash, + /// dash and busybox ash only. + #[arg(long)] + terminal: bool, }, /// Copy a local file or folder to a saved host. /// @@ -326,22 +335,34 @@ fn request(command: Command, console: &mut Console) -> Result { command, stdin, json, + terminal, } => { // From here on, errors are JSON too. console.exec_json = json; - let (host, command) = if json { + let request = if json { exec_request(&read_stdin(console)?) .map_err(|message| console.error(ErrorCode::BadRequest, &message))? - } else if stdin { - (id.unwrap_or_default(), read_stdin(console)?) } else { - (id.unwrap_or_default(), command.unwrap_or_default()) + ExecJson { + host: id.unwrap_or_default(), + command: if stdin { + read_stdin(console)? + } else { + command.unwrap_or_default() + }, + terminal: false, + } }; - let command = normalize_command(&command); + let command = normalize_command(&request.command); if command.trim().is_empty() { return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty"))); } - Request::Exec { host, command } + let host = request.host; + if terminal || request.terminal { + Request::ExecInTerminal { host, command } + } else { + Request::Exec { host, command } + } } Command::Upload { id, @@ -514,24 +535,26 @@ fn read_stdin(console: &mut Console) -> Result { } /// What `exec --json` reads. -#[derive(serde::Deserialize)] +#[derive(Debug, PartialEq, serde::Deserialize)] struct ExecJson { host: String, command: String, + /// `--terminal`. + #[serde(default)] + terminal: bool, } -/// The host and command of an `exec --json` request. -fn exec_request(text: &str) -> Result<(String, String), String> { +/// What an `exec --json` request asks for. +fn exec_request(text: &str) -> Result { let text = text.strip_prefix('\u{feff}').unwrap_or(text); - let request: ExecJson = serde_json::from_str(text).map_err(|error| { + serde_json::from_str(text).map_err(|error| { t!( "cli.exec.bad_json", form = r#"{"host": ..., "command": ...}"#, error = error ) .to_string() - })?; - Ok((request.host, request.command)) + }) } /// A command as the remote shell should see it. PowerShell ends every line diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index 98144a6..c28d026 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -127,6 +127,14 @@ pub enum Request { host: String, command: String, }, + /// `exec --terminal`: type `command` into the host's open terminal + /// instead of logging in again, for a bastion host that allows one + /// login. Its output comes as stdout, then [`Reply::Context`], then + /// [`Reply::Exit`]. + ExecInTerminal { + host: String, + command: String, + }, /// `source` is absolute: the app's working directory is not the /// caller's. Upload { @@ -207,6 +215,8 @@ pub enum Reply { Exit { code: i32, }, + /// Where an `exec --terminal` command runs, once it started. + Context(TerminalContext), /// A host as it is now: shown, created or changed. Host(HostDetails), HostDeleted(HostDeleted), @@ -250,6 +260,19 @@ impl HostInfo { } } +/// Where an `exec --terminal` command ran: the terminal may have gone to +/// another user, folder or machine since it was opened. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalContext { + pub user: String, + pub host: String, + pub cwd: String, + /// The interactive shell the command was typed into. + pub shell: String, + /// What ran the command: `bash`, or `sh` where there is none. + pub interp: String, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TransferCounters { pub files: u64, @@ -296,6 +319,14 @@ pub enum ErrorCode { /// A change was made, but could not all be written to disk or the /// keychain. SaveFailed, + /// `exec --terminal`: the host has no connected terminal. + NoTerminal, + /// `exec --terminal`: the terminal could not take the command. + TerminalBusy, + /// `exec --terminal`: the shell is not bash, dash or busybox ash. + UnsupportedShell, + /// `exec --terminal`: the command line is longer than the shell takes. + TooLong, } impl ErrorCode { @@ -314,6 +345,10 @@ impl ErrorCode { ErrorCode::CredentialNotFound => "credential_not_found", ErrorCode::HostInUse => "host_in_use", ErrorCode::SaveFailed => "save_failed", + ErrorCode::NoTerminal => "no_terminal", + ErrorCode::TerminalBusy => "terminal_busy", + ErrorCode::UnsupportedShell => "unsupported_shell", + ErrorCode::TooLong => "too_long", } } } @@ -641,7 +676,14 @@ mod tests { #[test] fn error_codes_are_spelled_as_documented() { - for code in [ErrorCode::NotEnabled, ErrorCode::HostKeyUnknown] { + for code in [ + ErrorCode::NotEnabled, + ErrorCode::HostKeyUnknown, + ErrorCode::NoTerminal, + ErrorCode::TerminalBusy, + ErrorCode::UnsupportedShell, + ErrorCode::TooLong, + ] { assert_eq!( serde_json::to_string(&code).unwrap(), format!("\"{}\"", code.as_str()) diff --git a/src/cli/server.rs b/src/cli/server.rs index 6210d88..592b975 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -26,13 +26,16 @@ use super::manage::{ }; use super::protocol::{ CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo, - PROTOCOL_VERSION, Reply, Request, TransferCounters, TransferSummary, VersionOnly, parse_json, - read_frame, write_frame, write_json, + PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary, + VersionOnly, parse_json, read_frame, write_frame, write_json, }; use crate::host::{Host, HostLogin, HostStore, matches_query}; use crate::i18n::t; use crate::secrets::SecretRef; use crate::ssh::ExecStream; +use crate::terminal::{ + BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun, +}; /// Does what a CLI request asks. Every method blocks: each request has a /// thread of its own. @@ -159,6 +162,26 @@ const QUEUE_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(10) }; +/// How an `exec --terminal` run is paced; quick in the tests. +const RUN_TIMING: RunTiming = if cfg!(test) { + RunTiming { + settle: Duration::ZERO, + probe: Duration::from_millis(300), + start: Duration::from_millis(300), + tick: Duration::from_millis(20), + } +} else { + RunTiming::STANDARD +}; + +/// An `exec --terminal` run waiting for the app to find the host's +/// terminal. +struct PendingRun { + id: u64, + host: String, + run: RunHandle, +} + /// A change waiting for the app, with where its outcome goes. struct PendingChange { id: u64, @@ -189,6 +212,9 @@ struct Shared { /// Changes to hosts and credentials, oldest first, until the app takes /// them up: only the UI thread can change the store. changes: Mutex>, + /// `exec --terminal` runs, oldest first, until the app takes them up: + /// only the UI thread can reach a terminal. + runs: Mutex>, next_change: AtomicU64, /// What kinds of command were served, for 匿名使用统计, until the app /// takes them. @@ -200,6 +226,7 @@ struct Shared { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CliUse { Exec, + ExecTerminal, Upload, Download, Sync, @@ -211,6 +238,7 @@ impl CliUse { fn of(request: &Request) -> Option { match request { Request::Exec { .. } => Some(Self::Exec), + Request::ExecInTerminal { .. } => Some(Self::ExecTerminal), Request::Upload { .. } => Some(Self::Upload), Request::Download { .. } => Some(Self::Download), Request::Sync { .. } => Some(Self::Sync), @@ -249,6 +277,7 @@ impl CliServer { backend, activation: Mutex::new(None), changes: Mutex::new(VecDeque::new()), + runs: Mutex::new(VecDeque::new()), next_change: AtomicU64::new(0), usage: Mutex::new(Vec::new()), }); @@ -297,6 +326,17 @@ impl CliServer { .map(|pending| (pending.change, pending.reply)) } + /// The oldest `exec --terminal` run waiting, with the host it is for, + /// for the app to hand to that host's terminal, or say why it cannot. + pub fn take_run(&self) -> Option<(String, RunHandle)> { + self.shared + .runs + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front() + .map(|pending| (pending.host, pending.run)) + } + /// Whether ShellRS was opened again since this was last asked, and the /// links it was opened with, oldest first: `Some` and empty for a plain /// re-open. The request threads cannot reach the window, so the app asks @@ -323,14 +363,23 @@ impl CliServer { } impl Drop for CliServer { - /// The app is going: whatever waits for it to take up a change hears - /// that it never will. + /// The app is going: whatever waits for it to take up a change or a + /// run hears that it never will. fn drop(&mut self) { self.shared .changes .lock() .unwrap_or_else(|error| error.into_inner()) .clear(); + for pending in self + .shared + .runs + .lock() + .unwrap_or_else(|error| error.into_inner()) + .drain(..) + { + pending.run.fail(RunFailure::NotConnected); + } } } @@ -640,6 +689,10 @@ fn respond( })?; Ok(Reply::Exit { code }) } + Request::ExecInTerminal { host, command } => { + let target = find(shared, &host)?; + run_in_terminal(shared, writer, &target.info.id, command) + } Request::Upload { host, source, @@ -790,6 +843,105 @@ fn change(shared: &Shared, change: CliChange) -> Result { outcome.recv().unwrap_or_else(|_| Err(gone())) } +/// Hand a run to the app, which finds the host's terminal, and see it +/// through: the command's output as stdout, its context, its exit code. +/// Taken back, so certainly not typed, when the app does not take it up +/// in time. While the command prints nothing, an empty frame now and then +/// finds out whether the caller is still there; once it is not, the +/// command gets a ^C. +fn run_in_terminal( + shared: &Shared, + writer: &mut impl io::Write, + host: &str, + command: String, +) -> Result { + let (run, events) = TerminalRun::new(command); + let id = shared.next_change.fetch_add(1, Ordering::Relaxed); + shared + .runs + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_back(PendingRun { + id, + host: host.to_owned(), + run: run.handle(), + }); + let first = match events.recv_timeout(QUEUE_TIMEOUT) { + Ok(event) => event, + Err(_) => { + let mut runs = shared + .runs + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(index) = runs.iter().position(|pending| pending.id == id) { + runs.remove(index); + return Err(CliError::new( + ErrorCode::ConnectFailed, + t!("cli.terminal.timed_out"), + )); + } + drop(runs); + // Taken up just now: its answer is on the way. + events + .recv() + .map_err(|_| run_error(RunFailure::NotConnected, host))? + } + }; + if let RunEvent::Failed(failure) = first { + return Err(run_error(failure, host)); + } + let code = run + .drive(&events, RUN_TIMING, &mut |progress| match progress { + RunProgress::Context(context) => write_json( + writer, + &Reply::Context(TerminalContext { + user: context.user, + host: context.host, + cwd: context.cwd, + shell: context.shell, + interp: context.interp, + }), + ), + RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes), + RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]), + }) + .map_err(|failure| run_error(failure, host))?; + Ok(Reply::Exit { code }) +} + +/// Why a run did not run, in the CLI's words. +fn run_error(failure: RunFailure, host: &str) -> CliError { + let busy = |message| CliError::new(ErrorCode::TerminalBusy, message); + match failure { + RunFailure::HostNotFound => host_not_found(host), + RunFailure::NoTerminal => { + CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal")) + } + RunFailure::NotConnected => { + CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected")) + } + RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new( + ErrorCode::UnsupportedShell, + t!("cli.terminal.unknown_shell"), + ), + RunFailure::UnsupportedShell(shell) => CliError::new( + ErrorCode::UnsupportedShell, + t!("cli.terminal.unsupported_shell", shell = shell), + ), + RunFailure::TooLong { limit } => CliError::new( + ErrorCode::TooLong, + t!("cli.terminal.too_long", limit = limit), + ), + RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")), + RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")), + RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")), + RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")), + RunFailure::Disconnected => { + CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected")) + } + } +} + fn find(shared: &Shared, id: &str) -> Result { shared .targets diff --git a/src/cli/tests.rs b/src/cli/tests.rs index db6fbef..c3660f7 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -19,7 +19,7 @@ use super::protocol::{ }; use super::server::{CliBackend, CliServer, CliTarget, CliUse}; use super::{Cli, Command, CredentialsCommand, HostsCommand}; -use super::{ConsoleText, exec_request, normalize_command}; +use super::{ConsoleText, ExecJson, exec_request, normalize_command}; use crate::app::cli_endpoint; use crate::host::{ AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore, @@ -27,6 +27,7 @@ use crate::host::{ }; use crate::secrets::SecretRef; use crate::ssh::ExecStream; +use crate::terminal::{RunFailure, RunHandle, TerminalTransportCommand}; /// Records what it was asked and answers from a script. #[derive(Default)] @@ -632,6 +633,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() { assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err()); // Split words are a mistake, not a longer command. assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err()); + // Typed into the open terminal, in any of the forms. + assert!(matches!( + parse(&["shellrs", "exec", "ID", "--terminal", "ls"]), + Ok(Command::Exec { terminal: true, command: Some(command), .. }) if command == "ls" + )); + assert!(matches!( + parse(&["shellrs", "exec", "--json", "--terminal"]), + Ok(Command::Exec { + terminal: true, + json: true, + .. + }) + )); } #[test] @@ -680,14 +694,23 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() { #[test] fn exec_json_reads_the_host_and_the_command() { + let asked = |host: &str, command: &str, terminal| ExecJson { + host: host.into(), + command: command.into(), + terminal, + }; assert_eq!( exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"), - Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string())) + Ok(asked("ID", "echo \"hi\" | wc -c", false)) ); // Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII. assert_eq!( exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#), - Ok(("ID".to_string(), "grep 生产 a.log".to_string())) + Ok(asked("ID", "grep 生产 a.log", false)) + ); + assert_eq!( + exec_request(r#"{"host":"ID","command":"ls","terminal":true}"#), + Ok(asked("ID", "ls", true)) ); assert!(exec_request(r#"{"command":"ls"}"#).is_err()); assert!(exec_request("ls -la").is_err()); @@ -1110,3 +1133,165 @@ fn an_app_that_does_not_know_the_command_is_said_to_be_older() { .starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"), ); } + +/// The app's end of an `exec --terminal` run: wait for it to come in. +fn take_run(server: &CliServer) -> (String, RunHandle) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(taken) = server.take_run() { + return taken; + } + assert!(std::time::Instant::now() < deadline, "no run came in"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// A terminal taking `run` up, with a shell whose program is `shell`: it +/// answers the probe, then the command line with `output` and `code`, +/// slowly enough for the server to check on the caller meanwhile. +/// Everything typed, once nothing more is. +fn answer_run( + run: RunHandle, + shell: &'static str, + output: &'static str, + code: i32, +) -> std::thread::JoinHandle { + let (input, typed) = std::sync::mpsc::channel(); + assert!(run.accept(input)); + std::thread::spawn(move || { + let mut all = String::new(); + while let Ok(TerminalTransportCommand::Write(bytes)) = + typed.recv_timeout(std::time::Duration::from_millis(500)) + { + let line = String::from_utf8(bytes).unwrap(); + all.push_str(&line); + // ` sh -c '