Compare commits

..
6 Commits
Author SHA1 Message Date
XuzandClaude Opus 5.5 63d1e4281c 版本号改为 0.1.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:54 +08:00
XuzandClaude Opus 5.5 b834477bf4 终端加上滚动条:翻看回滚历史时出现在右侧,可拖动、点轨道跳转
用 gpui-kit 的 Scrollbar,配一个按回滚行数换算像素的 ScrollbarHandle
(terminal/scrollbar.rs)。拖动的回调没有 context,经引擎给出的
Scrollback 直接滚动终端,再由滚动条通知视图重画。滚动条盖在整个边距上,
滑块大多落在文字旁边;全屏程序没有回滚,不显示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:22 +08:00
XuzandClaude Opus 5.5 65ea32acfb 终端执行的单元测试:dash 作为 sh 时允许输出里多一行 Terminated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:44:07 +08:00
XuzandClaude Opus 5.5 2df67bc639 版本号改为 0.1.6-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:42 +08:00
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00
XuzandClaude Opus 5.5 3c937a9be2 README 去掉界面语言的说明
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:18:41 +08:00
30 changed files with 2649 additions and 54 deletions

No files matched your search

+47
View File
@@ -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
+13
View File
@@ -2,6 +2,19 @@
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.6]
### 新增
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
- 终端右侧显示滚动条,可拖动翻看历史输出。
## [0.1.6-beta.1]
### 新增
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
## [0.1.5]
### 新增
+8
View File
@@ -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。
Generated
+1 -1
View File
@@ -7738,7 +7738,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.5"
version = "0.1.6"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
-3
View File
@@ -28,9 +28,6 @@ ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
> [!NOTE]
> The interface is in English and Simplified Chinese. It follows the system language unless you choose one in Settings › Appearance › Language, and switches at once.
## Features
**Hosts**
-3
View File
@@ -28,9 +28,6 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
> [!NOTE]
> 界面有简体中文和英文,默认跟随系统语言,可在「设置 › 外观 › 界面语言」里改,改了立即生效。
## 功能
**主机管理**
+1
View File
@@ -193,6 +193,7 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
+11 -4
View File
@@ -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 <ID> [--json] # 一台主机的全部配置
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
shellrs exec <ID> --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文
shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
shellrs sync <ID> <本地目录> <远程目录> [--delete]
@@ -148,6 +149,11 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
- `<ID>` 是 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 <ID> | create | update <ID> | delete <ID>
- 临时连接和外部连接能 `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": "<ID>", "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 = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --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」)。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
@@ -193,6 +199,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
@@ -439,7 +446,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 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。
+40
View File
@@ -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}.
+4 -1
View File
@@ -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",
+28 -1
View File
@@ -32,6 +32,23 @@ shellrs exec <host-id> --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 <host-id> --terminal "<command>"
shellrs exec <host-id> --terminal --stdin <<'EOF'
<command>
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 <host-id> --stdin
```
For `--terminal`, add `terminal = $true`:
```powershell
@{ host = '<host-id>'; 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.
+21 -9
View File
@@ -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,
))
}
}
}
+9 -1
View File
@@ -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<TerminalContext>,
}
/// `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);
+39 -16
View File
@@ -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<Request, i32> {
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<String, i32> {
}
/// 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<ExecJson, String> {
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
+43 -1
View File
@@ -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())
+156 -4
View File
@@ -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<VecDeque<PendingChange>>,
/// `exec --terminal` runs, oldest first, until the app takes them up:
/// only the UI thread can reach a terminal.
runs: Mutex<VecDeque<PendingRun>>,
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<Self> {
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<Reply, CliError> {
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<Reply, CliError> {
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<CliTarget, CliError> {
shared
.targets
+188 -3
View File
@@ -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<String> {
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 '<script>' sh NONCE …`
let Some(at) = line.find("' sh ") else {
continue;
};
let nonce = &line[at + 5..at + 17];
if line.contains("6973;P;") {
run.feed(format!("\x1b]6973;P;{nonce};{shell}\x07").as_bytes());
} else if line.contains("6973;B;") {
let context: String = "deploy\nasset\n/home/deploy\nbash"
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect();
run.feed(format!("\x1b]6973;B;{nonce};{context}\x07").as_bytes());
std::thread::sleep(std::time::Duration::from_millis(100));
run.feed(format!("{output}\x1b]6973;E;{nonce};{code}\x07").as_bytes());
}
}
all
})
}
#[test]
fn a_command_typed_into_the_terminal_passes_on_its_output_and_where_it_ran() {
let fixture = fixture();
let exec = |json| {
let socket = fixture.socket.clone();
let request = Request::ExecInTerminal {
host: id(&fixture.web),
command: "ls /srv".into(),
};
std::thread::spawn(move || {
if json {
let (code, result, _) = run_exec_json(&socket, request);
(code, result.to_string())
} else {
let (code, stdout, _) = run(&socket, request, false);
(code, stdout)
}
})
};
let command = exec(false);
let (host, run) = take_run(&fixture.server);
assert_eq!(host, id(&fixture.web));
let terminal = answer_run(run, "/usr/bin/bash", "a\r\nb\r\n", 2);
assert_eq!(command.join().unwrap(), (2, "a\nb\n".to_string()));
assert!(terminal.join().unwrap().contains(" 'ls /srv'\r"));
let command = exec(true);
let (_, run) = take_run(&fixture.server);
answer_run(run, "/bin/busybox", "a\r\n", 0);
let (code, result) = command.join().unwrap();
assert_eq!(code, 0);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"exit_code": 0,
"stdout": "a\n",
"stderr": "",
"context": {
"user": "deploy",
"host": "asset",
"cwd": "/home/deploy",
"shell": "busybox",
"interp": "bash",
},
})
);
assert_eq!(
fixture.server.take_usage(),
[CliUse::ExecTerminal, CliUse::ExecTerminal]
);
}
#[test]
fn a_run_that_cannot_be_typed_says_why() {
let fixture = fixture();
let exec = || {
let socket = fixture.socket.clone();
let host = id(&fixture.web);
std::thread::spawn(move || {
let request = Request::ExecInTerminal {
host,
command: "true".into(),
};
let (code, _, stderr) = run(&socket, request, false);
(code, String::from_utf8(stderr).unwrap())
})
};
let refused = |command: std::thread::JoinHandle<(i32, String)>, code: &str| {
let (exit, stderr) = command.join().unwrap();
assert_eq!(exit, 255);
assert!(
stderr.starts_with(&format!("shellrs: [{code}] ")),
"{stderr}"
);
stderr
};
// No terminal for it.
let command = exec();
take_run(&fixture.server).1.fail(RunFailure::NoTerminal);
refused(command, "no_terminal");
// A shell it cannot type into: nothing but the probe was typed.
let command = exec();
let terminal = answer_run(take_run(&fixture.server).1, "/usr/bin/zsh", "", 0);
assert!(refused(command, "unsupported_shell").contains("zsh"));
assert!(!terminal.join().unwrap().contains("6973;B;"));
// Nothing answers the probe.
let command = exec();
let (input, _typed) = std::sync::mpsc::channel();
assert!(take_run(&fixture.server).1.accept(input));
refused(command, "terminal_busy");
// Nobody takes it up: taken back, never typed.
let command = exec();
refused(command, "connect_failed");
assert!(fixture.server.take_run().is_none());
// An unknown host never gets to the app.
let socket = fixture.socket.clone();
let request = Request::ExecInTerminal {
host: "nope".into(),
command: "true".into(),
};
assert_eq!(run(&socket, request, false).0, 255);
assert!(fixture.server.take_run().is_none());
}
+97
View File
@@ -30,6 +30,7 @@ use super::highlight::{
};
use super::links::{LinkMarker, url_search, visible_links};
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
use super::run::{BusyReason, RunFailure, RunHandle};
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
use super::{
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalColors, TerminalLifecycle,
@@ -62,6 +63,8 @@ pub struct TerminalSnapshot {
pub cursor_shape: CursorShape,
pub cursor_blinking: bool,
pub display_offset: usize,
/// The lines above the screen, which the view can scroll back through.
pub history_size: usize,
pub colors: [Option<Rgb>; COUNT],
/// The addresses of the links on screen, by the cells' `link`.
pub links: Vec<gpui_kit::SharedString>,
@@ -359,6 +362,11 @@ impl TerminalEngine {
cx.notify();
}
/// Scrolls the view without a context, for the scrollbar's drag.
pub fn scrollback(&self) -> Scrollback {
Scrollback(self.runtime.term.clone())
}
pub fn start_selection(
&mut self,
point: Point,
@@ -493,6 +501,32 @@ impl TerminalEngine {
self.runtime.exec(command)
}
/// Take up an `exec --terminal` run (see [`super::TerminalRun`]): ^C to
/// whatever has the terminal, the rest as the output comes. Refused
/// while a full-screen program has the screen, which ^C does not end
/// and the command line would be keys to, and while another run has
/// the terminal, which the ^C would end.
pub fn run_command(&self, run: RunHandle) -> Result<(), RunFailure> {
if !self.lifecycle.accepts_input() {
return Err(RunFailure::NotConnected);
}
if self.mode().contains(TermMode::ALT_SCREEN) {
return Err(RunFailure::Busy(BusyReason::FullScreen));
}
{
let mut slot = lock_run(&self.runtime.run);
if slot.as_ref().is_some_and(RunHandle::is_active) {
return Err(RunFailure::Busy(BusyReason::AnotherRun));
}
if !run.accept(self.runtime.commands.clone()) {
return Ok(());
}
*slot = Some(run);
}
self.send_user_input(b"\x03".to_vec());
Ok(())
}
pub fn status(&self) -> TerminalStatus {
TerminalStatus::new(
self.lifecycle.clone(),
@@ -561,12 +595,35 @@ impl TerminalEngine {
cursor_shape: content.cursor.shape,
cursor_blinking: term.cursor_style().blinking,
display_offset: content.display_offset,
history_size: term.grid().history_size(),
colors: std::array::from_fn(|index| content.colors[index]),
links: links.into_links(),
}
}
}
/// A terminal's view of its history, for the scrollbar, whose drag hands
/// over a position and no context: whoever draws the terminal is told to
/// redraw it. Taken for each frame, as a restart replaces the terminal.
#[derive(Clone)]
pub struct Scrollback(Arc<FairMutex<AlacrittyTerm>>);
impl Scrollback {
/// Show the history from `lines` below its oldest line.
pub fn scroll_to(&self, lines: usize) {
scroll_to(&mut self.0.lock(), lines);
}
}
/// Lines are counted from the top, which stays put while output arrives
/// below: the view stays where the pointer let it go.
fn scroll_to(term: &mut AlacrittyTerm, lines: usize) {
let grid = term.grid();
let offset = grid.history_size().saturating_sub(lines);
let delta = offset as i32 - grid.display_offset() as i32;
term.scroll_display(Scroll::Delta(delta));
}
/// The text of the line the cursor is on, or of the nearest line above it
/// on screen that has any: where a program that rings for an answer has
/// its question.
@@ -636,6 +693,9 @@ struct TerminalRuntime {
commands: mpsc::Sender<TerminalTransportCommand>,
size: Arc<Mutex<TerminalSize>>,
wakeup_pending: Arc<AtomicBool>,
/// The `exec --terminal` run this connection carries, which the parser
/// thread reads the output for. Given up with the connection.
run: Arc<Mutex<Option<RunHandle>>>,
}
impl TerminalRuntime {
@@ -662,6 +722,8 @@ impl TerminalRuntime {
let term = Arc::new(FairMutex::new(AlacrittyTerm::new(config, &size, proxy)));
let parser_term = term.clone();
let run: Arc<Mutex<Option<RunHandle>>> = Arc::default();
let parser_run = run.clone();
thread::Builder::new()
.name("shellrs-terminal-parser".into())
.spawn(move || {
@@ -711,6 +773,7 @@ impl TerminalRuntime {
for notice in notices.scan(&bytes) {
parser_proxy.send(TerminalUiEventKind::Notice(notice));
}
feed_run(&parser_run, &bytes);
parser_proxy.wakeup();
}
TerminalTransportEvent::HostOsDetected(os) => {
@@ -753,6 +816,9 @@ impl TerminalRuntime {
}
}
}
if let Some(run) = lock_run(&parser_run).take() {
run.disconnected();
}
})
.expect("terminal parser thread");
@@ -772,6 +838,7 @@ impl TerminalRuntime {
commands,
size: shared_size,
wakeup_pending,
run,
}
}
@@ -971,6 +1038,21 @@ fn receive<T>(events: &async_channel::Receiver<T>, deadline: Option<Instant>) ->
})
}
/// Hand output to the run the connection carries, and let it go once it
/// is over.
fn feed_run(run: &Mutex<Option<RunHandle>>, bytes: &[u8]) {
let mut slot = lock_run(run);
if let Some(handle) = slot.as_ref()
&& !handle.feed(bytes)
{
*slot = None;
}
}
fn lock_run(run: &Mutex<Option<RunHandle>>) -> std::sync::MutexGuard<'_, Option<RunHandle>> {
run.lock().unwrap_or_else(|error| error.into_inner())
}
/// Draw what an open synchronized update has held back, if its time is up.
/// Says whether it was.
fn end_overdue_sync(processor: &mut Processor, term: &mut AlacrittyTerm) -> bool {
@@ -1540,6 +1622,21 @@ mod tests {
assert_eq!(cells[2].search, SearchMark::None);
}
#[test]
fn the_scrollbar_counts_lines_from_the_top_of_the_history() {
let mut term = test_term(8, 2);
feed(&mut term, "1\r\n2\r\n3\r\n4\r\n5".as_bytes());
assert_eq!(term.grid().history_size(), 3);
scroll_to(&mut term, 0);
assert_eq!(term.grid().display_offset(), 3);
scroll_to(&mut term, 2);
assert_eq!(term.grid().display_offset(), 1);
// Past the bottom is the bottom.
scroll_to(&mut term, 10);
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn user_input_returns_to_bottom_and_clears_selection() {
let mut term = test_term(8, 2);
+6
View File
@@ -10,6 +10,8 @@ mod local_terminal_panel;
mod model;
mod mouse;
mod notices;
mod run;
mod scrollbar;
mod search;
mod terminal_panel;
mod terminal_view;
@@ -31,6 +33,10 @@ pub use model::{
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
};
pub use notices::TerminalNotice;
pub use run::{
BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming,
TerminalRun,
};
pub use search::{SearchDirection, SearchMark, SearchPosition};
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
pub(crate) use terminal_view::terminal_key_bindings;
+694
View File
@@ -0,0 +1,694 @@
//! 在已开的终端里执行: what `shellrs exec --terminal` types into an open
//! remote terminal, and how the result is read back out of its output.
//!
//! A bastion host that opened ShellRS with a link serves one login, and
//! ends the session when another channel opens on it (see
//! `HostLogin::shell_only`). An agent's command can then only reach the
//! host the way the user's own do: typed into the shell. A run goes:
//!
//! 1. ^C, which ends what runs in the foreground and empties the input
//! line, in bash, dash and busybox ash alike. Nothing checks first what
//! the terminal is doing: whoever hands a tab to an agent knows.
//! 2. A moment later, a probe, whose answer (marker P) says the shell
//! reads lines again and which shell it is.
//! 3. Once P comes, the command line. It prints marker B with where it
//! runs, then the command's output through a pipe, then marker E with
//! the exit code.
//!
//! The markers are an OSC sequence alacritty ignores, with the run's
//! nonce an argument of its own. The echo of a typed line has `\033` as
//! four characters, never an escape, so only the run's own `printf` makes
//! them.
use std::io;
use std::sync::{Arc, Mutex, MutexGuard, mpsc};
use std::time::{Duration, Instant};
use rand::{Rng as _, distr::Alphanumeric};
use super::TerminalTransportCommand;
/// What every marker starts with: an OSC number no terminal uses.
const MARKER: &[u8] = b"\x1b]6973;";
/// The longest marker waited for: room for a context whose directory is
/// as long as paths get, in hex.
const MARKER_LIMIT: usize = 16 * 1024;
const NONCE_LENGTH: usize = 12;
/// Prints P with the nonce and the program the interactive shell runs:
/// `sh -c`'s parent is that shell. Not `/proc/<pid>/comm`, which says `sh`
/// for a bash started as sh.
const PROBE_SCRIPT: &str =
r#"printf "\033]6973;P;%s;%s\007" "$1" "$(readlink /proc/$PPID/exe 2>/dev/null)""#;
/// Runs the command (`$2`, as `printf %b` reads it) with bash where there
/// is one and sh where not, its output through a pipe so that programs
/// see no terminal: no colors, no pager. B comes first with the user, the
/// host, the directory and the interpreter, in hex so that no path can
/// break the marker; E comes last, printed by this shell after the pipe.
/// It survives a ^C (`trap : INT`) while the command, in a subshell whose
/// trap is reset, does not; the status comes out on fd 3, and none means
/// the command was interrupted. No `'`, `!` or `\\` in it: it is typed in
/// single quotes.
const COMMAND_SCRIPT: &str = concat!(
r#"trap : INT; i=$(command -v bash || echo sh); "#,
r#"c=$(printf "%s\n%s\n%s\n%s" "$(id -un)" "$(uname -n)" "$PWD" "${i##*/}" "#,
r#"| od -An -tx1 2>/dev/null | tr -d " \n"); "#,
r#"printf "\033]6973;B;%s;%s\007" "$1" "$c"; exec 4>&1; "#,
r#"s=$( { { "$i" -c "$(printf %b "$2")" </dev/null 2>&1 3>&- 4>&-; printf %d $? >&3; } "#,
r#"| cat >&4; } 3>&1 ); "#,
r#"printf "\033]6973;E;%s;%s\007" "$1" "${s:-130}""#,
);
/// The interactive shells a command can be typed into.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunShell {
Bash,
Dash,
Busybox,
}
impl RunShell {
/// The shell whose program is `exe`, as `/proc/<pid>/exe` reads.
pub fn of(exe: &str) -> Option<Self> {
match program_name(exe) {
"bash" => Some(Self::Bash),
"dash" => Some(Self::Dash),
"busybox" => Some(Self::Busybox),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Dash => "dash",
Self::Busybox => "busybox",
}
}
/// The longest command line it takes, Enter included. The line is
/// typed ahead while the probe still runs, when the terminal holds a
/// line of 4095 bytes at most; busybox's own line editor holds 1024.
pub fn line_limit(self) -> usize {
match self {
Self::Busybox => 1000,
Self::Bash | Self::Dash => 4000,
}
}
}
/// The file name of `exe`, without the mark Linux adds once the program
/// was upgraded under a running shell.
fn program_name(exe: &str) -> &str {
let exe = exe.trim();
let exe = exe.strip_suffix(" (deleted)").unwrap_or(exe);
exe.rsplit('/').next().unwrap_or(exe)
}
/// Where the command ran.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RunContext {
pub user: String,
pub host: String,
pub cwd: String,
/// The interactive shell it was typed into.
pub shell: String,
/// What ran it: `bash`, or `sh` where there is no bash.
pub interp: String,
}
impl RunContext {
/// The context from marker B's hex: user, host, directory and
/// interpreter, a line each. A directory may hold newlines itself, so
/// it is what is left between the first two and the last.
fn read(hex: &[u8], shell: &str) -> Self {
let bytes: Vec<u8> = hex
.as_chunks::<2>()
.0
.iter()
.filter_map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
.collect();
let text = String::from_utf8_lossy(&bytes);
let mut context = Self {
shell: shell.to_owned(),
..Self::default()
};
let mut parts = text.splitn(3, '\n');
context.user = parts.next().unwrap_or_default().to_owned();
context.host = parts.next().unwrap_or_default().to_owned();
if let Some((cwd, interp)) = parts.next().unwrap_or_default().rsplit_once('\n') {
context.cwd = cwd.to_owned();
context.interp = interp.to_owned();
}
context
}
}
/// What a run tells the request it serves, in order.
#[derive(Debug, PartialEq, Eq)]
pub enum RunEvent {
/// A terminal took the run up and sent its ^C.
Accepted,
/// The shell answered the probe and the command line was typed.
Typed,
/// The command started.
Begun(RunContext),
Output(Vec<u8>),
/// The command ended with this exit code.
Exited(i32),
Failed(RunFailure),
}
/// Why a run did not run, or did not finish.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RunFailure {
HostNotFound,
/// The host has no terminal open.
NoTerminal,
/// Its terminals are not connected.
NotConnected,
/// The program of the interactive shell; empty when it could not be
/// read (no `/proc`).
UnsupportedShell(String),
TooLong {
limit: usize,
},
Busy(BusyReason),
/// The terminal went away after the command was typed: it may have
/// run, in part.
Disconnected,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BusyReason {
/// A full-screen program has the screen, which ^C does not end.
FullScreen,
/// Another run has the terminal.
AnotherRun,
/// Nothing answered the probe.
NoAnswer,
/// The shell did not start the command.
NotStarted,
}
/// How long each step of a run may take.
#[derive(Clone, Copy, Debug)]
pub struct RunTiming {
/// After the ^C, for the shell to show its prompt again: bytes coming
/// with the ^C may go with it.
pub settle: Duration,
pub probe: Duration,
/// From typing the command to its marker B. A bastion host's command
/// filter or review may hold it back.
pub start: Duration,
/// How often the caller hears of a run that prints nothing.
pub tick: Duration,
}
impl RunTiming {
pub const STANDARD: Self = Self {
settle: Duration::from_millis(500),
probe: Duration::from_secs(5),
start: Duration::from_secs(15),
tick: Duration::from_secs(1),
};
}
/// What a run hands its caller while it runs.
#[derive(Debug, PartialEq, Eq)]
pub enum RunProgress {
Context(RunContext),
Output(Vec<u8>),
/// Nothing happened for a tick.
Idle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
/// Waiting for a terminal to take it up.
Queued,
/// ^C sent.
Installed,
Probing,
Typed,
Begun,
Done,
Abandoned,
}
impl Phase {
fn is_active(self) -> bool {
matches!(
self,
Phase::Installed | Phase::Probing | Phase::Typed | Phase::Begun
)
}
}
struct RunState {
nonce: String,
command: String,
phase: Phase,
shell: Option<RunShell>,
scanner: RunScanner,
/// The terminal's input, once one took the run up.
input: Option<mpsc::Sender<TerminalTransportCommand>>,
events: mpsc::Sender<RunEvent>,
}
impl RunState {
fn type_bytes(&self, bytes: impl Into<Vec<u8>>) {
if let Some(input) = &self.input {
let _ = input.send(TerminalTransportCommand::Write(bytes.into()));
}
}
fn tell(&self, event: RunEvent) {
let _ = self.events.send(event);
}
fn fail(&mut self, failure: RunFailure) {
self.phase = Phase::Abandoned;
self.tell(RunEvent::Failed(failure));
}
/// The shell answered the probe: type the command, if it is one the
/// line can be typed into and the line fits.
fn probed(&mut self, exe: &str) {
let Some(shell) = RunShell::of(exe) else {
self.fail(RunFailure::UnsupportedShell(program_name(exe).to_owned()));
return;
};
let mut line = command_line(&self.nonce, &self.command);
line.push('\r');
if line.len() > shell.line_limit() {
self.fail(RunFailure::TooLong {
limit: shell.line_limit(),
});
return;
}
self.type_bytes(line);
self.shell = Some(shell);
self.phase = Phase::Typed;
self.tell(RunEvent::Typed);
}
}
/// One `exec --terminal` run, as the request it serves holds it. Dropping
/// it gives the run up, with a ^C when its command was typed.
pub struct TerminalRun {
state: Arc<Mutex<RunState>>,
}
/// The same run, as the terminal it runs in holds it.
#[derive(Clone)]
pub struct RunHandle {
state: Arc<Mutex<RunState>>,
}
fn lock(state: &Mutex<RunState>) -> MutexGuard<'_, RunState> {
state.lock().unwrap_or_else(|error| error.into_inner())
}
impl TerminalRun {
pub fn new(command: String) -> (Self, mpsc::Receiver<RunEvent>) {
let nonce: String = rand::rng()
.sample_iter(Alphanumeric)
.take(NONCE_LENGTH)
.map(char::from)
.collect();
let (events, receiver) = mpsc::channel();
let state = RunState {
scanner: RunScanner::new(&nonce),
nonce,
command,
phase: Phase::Queued,
shell: None,
input: None,
events,
};
(
Self {
state: Arc::new(Mutex::new(state)),
},
receiver,
)
}
pub fn handle(&self) -> RunHandle {
RunHandle {
state: self.state.clone(),
}
}
/// Type the probe, once the terminal has sent its ^C.
pub fn type_probe(&self) {
let mut state = lock(&self.state);
if state.phase == Phase::Installed {
let mut line = probe_line(&state.nonce);
line.push('\r');
state.type_bytes(line);
state.phase = Phase::Probing;
}
}
/// Give the run up for `reason`, unless it got past that meanwhile:
/// whether it was given up. A command typed and not started gets a ^C,
/// which also withdraws one a bastion host holds for review.
pub fn give_up(&self, reason: BusyReason) -> bool {
let mut state = lock(&self.state);
let given_up = match reason {
BusyReason::NotStarted => state.phase == Phase::Typed,
_ => matches!(
state.phase,
Phase::Queued | Phase::Installed | Phase::Probing
),
};
if given_up {
if state.phase == Phase::Typed {
state.type_bytes(b"\x03".to_vec());
}
state.phase = Phase::Abandoned;
}
given_up
}
/// Give the run up whatever it does: ^C to a command typed and not
/// finished.
pub fn cancel(&self) {
let mut state = lock(&self.state);
match state.phase {
Phase::Done | Phase::Abandoned => return,
Phase::Typed | Phase::Begun => state.type_bytes(b"\x03".to_vec()),
Phase::Queued | Phase::Installed | Phase::Probing => {}
}
state.phase = Phase::Abandoned;
}
/// See the run through once a terminal took it up (after
/// [`RunEvent::Accepted`]): probe, wait for the command, and hand what
/// it prints to `progress`, with [`RunProgress::Idle`] every quiet
/// tick. The exit code, or why there is none. When `progress` fails,
/// its reader is gone: the run is cancelled, and given up as
/// [`RunFailure::Disconnected`], which nobody hears.
pub fn drive(
&self,
events: &mpsc::Receiver<RunEvent>,
timing: RunTiming,
progress: &mut dyn FnMut(RunProgress) -> io::Result<()>,
) -> Result<i32, RunFailure> {
std::thread::sleep(timing.settle);
self.type_probe();
let mut deadline = Some((Instant::now() + timing.probe, BusyReason::NoAnswer));
loop {
let next = match events.recv_timeout(timing.tick) {
Ok(RunEvent::Accepted) => continue,
Ok(RunEvent::Typed) => {
deadline = Some((Instant::now() + timing.start, BusyReason::NotStarted));
continue;
}
Ok(RunEvent::Begun(context)) => {
deadline = None;
RunProgress::Context(context)
}
Ok(RunEvent::Output(bytes)) => RunProgress::Output(bytes),
Ok(RunEvent::Exited(code)) => return Ok(code),
Ok(RunEvent::Failed(failure)) => return Err(failure),
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Some((at, reason)) = deadline
&& Instant::now() >= at
{
if self.give_up(reason) {
return Err(RunFailure::Busy(reason));
}
// It got further just now: its event is on the way.
deadline = None;
}
RunProgress::Idle
}
// The run itself holds the sender.
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(RunFailure::Disconnected),
};
if progress(next).is_err() {
self.cancel();
return Err(RunFailure::Disconnected);
}
}
}
}
impl Drop for TerminalRun {
fn drop(&mut self) {
self.cancel();
}
}
impl RunHandle {
/// Take the run up in a terminal whose input is `input`; the ^C is the
/// terminal's to send. False when the request gave it up already.
pub fn accept(&self, input: mpsc::Sender<TerminalTransportCommand>) -> bool {
let mut state = lock(&self.state);
if state.phase != Phase::Queued {
return false;
}
state.input = Some(input);
state.phase = Phase::Installed;
state.tell(RunEvent::Accepted);
true
}
/// Say why no terminal can take the run up.
pub fn fail(&self, failure: RunFailure) {
let mut state = lock(&self.state);
if state.phase == Phase::Queued {
state.fail(failure);
}
}
/// Whether the run still has its terminal: one given up does not,
/// whatever its command still does.
pub(super) fn is_active(&self) -> bool {
lock(&self.state).phase.is_active()
}
/// Read the terminal's output for the run's markers and output;
/// whether the run goes on.
pub fn feed(&self, bytes: &[u8]) -> bool {
let mut state = lock(&self.state);
if !state.phase.is_active() {
return false;
}
for scanned in state.scanner.scan(bytes) {
match (scanned, state.phase) {
(Scanned::Probe(exe), Phase::Probing) => state.probed(&exe),
(Scanned::Begin(hex), Phase::Typed) => {
let shell = state.shell.map(RunShell::name).unwrap_or_default();
state.phase = Phase::Begun;
state.tell(RunEvent::Begun(RunContext::read(&hex, shell)));
}
(Scanned::Output(bytes), Phase::Begun) => state.tell(RunEvent::Output(bytes)),
(Scanned::End(code), Phase::Begun) => {
state.phase = Phase::Done;
state.tell(RunEvent::Exited(code));
}
_ => {}
}
}
state.phase.is_active()
}
/// The terminal's connection is gone.
pub(super) fn disconnected(&self) {
let mut state = lock(&self.state);
match state.phase {
Phase::Installed | Phase::Probing => state.fail(RunFailure::NotConnected),
Phase::Typed | Phase::Begun => state.fail(RunFailure::Disconnected),
Phase::Queued | Phase::Done | Phase::Abandoned => {}
}
}
}
/// The probe line, with a leading space so that bash with `ignorespace`
/// keeps it out of the history.
fn probe_line(nonce: &str) -> String {
format!(" sh -c '{PROBE_SCRIPT}' sh {nonce}")
}
/// The command line: `command` as [`encode_command`] writes it.
fn command_line(nonce: &str, command: &str) -> String {
format!(
" sh -c '{COMMAND_SCRIPT}' sh {nonce} '{}'",
encode_command(command.as_bytes())
)
}
/// `command` as `printf %b` reads it back, with nothing in it a shell's
/// line editor or single quotes would take for their own: control
/// characters, quotes, `\`, `!` (csh-style history) and every byte above
/// ASCII (Meta keys to readline) as `\0NNN`. The rest reads as it is, for
/// the user and a bastion host's command audit alike.
pub(super) fn encode_command(command: &[u8]) -> String {
let mut encoded = String::with_capacity(command.len());
for &byte in command {
if !(0x20..0x7f).contains(&byte) || matches!(byte, b'\'' | b'\\' | b'!' | b'"') {
encoded.push_str(&format!("\\0{byte:03o}"));
} else {
encoded.push(char::from(byte));
}
}
encoded
}
/// What the scanner found in the output, in order.
#[derive(Debug, PartialEq, Eq)]
enum Scanned {
/// Marker P, with the interactive shell's program.
Probe(String),
/// Marker B, with the context in hex.
Begin(Vec<u8>),
/// What the command printed, between B and E, with CRLF as LF.
Output(Vec<u8>),
End(i32),
}
/// Finds a run's markers in terminal output, however it is split into
/// chunks, and what the command printed between B and E.
struct RunScanner {
nonce: Vec<u8>,
/// What may still turn out to be (part of) a marker.
pending: Vec<u8>,
capturing: bool,
/// A CR the next chunk may make CRLF.
carriage_return: bool,
}
impl RunScanner {
fn new(nonce: &str) -> Self {
Self {
nonce: nonce.as_bytes().to_vec(),
pending: Vec::new(),
capturing: false,
carriage_return: false,
}
}
fn scan(&mut self, bytes: &[u8]) -> Vec<Scanned> {
let mut text = std::mem::take(&mut self.pending);
text.extend_from_slice(bytes);
let mut found = Vec::new();
let mut output = Vec::new();
let mut rest = text.as_slice();
loop {
let Some(at) = find(rest, MARKER) else {
let kept = partial_marker(rest);
self.capture(&rest[..rest.len() - kept], &mut output);
self.pending = rest[rest.len() - kept..].to_vec();
break;
};
self.capture(&rest[..at], &mut output);
let body = &rest[at + MARKER.len()..];
match terminator(body) {
Some((end, length)) => {
if let Some(marker) = self.marker(&body[..end], &mut output) {
if !output.is_empty() {
found.push(Scanned::Output(std::mem::take(&mut output)));
}
found.push(marker);
}
rest = &body[end + length..];
}
// Too long to be one: not a marker after all.
None if body.len() > MARKER_LIMIT => {
self.capture(&rest[at..at + MARKER.len()], &mut output);
rest = body;
}
None => {
self.pending = rest[at..].to_vec();
break;
}
}
}
if !output.is_empty() {
found.push(Scanned::Output(output));
}
found
}
/// Keep `bytes` when they are the command's, with CRLF as LF.
fn capture(&mut self, bytes: &[u8], output: &mut Vec<u8>) {
if !self.capturing {
return;
}
for &byte in bytes {
if std::mem::take(&mut self.carriage_return) && byte != b'\n' {
output.push(b'\r');
}
if byte == b'\r' {
self.carriage_return = true;
} else {
output.push(byte);
}
}
}
/// The marker `body` (after `ESC ] 6973 ;`) is, if it is this run's.
fn marker(&mut self, body: &[u8], output: &mut Vec<u8>) -> Option<Scanned> {
let mut fields = body.splitn(3, |byte| *byte == b';');
let (kind, nonce, payload) = (fields.next()?, fields.next()?, fields.next()?);
if nonce != self.nonce.as_slice() {
return None;
}
match kind {
b"P" => Some(Scanned::Probe(
String::from_utf8_lossy(payload).into_owned(),
)),
b"B" => {
self.capturing = true;
Some(Scanned::Begin(payload.to_vec()))
}
b"E" => {
if std::mem::take(&mut self.carriage_return) {
output.push(b'\r');
}
self.capturing = false;
// Never anything but digits; the run ends either way.
let code = std::str::from_utf8(payload)
.ok()
.and_then(|code| code.trim().parse().ok())
.unwrap_or(255);
Some(Scanned::End(code))
}
_ => None,
}
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
/// How many bytes at the end of `bytes` begin a marker.
fn partial_marker(bytes: &[u8]) -> usize {
(1..MARKER.len())
.rev()
.find(|&length| length <= bytes.len() && bytes.ends_with(&MARKER[..length]))
.unwrap_or(0)
}
/// Where an OSC body ends, and how long its terminator is: BEL or ST.
fn terminator(body: &[u8]) -> Option<(usize, usize)> {
body.iter()
.enumerate()
.find_map(|(index, byte)| match byte {
0x07 => Some((index, 1)),
0x1b if body.get(index + 1) == Some(&b'\\') => Some((index, 2)),
_ => None,
})
}
#[cfg(test)]
#[path = "run_tests.rs"]
mod tests;
+712
View File
@@ -0,0 +1,712 @@
use std::sync::mpsc;
use std::time::Duration;
use super::*;
/// Timing for runs whose terminal is a thread of the test.
const QUICK: RunTiming = RunTiming {
settle: Duration::ZERO,
probe: Duration::from_millis(200),
start: Duration::from_millis(200),
tick: Duration::from_millis(10),
};
fn nonce(run: &TerminalRun) -> String {
lock(&run.state).nonce.clone()
}
fn marker(kind: &str, nonce: &str, payload: &str) -> String {
format!("\x1b]6973;{kind};{nonce};{payload}\x07")
}
fn hex(text: &str) -> String {
text.bytes().map(|byte| format!("{byte:02x}")).collect()
}
/// What was typed into the terminal so far, as text.
fn typed(input: &mpsc::Receiver<TerminalTransportCommand>) -> String {
input
.try_iter()
.map(|command| match command {
TerminalTransportCommand::Write(bytes) => String::from_utf8(bytes).unwrap(),
_ => String::new(),
})
.collect()
}
/// A run a terminal took up, with the terminal's input.
fn accepted(
command: &str,
) -> (
TerminalRun,
mpsc::Receiver<RunEvent>,
mpsc::Receiver<TerminalTransportCommand>,
) {
let (run, events) = TerminalRun::new(command.to_owned());
let (input, typed) = mpsc::channel();
assert!(run.handle().accept(input));
assert_eq!(events.try_recv(), Ok(RunEvent::Accepted));
(run, events, typed)
}
/// What the scanner found, with the output of successive chunks joined.
fn joined(found: Vec<Scanned>) -> Vec<Scanned> {
let mut joined: Vec<Scanned> = Vec::new();
for scanned in found {
match (joined.last_mut(), scanned) {
(Some(Scanned::Output(before)), Scanned::Output(more)) => before.extend(more),
(_, scanned) => joined.push(scanned),
}
}
joined
}
#[test]
fn markers_are_found_however_the_output_is_split() {
let mut scanner = RunScanner::new("abc");
let begin = marker("B", "abc", "6869");
let mut found = scanner.scan(format!("$ echo\r\n{begin}out\r\nline2\r").as_bytes());
found.extend(scanner.scan(b"\n\x1b]69"));
found.extend(scanner.scan(b"73;E;abc;3\x07$ "));
assert_eq!(
joined(found),
vec![
Scanned::Begin(b"6869".to_vec()),
Scanned::Output(b"out\nline2\n".to_vec()),
Scanned::End(3),
]
);
// After E, the prompt is not the command's.
assert!(scanner.scan(b"more\r\n").is_empty());
}
#[test]
fn only_this_runs_markers_count_and_either_terminator_ends_them() {
let mut scanner = RunScanner::new("abc");
let found = scanner.scan(
format!(
"{}{}\x1b]6973;P;abc;/usr/bin/bash\x1b\\",
marker("P", "other", "/bin/zsh"),
marker("E", "abc", "oops"),
)
.as_bytes(),
);
assert_eq!(
found,
vec![Scanned::End(255), Scanned::Probe("/usr/bin/bash".into())]
);
// The echo of a typed line has the escape as text.
assert!(
scanner
.scan(br#" sh -c 'printf "\033]6973;P;%s" "$1"' sh abc"#)
.is_empty()
);
}
#[test]
fn a_lone_carriage_return_stays_and_crlf_becomes_lf() {
let mut scanner = RunScanner::new("n");
let found = scanner.scan(
format!(
"{}50%\r100%\r\n{}",
marker("B", "n", ""),
marker("E", "n", "0")
)
.as_bytes(),
);
assert_eq!(found[1], Scanned::Output(b"50%\r100%\n".to_vec()));
}
#[test]
fn shells_are_told_by_their_program() {
assert_eq!(RunShell::of("/usr/bin/bash"), Some(RunShell::Bash));
// Upgraded under the running shell.
assert_eq!(
RunShell::of("/usr/bin/bash (deleted)"),
Some(RunShell::Bash)
);
assert_eq!(RunShell::of("/usr/bin/dash"), Some(RunShell::Dash));
assert_eq!(RunShell::of("/bin/busybox\n"), Some(RunShell::Busybox));
assert_eq!(RunShell::of("/usr/bin/zsh"), None);
assert_eq!(RunShell::of(""), None);
assert_eq!(program_name("/tmp/mysh"), "mysh");
assert_eq!(RunShell::Busybox.line_limit(), 1000);
assert_eq!(RunShell::Dash.line_limit(), 4000);
}
#[test]
fn the_context_keeps_a_directory_with_newlines_whole() {
let context = RunContext::read(hex("root\nweb-01\n/srv/a\nb\nbash").as_bytes(), "dash");
assert_eq!(
context,
RunContext {
user: "root".into(),
host: "web-01".into(),
cwd: "/srv/a\nb".into(),
shell: "dash".into(),
interp: "bash".into(),
}
);
// `od` missing: nothing known, the run goes on.
assert_eq!(RunContext::read(b"", "bash").user, "");
}
#[test]
fn a_run_probes_then_types_its_command_and_reads_its_output() {
let (run, events, input) = accepted("uname -a");
let handle = run.handle();
let nonce = nonce(&run);
run.type_probe();
let probe = typed(&input);
assert!(probe.starts_with(" sh -c '") && probe.ends_with(&format!(" sh {nonce}\r")));
assert!(handle.feed(marker("P", &nonce, "/usr/bin/bash").as_bytes()));
assert_eq!(events.try_recv(), Ok(RunEvent::Typed));
let command = typed(&input);
assert!(
command.ends_with(&format!(" sh {nonce} 'uname -a'\r")),
"{command}"
);
let context = hex("root\nweb-01\n/root\nbash");
let output = format!(
"{}Linux\r\n{}$ ",
marker("B", &nonce, &context),
marker("E", &nonce, "0")
);
assert!(!handle.feed(output.as_bytes()));
let RunEvent::Begun(context) = events.try_recv().unwrap() else {
panic!("not begun");
};
assert_eq!(
(context.user.as_str(), context.shell.as_str()),
("root", "bash")
);
assert_eq!(events.try_recv(), Ok(RunEvent::Output(b"Linux\n".to_vec())));
assert_eq!(events.try_recv(), Ok(RunEvent::Exited(0)));
// Finished: dropping it sends nothing.
drop(run);
assert_eq!(typed(&input), "");
}
#[test]
fn an_answer_after_the_run_was_given_up_types_nothing() {
let (run, events, input) = accepted("rm -rf build");
let handle = run.handle();
run.type_probe();
typed(&input);
assert!(run.give_up(BusyReason::NoAnswer));
assert!(!handle.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes()));
assert_eq!(typed(&input), "");
assert!(events.try_recv().is_err());
assert!(!handle.is_active());
}
#[test]
fn only_a_typed_command_gets_a_ctrl_c() {
let (run, _events, input) = accepted("true");
run.type_probe();
typed(&input);
// Probing: the probe is harmless where it waits.
assert!(!run.give_up(BusyReason::NotStarted));
run.cancel();
assert_eq!(typed(&input), "");
let (run, _events, input) = accepted("sleep 9");
run.type_probe();
run.handle()
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
typed(&input);
drop(run);
assert_eq!(typed(&input), "\x03");
}
#[test]
fn a_shell_other_than_the_three_or_a_line_too_long_types_nothing() {
let (run, events, input) = accepted("true");
run.type_probe();
typed(&input);
run.handle()
.feed(marker("P", &nonce(&run), "/usr/bin/zsh").as_bytes());
assert_eq!(
events.try_recv(),
Ok(RunEvent::Failed(RunFailure::UnsupportedShell("zsh".into())))
);
assert_eq!(typed(&input), "");
let (run, events, input) = accepted(&"x".repeat(1000));
run.type_probe();
typed(&input);
run.handle()
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
assert_eq!(
events.try_recv(),
Ok(RunEvent::Failed(RunFailure::TooLong { limit: 1000 }))
);
assert_eq!(typed(&input), "");
}
#[test]
fn a_terminal_gone_says_whether_the_command_may_have_run() {
let (run, events, _input) = accepted("true");
run.type_probe();
run.handle().disconnected();
assert_eq!(
events.try_recv(),
Ok(RunEvent::Failed(RunFailure::NotConnected))
);
let (run, events, _input) = accepted("true");
run.type_probe();
run.handle()
.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes());
events.try_recv().unwrap();
run.handle().disconnected();
assert_eq!(
events.try_recv(),
Ok(RunEvent::Failed(RunFailure::Disconnected))
);
}
/// How a fake shell answers the command line.
#[derive(Clone, Copy)]
enum Answer {
/// B, this output and E with 7.
Runs(&'static str),
/// B alone: still running.
Starts,
/// Nothing: held back.
Nothing,
}
/// Answers a run's lines the way a shell would, on a thread: P at once,
/// then the command line as `answer` says. Stops once nothing is typed
/// for a while (the run's handle keeps the input open); everything typed.
fn fake_shell(
run: &TerminalRun,
input: mpsc::Receiver<TerminalTransportCommand>,
answer: Answer,
) -> std::thread::JoinHandle<String> {
let (handle, nonce) = (run.handle(), nonce(run));
std::thread::spawn(move || {
let mut all = String::new();
while let Ok(TerminalTransportCommand::Write(bytes)) =
input.recv_timeout(Duration::from_millis(500))
{
let line = String::from_utf8(bytes).unwrap();
all.push_str(&line);
let reply = if line.contains("6973;P;") {
marker("P", &nonce, "/usr/bin/bash")
} else if line.contains("6973;B;") {
match answer {
Answer::Runs(output) => format!(
"{}{output}{}",
marker("B", &nonce, ""),
marker("E", &nonce, "7")
),
Answer::Starts => marker("B", &nonce, ""),
Answer::Nothing => continue,
}
} else {
continue;
};
handle.feed(reply.as_bytes());
}
all
})
}
#[test]
fn driving_a_run_passes_its_output_on_and_ends_with_its_code() {
let (run, events, input) = accepted("make");
let shell = fake_shell(&run, input, Answer::Runs("built\r\n"));
let mut progress = Vec::new();
let code = run.drive(&events, QUICK, &mut |step| {
if step != RunProgress::Idle {
progress.push(step);
}
Ok(())
});
assert_eq!(code, Ok(7));
assert!(matches!(progress[0], RunProgress::Context(_)));
assert_eq!(progress[1], RunProgress::Output(b"built\n".to_vec()));
drop(run);
assert!(!shell.join().unwrap().contains('\x03'));
}
#[test]
fn a_run_nobody_answers_or_that_never_starts_is_busy() {
let (run, events, _input) = accepted("true");
assert_eq!(
run.drive(&events, QUICK, &mut |_| Ok(())),
Err(RunFailure::Busy(BusyReason::NoAnswer))
);
// Held back by a command filter: withdrawn with ^C.
let (run, events, input) = accepted("reboot");
let shell = fake_shell(&run, input, Answer::Nothing);
assert_eq!(
run.drive(&events, QUICK, &mut |_| Ok(())),
Err(RunFailure::Busy(BusyReason::NotStarted))
);
drop(run);
assert!(shell.join().unwrap().ends_with('\x03'));
}
#[test]
fn a_caller_gone_cancels_the_command() {
let (run, events, input) = accepted("sleep 100");
let shell = fake_shell(&run, input, Answer::Starts);
let mut begun = false;
let result = run.drive(&events, QUICK, &mut |step| match step {
RunProgress::Context(_) => {
begun = true;
Ok(())
}
RunProgress::Idle if begun => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
_ => Ok(()),
});
assert_eq!(result, Err(RunFailure::Disconnected));
drop(run);
assert!(shell.join().unwrap().ends_with('\x03'));
}
#[test]
fn the_encoded_command_has_nothing_a_line_editor_or_quotes_would_take() {
let encoded = encode_command("echo 'it''s' \"$HOME\" a\\b !x\t中\n".as_bytes());
assert!(encoded.bytes().all(|byte| (0x20..0x7f).contains(&byte)));
assert!(!encoded.contains(['\'', '"', '!']));
assert!(!encoded.contains("\\\\"));
assert!(encoded.starts_with("echo \\0047it"), "{encoded}");
assert!(encoded.contains("$HOME"));
}
/// The scripts run for real by this machine's `sh`.
#[cfg(unix)]
mod sh {
use super::*;
use crate::testing::{sh, sh_accepts};
#[test]
fn the_fixed_scripts_are_plain_sh() {
assert!(sh_accepts(PROBE_SCRIPT));
assert!(sh_accepts(COMMAND_SCRIPT));
for script in [PROBE_SCRIPT, COMMAND_SCRIPT] {
assert!(!script.contains(['\'', '!']) && !script.contains("\\\\"));
}
}
#[test]
fn printf_b_reads_every_byte_back() {
let bytes: Vec<u8> = (0..=255).collect();
let output = sh(
&format!("printf %b '{}' | od -An -tx1", encode_command(&bytes)),
None,
);
let dumped: String = String::from_utf8(output.stdout)
.unwrap()
.split_whitespace()
.collect();
let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
assert_eq!(dumped, expected);
}
/// The command line as the interactive shell would run it, its output
/// read back by the scanner.
fn run_line(command: &str) -> (Vec<Scanned>, String) {
let nonce = "Nonce0123456";
let line = command_line(nonce, command);
let output = sh(line.trim_start(), None);
let mut scanner = RunScanner::new(nonce);
(scanner.scan(&output.stdout), line)
}
#[test]
fn the_command_line_runs_any_command_and_reports_its_status() {
let (found, _) =
run_line("printf '%s\\n' \"it's\" 'a\\b' '$HOME' '!x' 中文; echo err >&2; exit 3");
let [
Scanned::Begin(context),
Scanned::Output(output),
Scanned::End(code),
] = found.as_slice()
else {
panic!("{found:?}");
};
assert_eq!(
String::from_utf8_lossy(output),
"it's\na\\b\n$HOME\n!x\n中文\nerr\n"
);
assert_eq!(*code, 3);
let context = RunContext::read(context, "bash");
assert!(!context.user.is_empty() && context.cwd.starts_with('/'));
assert!(["bash", "sh"].contains(&context.interp.as_str()));
// Several lines, and a command killed by a signal.
let (found, _) = run_line("cd /\npwd\nkill -TERM $$");
// dash, as the sh that waits for it, also says "Terminated" there.
let Scanned::Output(output) = &found[1] else {
panic!("{found:?}");
};
assert!(output.starts_with(b"/\n"), "{found:?}");
assert_eq!(found[2], Scanned::End(143));
let (found, _) = run_line("kill -INT $$");
assert_eq!(found.last(), Some(&Scanned::End(130)));
}
}
/// The run against real shells in containers, on a real terminal: what
/// the tests above cannot show. Needs Docker and the images; run with
/// `cargo test --lib terminal::run -- --ignored` (CI: `shells.yml`).
#[cfg(unix)]
mod shells {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use super::*;
use crate::testing::{Pty, pty};
/// A shell in a container, waited for until it shows its prompt.
fn start(image: &str, shell: &[&str]) -> Pty {
let mut argv = vec!["docker", "run", "--rm", "-i", "-t", image];
argv.extend_from_slice(shell);
let pty = pty(&argv);
pty.output
.recv_timeout(Duration::from_secs(60))
.expect("the shell starts");
settle(&pty);
pty
}
/// Wait until the terminal has been quiet a while.
fn settle(pty: &Pty) {
while pty.output.recv_timeout(Duration::from_millis(500)).is_ok() {}
}
struct Outcome {
result: Result<i32, RunFailure>,
output: String,
context: Option<RunContext>,
}
/// Run `command` the way the app does: ^C, then the run. `interrupt`
/// presses ^C that long after the command started, as the user would.
fn run(pty: Pty, command: &str, interrupt: Option<Duration>) -> (Pty, Outcome) {
let (run, events) = TerminalRun::new(command.to_owned());
let handle = run.handle();
let (input, typed) = mpsc::channel();
assert!(handle.accept(input.clone()));
input
.send(TerminalTransportCommand::Write(b"\x03".to_vec()))
.unwrap();
let done = Arc::new(AtomicBool::new(false));
let pump = {
let done = done.clone();
let handle = handle.clone();
std::thread::spawn(move || {
let mut pty = pty;
let mut begun_at = None;
while !done.load(Ordering::Acquire) {
while let Ok(TerminalTransportCommand::Write(bytes)) = typed.try_recv() {
pty.write(&bytes);
}
if let Ok(bytes) = pty.output.recv_timeout(Duration::from_millis(10)) {
let was_begun = lock(&handle.state).phase == Phase::Begun;
handle.feed(&bytes);
if !was_begun && lock(&handle.state).phase == Phase::Begun {
begun_at = Some(Instant::now());
}
}
if let (Some(after), Some(at)) = (interrupt, begun_at)
&& at.elapsed() >= after
{
pty.write(b"\x03");
begun_at = None;
}
}
pty
})
};
let (mut output, mut context) = (Vec::new(), None);
let timing = RunTiming {
probe: Duration::from_secs(10),
tick: Duration::from_millis(100),
..RunTiming::STANDARD
};
let result = run.drive(&events, timing, &mut |step| {
match step {
RunProgress::Context(found) => context = Some(found),
RunProgress::Output(bytes) => output.extend(bytes),
RunProgress::Idle => {}
}
Ok(())
});
drop(run);
done.store(true, Ordering::Release);
let pty = pump.join().unwrap();
settle(&pty);
let output = String::from_utf8(output).unwrap();
(
pty,
Outcome {
result,
output,
context,
},
)
}
fn dump(text: &str) -> String {
format!("od -An -tx1 <<'SHELLRS_END'\n{text}\nSHELLRS_END")
}
/// Every ASCII character but NUL (which no shell string holds),
/// characters whose UTF-8 has every kind of lead and continuation
/// byte, and random ones, through a heredoc into `od`: in as many runs
/// as the shell's line takes.
fn round_trip(mut pty: Pty, limit: usize) -> Pty {
let mut text: Vec<char> = (1..=127_u8).map(char::from).collect();
text.extend("\u{80}\u{7ff}\u{800}中文\u{ffff}\u{10000}😀\u{10ffff}".chars());
let mut random = rand::rng();
text.extend(
std::iter::repeat_with(|| random.random::<char>())
.filter(|character| *character != '\0')
.take(100),
);
let fits = |piece: &str| command_line("Nonce0123456", &dump(piece)).len() < limit;
let mut rest = text.as_slice();
while !rest.is_empty() {
let length = (1..=rest.len())
.take_while(|&length| fits(&rest[..length].iter().collect::<String>()))
.last()
.expect("one character fits");
let piece: String = rest[..length].iter().collect();
rest = &rest[length..];
let (next, outcome) = run(pty, &dump(&piece), None);
pty = next;
assert_eq!(outcome.result, Ok(0), "{piece:?}: {}", outcome.output);
let dumped: String = outcome.output.split_whitespace().collect();
assert_eq!(dumped, hex(&format!("{piece}\n")), "{piece:?}");
}
pty
}
/// The longest command that fits `limit`, and one byte more.
fn at_limit(pty: Pty, limit: usize) -> Pty {
let fill = |length: usize| format!("echo {}", "x".repeat(length));
let base = command_line("Nonce0123456", &fill(0)).len() + 1;
let (pty, outcome) = run(pty, &fill(limit - base), None);
assert_eq!(outcome.result, Ok(0));
assert_eq!(outcome.output.trim_end().len(), limit - base);
let (pty, outcome) = run(pty, &fill(limit - base + 1), None);
assert_eq!(outcome.result, Err(RunFailure::TooLong { limit }));
pty
}
/// Whatever has the terminal, ^C hands it back.
fn takes_over(mut pty: Pty) -> Pty {
for busy in [
"sleep 100\r",
"cat\r",
"read -p 'Password: ' secret\r",
"echo partial",
] {
pty.write(busy.as_bytes());
settle(&pty);
let (next, outcome) = run(pty, "echo ok", None);
pty = next;
assert_eq!(outcome.result, Ok(0), "{busy:?}");
assert_eq!(outcome.output, "ok\n", "{busy:?}");
}
pty
}
fn interrupted(pty: Pty) -> Pty {
let (pty, outcome) = run(pty, "sleep 30", Some(Duration::from_secs(2)));
assert_eq!(outcome.result, Ok(130));
pty
}
fn check(image: &str, shell: &[&str], expected: &str, interp: &str) {
let (pty, outcome) = run(start(image, shell), "id -un", None);
assert_eq!(outcome.result, Ok(0), "{image}: {}", outcome.output);
let context = outcome.context.expect("context");
assert_eq!(context.shell, expected, "{image}");
assert_eq!(context.interp, interp, "{image}");
assert_eq!((context.user.as_str(), context.cwd.as_str()), ("root", "/"));
assert_eq!(outcome.output, "root\n");
let limit = RunShell::of(expected).unwrap().line_limit();
let pty = round_trip(pty, limit);
let pty = at_limit(pty, limit);
let pty = takes_over(pty);
interrupted(pty);
}
#[test]
#[ignore = "needs Docker"]
fn bash_4_2() {
check("bash:4.2", &["bash"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn bash_4_4() {
check("bash:4.4", &["bash"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn bash_5_0() {
check("bash:5.0", &["bash"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn bash_5_1() {
check("bash:5.1", &["bash"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn bash_5_2() {
check("bash:5.2", &["bash"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn bash_started_as_sh() {
check("rockylinux:9", &["sh"], "bash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn dash() {
check("debian:stable-slim", &["sh"], "dash", "bash");
}
#[test]
#[ignore = "needs Docker"]
fn busybox_ash() {
check("alpine:3", &["sh"], "busybox", "sh");
}
#[test]
#[ignore = "needs Docker"]
fn another_shell_is_refused() {
// A shell program by another name: bash, which runs whatever its
// file is called.
let shell = start(
"bash:5.2",
&[
"sh",
"-c",
"cp /usr/local/bin/bash /tmp/mysh && exec /tmp/mysh",
],
);
let (_, outcome) = run(shell, "true", None);
assert_eq!(
outcome.result,
Err(RunFailure::UnsupportedShell("mysh".into()))
);
}
}
+103
View File
@@ -0,0 +1,103 @@
//! The terminal's scrollbar: gpui-kit's, over the scrollback.
//!
//! The grid is drawn afresh each frame and scrolls by whole lines, so there
//! is no scroll handle for gpui-kit's `Scrollbar` to follow. This one gives
//! it the history in pixels: a line is a row's height, the content is the
//! scrollbar's own height plus the history, and the offset is how far the
//! view is from the oldest line.
use std::cell::Cell;
use std::rc::Rc;
use gpui_kit::component::scroll::ScrollbarHandle;
use gpui_kit::{Bounds, Pixels, Point, Size, point, px, size};
use super::TerminalSnapshot;
use super::engine::Scrollback;
#[derive(Clone)]
pub(super) struct ScrollbackHandle {
scrollback: Scrollback,
history_size: usize,
display_offset: usize,
line_height: Pixels,
/// Where the scrollbar lies, which the canvas under it notes before the
/// scrollbar is laid out in the same frame.
viewport: Rc<Cell<Bounds<Pixels>>>,
}
impl ScrollbackHandle {
pub(super) fn new(
scrollback: Scrollback,
snapshot: &TerminalSnapshot,
line_height: Pixels,
) -> Self {
Self {
scrollback,
history_size: snapshot.history_size,
display_offset: snapshot.display_offset,
line_height,
viewport: Rc::default(),
}
}
pub(super) fn set_viewport(&self, bounds: Bounds<Pixels>) {
self.viewport.set(bounds);
}
}
impl ScrollbarHandle for ScrollbackHandle {
fn viewport_bounds(&self) -> Bounds<Pixels> {
self.viewport.get()
}
fn offset(&self) -> Point<Pixels> {
point(
px(0.),
offset(self.history_size, self.display_offset, self.line_height),
)
}
fn set_offset(&self, offset: Point<Pixels>) {
self.scrollback
.scroll_to(lines_from_top(offset.y, self.line_height));
}
fn content_size(&self) -> Size<Pixels> {
let viewport = self.viewport.get().size;
size(
viewport.width,
viewport.height + self.line_height * self.history_size as f32,
)
}
}
/// How far the view is scrolled down from the oldest line, as gpui-kit
/// counts it: zero at the top, negative below.
fn offset(history_size: usize, display_offset: usize, line_height: Pixels) -> Pixels {
-(line_height * history_size.saturating_sub(display_offset) as f32)
}
/// The line at the top of the view for a scroll `offset`.
fn lines_from_top(offset: Pixels, line_height: Pixels) -> usize {
(-offset / line_height).round().max(0.) as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_offset_counts_whole_lines_from_the_oldest() {
let line = px(17.);
// At the bottom of 100 lines of history, 100 lines down.
assert_eq!(offset(100, 0, line), px(-1700.));
// At the top.
assert_eq!(offset(100, 100, line), px(0.));
assert_eq!(lines_from_top(offset(100, 40, line), line), 60);
// A drag lands between lines; past the top is the top.
assert_eq!(lines_from_top(px(-25.), line), 1);
assert_eq!(lines_from_top(px(-26.), line), 2);
assert_eq!(lines_from_top(px(5.), line), 0);
}
}
+33 -1
View File
@@ -14,6 +14,7 @@ use gpui_kit::component::{
h_flex,
input::{Input, InputEvent, InputState},
menu::PopupMenu,
scroll::Scrollbar,
tooltip::Tooltip,
v_flex,
};
@@ -29,6 +30,7 @@ use crate::connection::{ConnectionPromptReply, Latency};
use crate::i18n::{UiLocale, t};
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::scrollbar::ScrollbackHandle;
use super::search::SearchMark;
use super::{
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
@@ -402,6 +404,11 @@ impl TerminalView {
self.engine.read(cx).exec(command)
}
/// Take up an `exec --terminal` run; see [`TerminalEngine::run_command`].
pub fn run_command(&self, run: super::RunHandle, cx: &App) -> Result<(), super::RunFailure> {
self.engine.read(cx).run_command(run)
}
pub fn screen_text(&self, cx: &App) -> String {
self.engine.read(cx).snapshot().visible_text()
}
@@ -910,6 +917,12 @@ impl EntityInputHandler for TerminalView {
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let snapshot = self.engine.read(cx).snapshot();
let font = TerminalFont::current(cx);
let scrollback = ScrollbackHandle::new(
self.engine.read(cx).scrollback(),
&snapshot,
font.row_height(window),
);
let view = cx.entity();
let context_menu = self.context_menu.clone();
let element = TerminalElement {
@@ -922,7 +935,6 @@ impl Render for TerminalView {
requested_size: self.requested_size,
};
let font = TerminalFont::current(cx);
let terminal = div()
.id(self.element_id.clone())
.test_support()
@@ -979,6 +991,26 @@ impl Render for TerminalView {
// the terminal's too.
.bg(TerminalColors::current(cx).background())
.child(terminal)
// Over the whole margin, so the thumb lies mostly beside the text.
.child(
div()
.absolute()
.inset_0()
.child(
canvas(
{
let scrollback = scrollback.clone();
move |bounds, _, _| scrollback.set_viewport(bounds)
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")),
)
.when_some(self.find.as_ref(), |container, find| {
container.child(self.render_find_bar(find, cx))
})
+64
View File
@@ -34,6 +34,70 @@ pub fn sh(script: &str, path: Option<&str>) -> std::process::Output {
command.output().expect("sh runs")
}
/// A program on a terminal of its own: what it prints comes in on `output`.
/// Unix only, like `sh`.
#[cfg(unix)]
pub struct Pty {
pub output: std::sync::mpsc::Receiver<Vec<u8>>,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
#[cfg(unix)]
impl Pty {
pub fn write(&mut self, bytes: &[u8]) {
self.writer.write_all(bytes).expect("pty takes input");
self.writer.flush().expect("pty takes input");
}
}
#[cfg(unix)]
impl Drop for Pty {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
/// Start `argv` on a new terminal of 120 × 40.
#[cfg(unix)]
pub fn pty(argv: &[&str]) -> Pty {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let pair = native_pty_system()
.openpty(PtySize {
rows: 40,
cols: 120,
pixel_width: 0,
pixel_height: 0,
})
.expect("pty opens");
let mut command = CommandBuilder::from_argv(argv.iter().map(Into::into).collect());
command.env("TERM", "xterm-256color");
let child = {
let _forks = no_forks();
pair.slave.spawn_command(command).expect("program starts")
};
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("pty reads");
let writer = pair.master.take_writer().expect("pty writes");
let (sender, output) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buffer = [0_u8; 8192];
while let Ok(count) = std::io::Read::read(&mut reader, &mut buffer) {
if count == 0 || sender.send(buffer[..count].to_vec()).is_err() {
break;
}
}
});
Pty {
output,
writer,
child,
_master: pair.master,
}
}
/// Whether this machine's `sh` reads `script` without a syntax error.
pub fn sh_accepts(script: &str) -> bool {
let mut command = Command::new("sh");
+64 -5
View File
@@ -1,7 +1,8 @@
//! The workspace's share of the external CLI: coming forward when ShellRS
//! is opened again, and the changes to hosts and credentials a `shellrs`
//! command asks for. The request threads cannot reach the store; the
//! changes wait in the server until they are made here, one at a time.
//! is opened again, the changes to hosts and credentials a `shellrs`
//! command asks for, and the `exec --terminal` runs. The request threads
//! cannot reach the store or the terminals; the changes and the runs wait
//! in the server until they are taken up here.
use std::time::Duration;
@@ -14,12 +15,13 @@ use crate::cli::{
};
use crate::cli::{
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,
};
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
use crate::i18n::t;
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::terminal::{RunFailure, RunHandle, TerminalLifecycle, TerminalPanel};
use super::workspace_view::Workspace;
@@ -48,6 +50,7 @@ impl Workspace {
for usage in server.take_usage() {
this.count(match usage {
CliUse::Exec => Counter::CliExec,
CliUse::ExecTerminal => Counter::CliExecTerminal,
CliUse::Upload => Counter::CliUpload,
CliUse::Download => Counter::CliDownload,
CliUse::Sync => Counter::CliSync,
@@ -73,6 +76,62 @@ impl Workspace {
.detach();
}
/// Hand each `exec --terminal` run the CLI server has heard to its
/// host's terminal. Asked on a timer of its own: the poller above waits
/// for each change to be made, and a run must not wait behind a
/// keychain write.
pub(super) fn serve_cli_runs(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(CLI_POLL).await;
let served = this.update(cx, |this, cx| {
let Some(server) = this.cli_server.as_ref() else {
return;
};
let runs: Vec<_> = std::iter::from_fn(|| server.take_run()).collect();
for (host, run) in runs {
if let Err(failure) = this.start_cli_run(&host, run.clone(), cx) {
run.fail(failure);
}
}
});
if served.is_err() {
break;
}
}
})
.detach();
}
/// Type an `exec --terminal` run into the terminal of the host the CLI
/// calls `host`: the one the right sidebar follows when it is this
/// host's, else the host's newest connected one.
pub fn start_cli_run(&self, host: &str, run: RunHandle, cx: &App) -> Result<(), RunFailure> {
let host = find_host(self.store.read(cx), host)
.map_err(|_| RunFailure::HostNotFound)?
.id;
let connected = |panel: &&Entity<TerminalPanel>| {
matches!(panel.read(cx).lifecycle(cx), TerminalLifecycle::Running)
};
let panel = self
.tool_terminal(cx)
.filter(|terminal| terminal.host == host)
.and_then(|terminal| self.terminals.get(&terminal.id))
.filter(connected)
.or_else(|| {
self.terminals
.values()
.filter(|panel| panel.read(cx).host_id() == host)
.filter(connected)
.max_by_key(|panel| panel.read(cx).id().0)
});
match panel {
Some(panel) => panel.read(cx).terminal().read(cx).run_command(run, cx),
None if self.terminal(host, cx).is_some() => Err(RunFailure::NotConnected),
None => Err(RunFailure::NoTerminal),
}
}
/// Make a change a `shellrs` command asks for, checked as the host and
/// credential forms check theirs. Its outcome is the reply the command
/// prints; on success, the host or credential as it is now.
+1
View File
@@ -265,6 +265,7 @@ impl Workspace {
Ok(server) => {
this.cli_server = Some(server);
this.serve_cli(window, cx);
this.serve_cli_runs(window, cx);
}
Err(error) => notify_once_open(
Notification::error(error.to_string()).title(t!("workspace.cli.start_failed")),
+206
View File
@@ -4,6 +4,9 @@
//! each change over.
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
use shellrs::terminal::{
BusyReason, RunEvent, RunFailure, RunProgress, RunTiming, TerminalRun, TerminalView,
};
use crate::support::*;
@@ -329,3 +332,206 @@ fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppCont
assert_eq!(host.user.as_ref(), "deploy");
});
}
/// How an `exec --terminal` run is paced against a fake shell that
/// answers at once.
const QUICK: RunTiming = RunTiming {
settle: Duration::ZERO,
probe: Duration::from_secs(2),
start: Duration::from_secs(2),
tick: Duration::from_millis(10),
};
/// The seed with web-01's terminal running on a transport that behaves as
/// `behavior`.
async fn open_terminal(
cx: &mut TestAppContext,
behavior: FakeBehavior,
) -> (
WindowHandle<Root>,
Entity<Workspace>,
Arc<FakeTerminalFactory>,
) {
let factory = Arc::new(FakeTerminalFactory {
behavior,
..FakeTerminalFactory::default()
});
let (handle, workspace) =
open_workspace_with_remote_factory(cx, HostStore::seed(), factory.clone());
let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL);
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
remote_lifecycle(&workspace, terminal, cx).accepts_input()
})
.await;
(handle, workspace, factory)
}
fn start_run(
workspace: &Entity<Workspace>,
host: &str,
run: &TerminalRun,
cx: &mut TestAppContext,
) -> Result<(), RunFailure> {
cx.update(|cx| workspace.read(cx).start_cli_run(host, run.handle(), cx))
}
fn web_terminal(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<TerminalView> {
cx.update(|cx| {
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))
.unwrap()
.read(cx)
.terminal()
.clone()
})
}
#[gpui_kit::test]
async fn a_cli_run_is_typed_into_the_hosts_terminal_after_a_ctrl_c(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/bash",
ends: true,
},
)
.await;
let web = host_id(&workspace, 1, cx);
let before = factory.written_text().len();
let (run, events) = TerminalRun::new("uname -a".into());
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
let (mut output, mut context) = (Vec::new(), None);
let code = run.drive(&events, QUICK, &mut |step| {
match step {
RunProgress::Context(found) => context = Some(found),
RunProgress::Output(bytes) => output.extend(bytes),
RunProgress::Idle => {}
}
Ok(())
});
assert_eq!(code, Ok(0));
assert_eq!(output, b"ran\n");
let context = context.expect("begun");
assert_eq!(
(
context.user.as_str(),
context.cwd.as_str(),
context.shell.as_str()
),
("root", "/root", "bash")
);
// ^C, the probe, the command line; never Ctrl-E, a byte to dash.
let typed = factory.written_text()[before..].to_string();
assert!(typed.starts_with("\x03 sh -c '"), "{typed:?}");
assert!(typed.ends_with(" 'uname -a'\r"), "{typed:?}");
assert_eq!(typed.matches(" sh -c '").count(), 2);
assert!(!typed.contains('\x05'));
}
#[gpui_kit::test]
async fn a_cli_run_a_terminal_cannot_take_says_why(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/zsh",
ends: true,
},
)
.await;
let (other, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, "nope", &other, cx),
Err(RunFailure::HostNotFound)
);
let db = host_id(&workspace, 3, cx);
assert_eq!(
start_run(&workspace, &db, &other, cx),
Err(RunFailure::NoTerminal)
);
// Another run has the terminal: no ^C to end it.
let web = host_id(&workspace, 1, cx);
let (first, first_events) = TerminalRun::new("sleep 9".into());
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
let before = factory.written_text().len();
let (second, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, &web, &second, cx),
Err(RunFailure::Busy(BusyReason::AnotherRun))
);
assert_eq!(factory.written_text().len(), before);
// A shell it cannot type into: only the probe was typed.
assert_eq!(first_events.recv().unwrap(), RunEvent::Accepted);
assert_eq!(
first.drive(&first_events, QUICK, &mut |_| Ok(())),
Err(RunFailure::UnsupportedShell("zsh".into()))
);
assert_eq!(
factory.written_text()[before..].matches(" sh -c '").count(),
1
);
// The terminal disconnected.
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
cx.run_until_parked();
let (third, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, &web, &third, cx),
Err(RunFailure::NotConnected)
);
}
#[gpui_kit::test]
async fn a_cli_run_leaves_a_full_screen_program_alone(cx: &mut TestAppContext) {
let (handle, workspace, factory) = open_terminal(cx, FakeBehavior::Prints("\x1b[?1049h")).await;
let terminal = web_terminal(&workspace, cx);
// The alternate screen is up: the prompt line is off it.
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
!terminal.read(cx).screen_text(cx).contains("alpha.txt")
})
.await;
let before = factory.written_text().len();
let (run, _) = TerminalRun::new("true".into());
let web = host_id(&workspace, 1, cx);
assert_eq!(
start_run(&workspace, &web, &run, cx),
Err(RunFailure::Busy(BusyReason::FullScreen))
);
assert_eq!(factory.written_text().len(), before);
}
#[gpui_kit::test]
async fn a_cli_run_whose_terminal_goes_away_may_have_run(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/bash",
ends: false,
},
)
.await;
let web = host_id(&workspace, 1, cx);
let (run, events) = TerminalRun::new("sleep 100".into());
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
let (begun, begun_here) = mpsc::channel();
let driver = std::thread::spawn(move || {
run.drive(&events, QUICK, &mut |step| {
if matches!(step, RunProgress::Context(_)) {
let _ = begun.send(());
}
Ok(())
})
});
begun_here
.recv_timeout(Duration::from_secs(5))
.expect("the command started");
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
cx.run_until_parked();
assert_eq!(driver.join().unwrap(), Err(RunFailure::Disconnected));
assert!(factory.written_text().contains(" 'sleep 100'\r"));
}
+40
View File
@@ -12,6 +12,13 @@ pub enum FakeBehavior {
ReportsLatency(Latency),
/// Prints this after its prompt line, as a program would.
Prints(&'static str),
/// Answers what `exec --terminal` types the way a shell whose program
/// is `exe` would: the probe, then the command line, which prints
/// `ran` and ends with 0 when `ends`, and runs on otherwise.
Shell {
exe: &'static str,
ends: bool,
},
}
#[derive(Default)]
@@ -165,7 +172,11 @@ impl TerminalTransport for FakeTerminalTransport {
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(bytes.clone());
let answer = shell_answer(self.behavior, &bytes);
events.send_blocking(TerminalTransportEvent::Output(bytes))?;
if let Some(answer) = answer {
events.send_blocking(TerminalTransportEvent::Output(answer))?;
}
}
TerminalTransportCommand::Resize(size) => {
self.resizes
@@ -199,6 +210,35 @@ impl TerminalTransport for FakeTerminalTransport {
}
}
/// What a [`FakeBehavior::Shell`] prints for a line `exec --terminal`
/// typed (` sh -c '<script>' sh NONCE …`): the probe's marker, or the
/// command's, run in /root on web-01 as root.
fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option<Vec<u8>> {
let FakeBehavior::Shell { exe, ends } = behavior else {
return None;
};
let line = String::from_utf8_lossy(typed);
let at = line.find("' sh ")?;
let nonce = line.get(at + 5..at + 17)?;
let answer = if line.contains("6973;P;") {
format!("\x1b]6973;P;{nonce};{exe}\x07")
} else if line.contains("6973;B;") {
let context: String = "root\nweb-01\n/root\nbash"
.bytes()
.map(|byte| format!("{byte:02x}"))
.collect();
let begin = format!("\x1b]6973;B;{nonce};{context}\x07");
if ends {
format!("{begin}ran\r\n\x1b]6973;E;{nonce};0\x07")
} else {
begin
}
} else {
return None;
};
Some(answer.into_bytes())
}
pub fn open_workspace_with_remote_factory(
cx: &mut TestAppContext,
store: HostStore,
+19
View File
@@ -921,6 +921,25 @@ async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut Test
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
}
#[gpui_kit::test]
async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) {
let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect();
let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await;
let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx));
assert!(!screen(cx).lines().any(|line| line == "line 1"));
// A click at the top of the scrollbar's track, in the margin beside the
// text, goes to the oldest line.
in_frame(cx, handle, |window, cx| {
let terminal = window.find(("local-terminal", 1_u64)).bounds();
let top = point(terminal.right() + px(2.), terminal.top() + px(2.));
click_at(window, top, gpui_kit::Modifiers::none(), cx);
});
let lines = screen(cx);
assert!(lines.lines().any(|line| line == "line 1"), "{lines}");
assert!(!lines.contains("end"), "{lines}");
}
#[gpui_kit::test]
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
// 「你好」 in base64.