diff --git a/CLAUDE.md b/CLAUDE.md index 53acbbc..18bf628 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - Unix 套接字 0600,并用 `peer_cred` 核对 uid。 - Windows 用命名管道:带 DACL 和 `FIRST_PIPE_INSTANCE`;客户端以 `SECURITY_IDENTIFICATION` 打开,并核对属主;用同步的 Win32 管道;关句柄时不 `DisconnectNamedPipe`。 - exec 和传输都不许挂住:非交互,陌生主机直接答否,问题按固定策略回答。 + - `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。 - UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。 - **Windows**: - 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。 diff --git a/docs/manual.md b/docs/manual.md index e4ec341..137591b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -127,6 +127,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ```sh shellrs list [--query <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON shellrs exec "<命令>" # 或 --stdin 从标准输入读命令 +shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON shellrs upload <本地路径> <远程路径> shellrs download <远程路径> <本地路径> ``` @@ -135,7 +136,8 @@ shellrs download <远程路径> <本地路径> - `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。 - 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。 - 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 -- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 +- `exec --json` 从标准输入读 `{"host": "", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。 +- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = ''; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 ## 从堡垒机打开(外部连接) diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index 91d356f..c5693b1 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -41,23 +41,26 @@ shellrs download /tmp/app.tar.gz ./latest.tar.gz ## Windows -The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths. +The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths. In Git Bash, use the heredoc form above. -In PowerShell, pass multi-line commands and commands containing quotes through stdin with a here-string instead of a heredoc. Windows PowerShell 5.1 breaks double quotes inside arguments, so do not rely on them there: +In PowerShell, prefer `exec --json`: build the request as an object, pipe it in, and read the result as JSON. Nothing in the command needs quoting (Windows PowerShell 5.1 breaks double quotes inside arguments), and the result is plain ASCII, so the console's code page cannot garble remote output: ```powershell +@{ host = ''; command = 'grep -c "error" /var/log/app.log' } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json +``` + +`exec --json` reads `{"host": "", "command": ""}` from stdin and, once the command ends, prints `{"exit_code": N, "stdout": "...", "stderr": "..."}`, or `{"error": {"code": "...", "message": "..."}}` when it could not run it. The exit code is the same as without `--json`, in `$LASTEXITCODE`. Windows PowerShell 5.1 pipes text to programs as ASCII: write non-ASCII characters in the command as `\uXXXX` escapes. + +`--json` prints nothing until the command ends. To see the output as it comes (a long build, a large log), pass the command through stdin with a here-string instead, after making PowerShell read and write UTF-8: + +```powershell +$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new() @' '@ | shellrs exec --stdin ``` -Before running `shellrs` in PowerShell, make it read and write UTF-8, or non-ASCII text (such as the Chinese names of saved hosts and remote output) turns into `?` or mojibake: - -```powershell -$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new() -``` - -The exit code is in `$LASTEXITCODE`. +The `@'` must end its line and the `'@` must start one. Set the same encodings before `list`, `upload` and `download`, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake. ## Rules diff --git a/src/cli/client.rs b/src/cli/client.rs index 382a932..5134aec 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -29,17 +29,54 @@ pub struct Console<'a> { pub stderr: &'a mut dyn Write, /// JSON instead of text, asked for or implied by a pipe. pub json: bool, + /// `exec --json`: the output gathered into one ASCII JSON object, and + /// errors in JSON as well. + pub exec_json: bool, /// Progress lines only make sense on a terminal. pub stderr_is_terminal: bool, } impl Console<'_> { pub fn error(&mut self, code: ErrorCode, message: &str) -> i32 { - let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str()); + if self.exec_json { + let error = serde_json::json!({ + "error": { "code": code.as_str(), "message": message } + }); + let _ = writeln!(self.stdout, "{}", ascii_json(&error)); + } else { + let _ = writeln!(self.stderr, "shellrs: [{}] {message}", code.as_str()); + } FAILURE_EXIT } } +/// What `exec --json` prints once the command ends. Output that is not +/// UTF-8 has U+FFFD in place of what is not. +#[derive(serde::Serialize)] +struct ExecResult<'a> { + exit_code: i32, + stdout: std::borrow::Cow<'a, str>, + stderr: std::borrow::Cow<'a, str>, +} + +/// `value` as JSON in ASCII alone: every other character escaped, which +/// only ever happens inside strings. PowerShell and the Windows console +/// read it right whatever code page they assume. +pub(super) fn ascii_json(value: &impl serde::Serialize) -> String { + let json = serde_json::to_string(value).unwrap_or_default(); + let mut ascii = String::with_capacity(json.len()); + for character in json.chars() { + if character.is_ascii() { + ascii.push(character); + } else { + for unit in character.encode_utf16(&mut [0; 2]) { + ascii.push_str(&format!("\\u{unit:04x}")); + } + } + } + ascii +} + /// Send `request` to the app listening at `endpoint` and print the answer; /// the exit code. pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 { @@ -170,11 +207,15 @@ where let mut reader = BufReader::new(stream); // The progress line on screen, to be blanked before anything else. let mut progress = ProgressLine::default(); + // What `exec --json` holds until the command ends. + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); loop { let Some((kind, payload)) = read_frame(&mut reader)? else { return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); }; match kind { + FrameKind::Stdout if console.exec_json => stdout.extend_from_slice(&payload), + FrameKind::Stderr if console.exec_json => stderr.extend_from_slice(&payload), FrameKind::Stdout => { console.stdout.write_all(&payload)?; console.stdout.flush()?; @@ -199,6 +240,15 @@ where return Ok(0); } Reply::TransferDone(summary) => return Ok(print_summary(&summary, console)?), + 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), + }; + writeln!(console.stdout, "{}", ascii_json(&result))?; + return Ok(code); + } Reply::Exit { code } => return Ok(code), Reply::Activated => return Ok(0), Reply::Error { code, message } => return Ok(console.error(code, &message)), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b170cfe..ef6e90d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -73,14 +73,22 @@ enum Command { /// remote command's. Exec { /// Host ID, from `shellrs list`. - id: String, + #[arg(required_unless_present = "json")] + id: Option, /// One complete remote shell command, quoted as one argument. - #[arg(required_unless_present = "stdin")] + #[arg(required_unless_present_any = ["stdin", "json"])] command: Option, /// Read the command from stdin instead, for commands with quotes, /// pipes, `$` or several lines. #[arg(long, conflicts_with = "command")] stdin: bool, + /// Read {"host": ID, "command": COMMAND} from stdin and print + /// {"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. + #[arg(long, conflicts_with_all = ["id", "command", "stdin"])] + json: bool, }, /// Copy a local file or folder to a saved host. /// @@ -134,6 +142,7 @@ pub fn main(args: Vec) -> i32 { stdout: &mut stdout, stderr: &mut stderr, json, + exec_json: false, stderr_is_terminal, }; let code = match request(cli.command, &mut console) { @@ -153,23 +162,27 @@ fn request(command: Command, console: &mut Console) -> Result { console.json |= json; Request::List { query } } - Command::Exec { id, command, stdin } => { - let command = if stdin { - let mut text = String::new(); - if let Err(error) = std::io::stdin().read_to_string(&mut text) { - return Err( - console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}")) - ); - } - text + Command::Exec { + id, + command, + stdin, + json, + } => { + // From here on, errors are JSON too. + console.exec_json = json; + let (host, command) = 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 { - command.unwrap_or_default() + (id.unwrap_or_default(), command.unwrap_or_default()) }; let command = normalize_command(&command); if command.trim().is_empty() { return Err(console.error(ErrorCode::BadRequest, "命令不能为空")); } - Request::Exec { host: id, command } + Request::Exec { host, command } } Command::Upload { id, @@ -200,6 +213,32 @@ fn request(command: Command, console: &mut Console) -> Result { }) } +fn read_stdin(console: &mut Console) -> Result { + let mut text = String::new(); + std::io::stdin() + .read_to_string(&mut text) + .map_err(|error| { + console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}")) + })?; + Ok(text) +} + +/// What `exec --json` reads. +#[derive(serde::Deserialize)] +struct ExecJson { + host: String, + command: String, +} + +/// The host and command of an `exec --json` request. +fn exec_request(text: &str) -> Result<(String, String), String> { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let request: ExecJson = serde_json::from_str(text).map_err(|error| { + format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}") + })?; + Ok((request.host, request.command)) +} + /// A command as the remote shell should see it. PowerShell ends every line /// it pipes to a program with CRLF, and a POSIX shell would take the CR as /// part of the command; a BOM would be the start of the first word. diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 277a5d3..1d90603 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -9,12 +9,13 @@ use std::{ use clap::Parser as _; +use super::client::ascii_json; use super::client::{self, Console}; use super::link::OpenLink; use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary}; use super::server::{CliBackend, CliServer, CliTarget}; use super::{Cli, Command}; -use super::{ConsoleText, normalize_command}; +use super::{ConsoleText, exec_request, normalize_command}; use crate::app::cli_endpoint; use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink}; use crate::secrets::SecretRef; @@ -140,6 +141,7 @@ fn run_on( stdout: &mut stdout, stderr: &mut stderr, json, + exec_json: false, stderr_is_terminal, }, ); @@ -555,6 +557,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() { // Neither, or both. assert!(parse(&["shellrs", "exec", "ID"]).is_err()); assert!(parse(&["shellrs", "exec", "ID", "ls", "--stdin"]).is_err()); + // JSON brings the host and the command with it, and nothing else does. + assert!(matches!( + parse(&["shellrs", "exec", "--json"]), + Ok(Command::Exec { + id: None, + command: None, + json: true, + .. + }) + )); + assert!(parse(&["shellrs", "exec"]).is_err()); + assert!(parse(&["shellrs", "exec", "ID", "--json"]).is_err()); + 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()); } @@ -602,3 +617,80 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() { raw.finish().unwrap(); assert_eq!(raw.inner, b"\xff\xd6"); } + +#[test] +fn exec_json_reads_the_host_and_the_command() { + assert_eq!( + exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"), + Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string())) + ); + // 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())) + ); + assert!(exec_request(r#"{"command":"ls"}"#).is_err()); + assert!(exec_request("ls -la").is_err()); +} + +#[test] +fn exec_json_output_is_ascii_whatever_it_holds() { + assert_eq!( + ascii_json(&serde_json::json!({ "s": "生产 😀\n" })), + r#"{"s":"\u751f\u4ea7 \ud83d\ude00\n"}"# + ); +} + +/// `exec --json` against the fixture: the exit code, and stdout parsed. +fn run_exec_json(socket: &Path, request: Request) -> (i32, serde_json::Value, Vec) { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let code = client::run( + socket, + request, + &mut Console { + stdout: &mut stdout, + stderr: &mut stderr, + json: true, + exec_json: true, + stderr_is_terminal: false, + }, + ); + assert!(stdout.is_ascii(), "{}", String::from_utf8_lossy(&stdout)); + (code, serde_json::from_slice(&stdout).unwrap(), stderr) +} + +#[test] +fn exec_json_gathers_the_output_and_says_errors_in_json() { + let fixture = fixture(); + let (code, result, stderr) = run_exec_json( + &fixture.socket, + Request::Exec { + host: id(&fixture.web), + command: "uname -a".into(), + }, + ); + assert_eq!(code, 3); + assert_eq!( + result, + serde_json::json!({ "exit_code": 3, "stdout": "out\n", "stderr": "\u{fffd}\n" }) + ); + assert!(stderr.is_empty()); + + fixture.server.set_enabled(false); + let (code, result, stderr) = run_exec_json( + &fixture.socket, + Request::Exec { + host: id(&fixture.web), + command: "true".into(), + }, + ); + assert_eq!(code, 255); + assert_eq!(result["error"]["code"], "not_enabled"); + assert!( + result["error"]["message"] + .as_str() + .unwrap() + .contains("设置 → 外部 CLI") + ); + assert!(stderr.is_empty()); +} diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs index 01f304e..21fb55f 100644 --- a/tests/cli_bin.rs +++ b/tests/cli_bin.rs @@ -133,6 +133,21 @@ fn the_command_finds_the_app_through_its_data_directory() { "cd /srv\nls\n" ); + // JSON in, JSON out, in ASCII alone. + let request = serde_json::json!({ "host": id, "command": "ls" }).to_string(); + let output = shellrs(data_dir.path(), &["exec", "--json"], request.as_bytes()); + assert_eq!(output.status.code(), Some(7)); + assert!(output.stdout.is_ascii()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({ "exit_code": 7, "stdout": "输出\n", "stderr": "warning\n" }) + ); + assert!(output.stderr.is_empty()); + let output = shellrs(data_dir.path(), &["exec", "--json"], b"ls"); + assert_eq!(output.status.code(), Some(255)); + let error: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["code"], "bad_request"); + // Everything arrives, though the app is done long before. let output = shellrs(data_dir.path(), &["exec", &id, "large"], b""); assert_eq!(output.status.code(), Some(0));