diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7db27c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目 + +bolt 是一个单文件 MCP 服务器(`src/main.rs`),让 MCP 客户端在主机上执行 shell 命令、读写和传输文件。它手写实现了 JSON-RPC 2.0 + MCP(协议版本 2024-11-05 / 2025-03-26 / 2025-06-18),支持两种传输:stdio(默认)和 Streamable HTTP(`--http `)。依赖只有 `serde_json`,Unix 下再加 `libc`;base64、SHA-256、HMAC、百分号编码、随机数(`/dev/urandom` / `BCryptGenRandom`)都是手写的。请保持这一点:不引入新 crate,不引入 async runtime,也不引入 HTTP/MCP 框架。代码注释、日志信息和文档都用中文,新增内容请保持一致。 + +## 常用命令 + +```bash +cargo build --release # 产物 target/release/bolt(release 配置:lto=fat、codegen-units=1、strip) +cargo test --release # 单元测试(位于 src/main.rs 末尾的 mod tests) +cargo test --release test_policy # 按名称运行单个单元测试 +cargo clippy --release --all-targets # 当前无警告,请保持 +cargo check --release --all-targets --target x86_64-pc-windows-gnu # 在 Linux 上检查 Windows 分支能否编译 +./target/release/bolt --help # 查看 CLI 参数;每个参数都可用对应的 BOLT_* 环境变量作为回退(CLI 优先) +./target/release/bolt --http 127.0.0.1:8080 --token t --allow "git,ls" --file-root /tmp +``` + +- `test_run_command_basic` 和 `test_shell_session` 带 `#[cfg(unix)]`(使用 `sh -c`、`printf`、`sleep`),Windows 上会跳过。Windows 下服务器使用 `cmd /C`。 +- 端到端测试在 `docs/bolt_stdio_test.py`(stdio)和 `docs/bolt_http_test.py`(HTTP,会额外起一个带 `--token` 和 `--file-root` 的实例)。它们对管道使用 `select`,需要 Unix 主机;二进制路径默认是 `target/release/bolt`,可用 `BOLT_BIN` 覆盖,例如 `BOLT_BIN=/path/bolt python3 docs/bolt_http_test.py`。HTTP 测试占用端口 18080 和 18081。 + +## 架构(src/main.rs,自上而下) + +文件用 `// ----` 横幅注释分段:常量 → 配置 → 出站通道 → 应用状态 → JSON-RPC 辅助 → 命令策略 → 编码/摘要/随机数 → run_command 执行引擎 → shell 会话 → 文件 → 工具列表 → MCP 分发 → 客户端作用域 → stdio 传输 → HTTP 传输 → 信号与清理 → main → 测试。 + +- **`App` / `Shared = Arc`**:全局状态。包含 `Config`、按配置生成的 `tools`、`inflight` 表(`作用域\n请求id` → `Arc`)、`sessions`(shell 会话,带 `owner` 作用域)、`mcp_sessions`(`Mcp-Session-Id` → 最近活动时间)、并发/连接计数,以及 `file_link` 的签名密钥(启动时随机生成)。 +- **`Ctx`(作用域 + 出口)**:每个请求都带一个 `Ctx { scope, sink, base_url }`。`scope` 用来隔离取消和 shell 会话:stdio 下是 `"stdio"`;HTTP 下是 `Mcp-Session-Id`,不带该头的请求共用匿名作用域 `"-"`。`sink` 决定通知和响应发往哪里:`Sink::Stdio`、`Sink::Hub(hub, scope)`(只推给该作用域的 GET SSE 流)、`Sink::Chan`(当前 POST 的 SSE 响应流)。`base_url` 由 `Host` / `X-Forwarded-*` 推出,只有 `file_link` 用到。 +- **`process_message(v, app, ctx, async_exec)`**:两种传输共用的唯一入口。通知返回 `None`。`tools/call` 先通过 `CallSlot` 占用一个并发名额(超限时返回 `isError` 结果),再注册 `Killable`。`async_exec` 为 true(stdio)时在 `bolt-tool` 线程中执行,完成后经 `ctx.sink` 发送响应,所以 stdio 响应可能乱序;为 false(HTTP)时同步执行。`notifications/exit` 只在 stdio 作用域生效。 +- **`exec_tool_call`**:按工具名分发。工具执行失败返回 `Ok(tool_error(..))`(`isError: true`),成功结果用 `tool_ok(v)` 包成 `content` + `structuredContent`。参数错误或未知工具返回 `Err((code, msg))`,转成 JSON-RPC 错误。参数用 `req_str` / `opt_u64` 等辅助函数读取。新增工具时请保持这一区分,并在 `build_tools(cfg)` 里补上 schema。文件工具只有在 `files_enabled` 时才会出现在列表中,`file_link` 只在 HTTP 模式下出现。 +- **进程生命周期**:Unix 下子进程放进独立进程组(`pre_exec` + `setpgid(0,0)`),`kill_tree` 对 `-pid` 发 `SIGKILL`;Windows 下用 `taskkill /T /F`。`Killable` 处理"pid 注册前就收到取消"的情况。主进程被收割后会把 pid 清零,防止 pid 复用时误杀。主进程退出后,读取线程最多再等 `PIPE_DRAIN_GRACE`;如果后台进程还占着管道,就标记 `detached` 直接返回,读取线程转入后台继续排空但不再保存。 +- **Shell 会话**:`SessBuf` 里的偏移都是绝对字节偏移(`base` / `read`),裁剪缓冲前部不会让读取位置错位。退出信息也放在同一把锁里,`Condvar` 不会丢唤醒。`shell_read` 会避免切断 UTF-8 字符,每 200ms 检查一次是否被取消。`janitor_loop` 每 30 秒回收空闲会话和过期的 MCP 会话。 +- **文件**:`resolve_path` 是所有文件访问(工具和 `/files`)唯一的路径入口。已存在的路径用 `canonicalize` 解析符号链接;不存在的路径先规范化最近的已存在祖先,再拼上剩余部分(剩余部分不允许 `.` / `..`),最后检查是否在 `--file-root` 之内。`write_file` 的 overwrite 模式写同目录临时文件后 rename。错误类型是 `FErr = (HTTP 状态码, 信息)`,工具和 HTTP 共用。 +- **HTTP 传输**:手写 HTTP/1.1,每个连接一个线程(`conn_loop`,受 `MAX_HTTP_CONNS` 限制)。检查顺序:OPTIONS(CORS)→ Origin 白名单 → Bearer token(`ct_eq` 常量时间比较;带 `sig` 的 `/files` 请求改为在 `handle_files` 里校验 HMAC 签名)→ 路由到 `handle_mcp` 或 `handle_files`。读取 body 之前出错一律关闭连接。请求体统一经 `BodyReader` 读取,支持 Content-Length 和 chunked。`handle_*` 对读写端是泛型的,单元测试直接喂内存缓冲。 +- **退出**:Unix 下屏蔽 SIGINT 和 SIGTERM,由 `sigwait` 线程接收信号,执行 `shutdown_cleanup` 后直接 `process::exit`(stdio 主线程可能阻塞在读 stdin 上)。Windows 下用 `SetConsoleCtrlHandler` 做同样的事。 +- **命令策略**(`policy_check(cfg, command, shell)`):allow/deny 都按程序名精确匹配(会去掉路径、引号、转义和 `.exe` 等扩展名)。配置了 allow 时,shell 模式禁止元字符;deny 会检查按元字符切分后每一段的程序名。作用范围:`run_command`、`shell_open` 的 shell 名称、`shell_write` 写入的每一行。配置了 allow 但没有 `--file-root` 时文件功能禁用。 + +各种上限和默认值是文件顶部的 `const`(`MAX_HTTP_BODY`、`MAX_CONCURRENT_CALLS`、`SESSION_IDLE`、`DEFAULT_READ_LEN`、`DEFAULT_MAX_UPLOAD` 等)。 + +## 部署(deploy/) + +- `install.sh` 是需要 root 的 POSIX sh 安装脚本。自动识别 Linux(systemd:`/usr/local/bin/bolt`、`/etc/bolt.env`、`bolt.service`)或 macOS(launchd 标签 `io.jocay.bolt`,plist 放在 `/Library/Application Support/bolt/`,启用开机自启时复制到 `/Library/LaunchDaemons/`)。默认从 Gitea Release 下载 `bolt--`(版本由 `BOLT_VERSION` 指定,默认 `v0.1.0`;可用 `BOLT_DL_URL` 或 `--binary` 覆盖)。`BOLT_DL_TOKEN`(下载认证)与 `--token`(服务器的 Bearer 令牌)相互独立。 +- `bolt.service` 和 `bolt.env.example` 用于手动 systemd 部署。监听地址写在 unit 文件的 `ExecStart` 里,其余配置来自 `/etc/bolt.env` 中的 `BOLT_*` 变量。 +- 修改 CLI 参数时,需同步更新 `parse_args`/`print_help`、`install.sh`(参数解析及其生成的 env/plist)、`bolt.env.example` 和 README 中的参数表。 diff --git a/Cargo.lock b/Cargo.lock index 821b536..3b596c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "bolt" -version = "0.1.0" +version = "0.2.0" dependencies = [ "libc", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a085096..ec8e1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bolt" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)" license = "MIT" diff --git a/README.md b/README.md index 4fd2987..808119f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # bolt — 极速单文件 MCP 命令执行服务器 -让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令。 +让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令、读写和传输文件。 -- **单文件**:全部代码在 `src/main.rs`(约 1900 行,其中 400 行为单元测试),仅依赖 `serde_json`(Unix 下加 `libc`) +- **单文件**:全部代码在 `src/main.rs`,仅依赖 `serde_json`(Unix 下加 `libc`);base64 / SHA-256 / HMAC 均为手写 - **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖 - **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接) @@ -27,7 +27,7 @@ claude mcp add --transport stdio bolt -- /abs/path/to/bolt ```bash bolt --http 127.0.0.1:8080 # 仅本机 bolt --http 0.0.0.0:8080 --token mysecret # 对外 + Bearer 认证 -bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" +bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" --file-root /srv/data # Claude Code 连接远程 claude mcp add --transport http bolt http://127.0.0.1:8080/mcp @@ -38,12 +38,35 @@ claude mcp add --transport http bolt http://127.0.0.1:8080/mcp | 工具 | 说明 | |---|---| | `run_command` | 执行 shell 命令:完整 shell 语法(管道/重定向/`&&`)、`cwd`、`env`、`timeout_ms`(超时自动杀进程树)、`max_output_bytes`(输出截断防膨胀)、`shell=false` 直接执行二进制;客户端可传 `_meta.progressToken` 接收进度通知,支持 `notifications/cancelled` 取消 | -| `shell_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd` | +| `shell_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd`;空闲 30 分钟自动回收 | | `shell_write` | 向会话写入输入(自动补换行) | -| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出) | +| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出),可被取消 | | `shell_close` | 关闭并杀掉会话进程树 | +| `file_read` | 读取文件:UTF-8 文本按文本返回,图片按 image 返回,其余按 base64;`offset` + `length` 分块读取,不会切断多字节字符 | +| `file_write` | 写入文件:`overwrite`(默认,临时文件 + rename 原子替换,保留原权限)/ `create`(已存在则失败)/ `append`;`encoding=base64` 写二进制;`mkdirs` 自动建目录;返回 SHA-256 | +| `file_stat` | 文件信息(是否存在、类型、大小、修改时间、权限),可选计算 SHA-256 | +| `file_link` | 仅 HTTP 模式:生成带 HMAC 签名、限时有效的 `/files` 直传链接,并给出可在客户端本机执行的 `curl` 命令 | -`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `duration_ms` / `stdout` / `stderr` / 截断标志等(同时提供 `content` 与 `structuredContent`)。 +所有工具结果都同时提供 `content` 与 `structuredContent`(`file_read` 为两块 `content`:元数据 JSON + 文件内容)。 + +`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `detached` / `duration_ms` / `stdout` / `stderr` / 截断标志等。命令退出后若仍有后台进程占用输出管道(如 `python -m http.server &`),等待 300ms 后返回 `detached=true`,后台进程继续运行。 + +## 文件传输 + +MCP 工具的参数和结果都要经过模型上下文,所以分两层: + +- **小文件**(配置、脚本、日志片段、截图):直接用 `file_read` / `file_write`,stdio 和 HTTP 都可用。 +- **大文件**(HTTP 模式):数据走 `/files` 端点,不经过模型。 + - AI 调用 `file_link` 拿到签名链接,在客户端本机执行返回的 `curl` 命令即可: + + ```bash + curl -fsSL -o ./backup.tar.gz 'http://host:8080/files?path=/srv/data/backup.tar.gz&exp=...&sig=...' # 下载 + curl -fsS -T ./dataset.zip 'http://host:8080/files?path=/srv/data/dataset.zip&exp=...&sig=...&mode=overwrite&mkdirs=0' # 上传 + ``` + + - 也可以直接带 Bearer 令牌访问:`curl -H "Authorization: Bearer $T" -T f.bin "http://host:8080/files?path=/srv/data/f.bin&mkdirs=1"`。 + - 支持 `Range` 断点续传(206)、`HEAD`、chunked 上传、`Expect: 100-continue`;上传同样是原子替换,失败不破坏原文件,响应里带 SHA-256。 + - 签名覆盖方法、路径、写入模式和过期时间,链接只能用于签名时指定的那一个操作;签名密钥在进程启动时随机生成,重启后旧链接失效。 ## 配置 @@ -51,59 +74,71 @@ CLI 参数(环境变量 `BOLT_*` 为回退): | 参数 | 默认 | 说明 | |---|---|---| -| `--http ` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080` | +| `--http ` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080`(`:8080` 等价于 `127.0.0.1:8080`) | | `--token ` | 无 | HTTP 要求 `Authorization: Bearer ` | -| `--allow ` | 空 | 命令白名单(命令首词前缀匹配;非空时仅允许列表内命令) | -| `--deny ` | 空 | 命令黑名单(前缀匹配) | +| `--allow ` | 空 | 命令白名单(按程序名精确匹配;非空时 shell 模式禁止元字符) | +| `--deny ` | 空 | 命令黑名单(按程序名匹配命令中每一段,尽力而为) | | `--timeout-ms ` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) | -| `--cwd ` | 当前 | 命令默认工作目录 | +| `--cwd ` | 当前 | 命令与相对文件路径的默认工作目录 | | `--max-output ` | 1M | 单流输出捕获上限(支持 K/M/G) | | `--allow-origin ` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) | +| `--file-root ` | 不限 | 文件工具与 `/files` 只能访问这些目录;配置了 `--allow` 时必须设置,否则文件功能禁用 | +| `--max-upload ` | 2G | `PUT /files` 单次上传上限(支持 K/M/G) | ```bash -export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000 +export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000 BOLT_FILE_ROOT=/srv/data bolt --http 127.0.0.1:8080 ``` ## 协议实现(2025-06-18 规范) - `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown` -- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`、`notifications/progress`(进度上报) +- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`(仅 stdio 生效)、`notifications/progress`(进度上报) - 工具执行错误以 `isError: true` 返回;协议错误返回标准 JSON-RPC 错误码(-32601/-32602/-32700 等) -- Streamable HTTP:POST 请求(通知 → 202;请求 → JSON 或 SSE 响应)、GET 长连接 SSE 流(服务器通知广播 + 10s 心跳)、CORS、Origin 校验、Bearer 认证、无状态(不要求会话 ID) +- Streamable HTTP: + - `initialize` 时签发 `Mcp-Session-Id`;携带该头的请求按会话隔离 shell 会话与取消,未知/过期 ID 返回 404,`DELETE /mcp` 终止会话并清理其资源;不带该头的客户端共用一个匿名作用域(兼容无状态客户端) + - POST:通知 → 202;请求 → JSON 或 SSE。`Accept` 含 `text/event-stream` 的 `tools/call` 会在同一个 SSE 响应里先推进度通知、最后推结果 + - GET:长连接 SSE 流(接收本会话的服务器通知 + 10s 心跳) + - 支持 chunked 请求体、CORS、Origin 校验、Bearer 认证 -## 性能 +## 性能与资源 -- 单线程事件循环 + 复用缓冲区,无锁热路径;输出计数用原子操作,进度推送不占主循环 +- 复用缓冲区,输出计数用原子操作,进度推送不占主循环;stdio 每个工具调用一个工作线程,HTTP 每个连接一个线程 - 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸 +- 上限:并发工具调用 128、HTTP 连接 512、shell 会话 64(空闲 30 分钟回收)、MCP 会话 1024(空闲 24 小时回收) - 实测(本机,含 Python 客户端逐字节 I/O 开销): - ping 往返吞吐 ≈ **14,700 msg/s** - 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出) -- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`,二进制约 0.7MB +- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true` ## 测试 ```bash -cargo test --release # 8 个单元测试(协议分发/策略/HTTP 解析/执行引擎/会话) +cargo test --release # 单元测试(策略/编码与摘要/HTTP 解析/文件读写与目录限制/会话缓冲/执行引擎等) +cargo test --release test_files # 只跑某一个 ``` -集成测试脚本在 `docs/`(stdio 12 项、HTTP 19 项,覆盖超时/取消/进程树/进度/并发/auth/Origin): +端到端测试脚本在 `docs/`(需要 Unix 主机;二进制路径默认 `target/release/bolt`,可用 `BOLT_BIN` 指定): ```bash -python3 /tmp/bolt_stdio_test.py # stdio 端到端 -python3 /tmp/bolt_http_test.py # HTTP 端到端 +python3 docs/bolt_stdio_test.py # stdio 端到端 +python3 docs/bolt_http_test.py # HTTP 端到端(含 Mcp-Session-Id、/files、签名链接) ``` ## 安全说明 -- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token` -- 该服务器等于远程 root shell 入口,务必配置白名单(`--allow`)与认证后再暴露公网 -- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程 +- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`(未设置时启动日志会警告) +- 该服务器等于远程 root shell 入口,务必配置认证后再暴露公网 +- 白名单(`--allow`):按程序名精确匹配,shell 模式下禁止 `; & | $ \` < > ( ) { }` 和换行,防止 `git status; rm -rf ~` 这类串联绕过;`shell_write` 写入的每一行同样受检。注意白名单内的程序如果自身能执行任意命令(`sh`、`env`、`docker`、`find -exec`、`git -c ...`),白名单就约束不了它 +- 黑名单(`--deny`):检查命令每一段的程序名(去掉路径、引号、转义和前置的 `VAR=x`),但仍可能被包装命令绕过,只适合防误操作 +- 文件功能等同于任意写文件(可写 `authorized_keys`、crontab),所以配置白名单时必须用 `--file-root` 限定目录,否则文件功能自动禁用;路径会解析符号链接并拒绝 `..` 逃逸 +- 不同 MCP 会话之间的 shell 会话与取消相互隔离;HTTP 客户端无法通过 `notifications/exit` 关停服务 +- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程;收到 SIGINT/SIGTERM(Windows 为 Ctrl+C)时先清理全部子进程再退出 ## 平台 - Linux / macOS(完整功能:进程组杀、信号处理) -- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树;无信号优雅退出) +- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树、Ctrl+C 清理退出) ## 在其他设备上部署(最快方式) 一键脚本支持 Linux(systemd)和 macOS(launchd),自动识别 amd64 / arm64;Apple Silicon 即使在 Rosetta 终端中也优先选择 arm64。安装需要 root 权限。 @@ -164,6 +199,8 @@ macOS 服务的工作目录为 `/var/root`,PATH 包含 `/opt/homebrew/bin` 和 ` | `--token ` | Bearer 认证令牌(公网必填) | | `--allow ` / `--deny ` | 命令白/黑名单 | | `--timeout-ms ` | 默认命令超时 | +| `--file-root ` | 文件功能允许访问的目录(配置 `--allow` 时必填,否则文件功能禁用) | +| `--max-upload ` | `PUT /files` 单次上传上限(默认 2G) | | `--binary <路径或URL>` | 跳过下载,用本地已拷贝的二进制(如 `scp` 过去的) | | `--no-start` / `--no-enable` | 只安装不启动 / 不设开机自启 | diff --git a/deploy/bolt.env.example b/deploy/bolt.env.example index 1336e13..a41d065 100644 --- a/deploy/bolt.env.example +++ b/deploy/bolt.env.example @@ -7,12 +7,18 @@ # 认证令牌(必填,若监听 0.0.0.0 对外暴露) #BOLT_TOKEN=请改成强随机串 -# 命令白名单(非空时仅允许列出的命令,用逗号分隔) +# 命令白名单(按程序名精确匹配,逗号分隔;非空时 shell 模式禁止管道/重定向/命令串联等元字符) #BOLT_ALLOW=git,docker,curl -# 命令黑名单(前缀匹配) +# 命令黑名单(按程序名精确匹配命令中每一段;尽力而为,不能替代白名单) #BOLT_DENY=rm,mkfs +# 文件工具与 /files 允许访问的目录(逗号分隔;设置了 BOLT_ALLOW 时必须配置,否则文件功能禁用) +#BOLT_FILE_ROOT=/srv/data,/tmp + +# PUT /files 单次上传上限(支持 K/M/G,默认 2G) +#BOLT_MAX_UPLOAD=2G + # 默认命令超时毫秒(0 = 无限) #BOLT_TIMEOUT_MS=30000 diff --git a/deploy/install.sh b/deploy/install.sh index f3a50fe..1f403a4 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -14,6 +14,8 @@ # --allow 命令白名单 # --deny 命令黑名单 # --timeout-ms 命令默认超时(默认 30000) +# --file-root 文件工具与 /files 只能访问这些目录(配置 --allow 时必须设置,否则文件功能禁用) +# --max-upload PUT /files 单次上传上限,支持 K/M/G(默认 2G) # --binary 二进制来源:本地路径 或 http(s) URL(默认从 Gitea Release 下载) # --no-start 只安装不启动 # --no-enable 不设置开机自启 @@ -24,6 +26,8 @@ TOKEN="${BOLT_TOKEN:-}" ALLOW="" DENY="" TIMEOUT_MS="${BOLT_TIMEOUT_MS:-30000}" +FILE_ROOT="" +MAX_UPLOAD="" BINARY_SRC="" NO_START=0 NO_ENABLE=0 @@ -35,6 +39,8 @@ while [ $# -gt 0 ]; do --allow) ALLOW="${2:-}"; shift 2 ;; --deny) DENY="${2:-}"; shift 2 ;; --timeout-ms) TIMEOUT_MS="${2:-}"; shift 2 ;; + --file-root) FILE_ROOT="${2:-}"; shift 2 ;; + --max-upload) MAX_UPLOAD="${2:-}"; shift 2 ;; --binary) BINARY_SRC="${2:-}"; shift 2 ;; --no-start) NO_START=1; shift ;; --no-enable) NO_ENABLE=1; shift ;; @@ -150,6 +156,8 @@ if [ "$OS" = Darwin ]; then BOLT_ALLOW$(xml_escape "$ALLOW") BOLT_DENY$(xml_escape "$DENY") BOLT_TIMEOUT_MS$(xml_escape "$TIMEOUT_MS") + BOLT_FILE_ROOT$(xml_escape "$FILE_ROOT") + BOLT_MAX_UPLOAD$(xml_escape "$MAX_UPLOAD") WorkingDirectory/var/root RunAtLoad @@ -200,6 +208,12 @@ fi if [ -n "$DENY" ]; then printf 'BOLT_DENY=%s\n' "$DENY" >> "$ENV_FILE" fi +if [ -n "$FILE_ROOT" ]; then + printf 'BOLT_FILE_ROOT=%s\n' "$FILE_ROOT" >> "$ENV_FILE" +fi +if [ -n "$MAX_UPLOAD" ]; then + printf 'BOLT_MAX_UPLOAD=%s\n' "$MAX_UPLOAD" >> "$ENV_FILE" +fi [ -z "$TOKEN" ] && [ -z "$ALLOW" ] && [ -z "$DENY" ] && \ printf '# 默认无认证无白名单;监听公网时请设置 BOLT_TOKEN 与 BOLT_ALLOW\n' >> "$ENV_FILE" diff --git a/docs/bolt_http_test.py b/docs/bolt_http_test.py index 6b8e478..4083c3f 100644 --- a/docs/bolt_http_test.py +++ b/docs/bolt_http_test.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 """bolt HTTP 模式端到端测试""" -import http.client, json, subprocess, threading, time +import hashlib, http.client, json, os, shutil, subprocess, sys, tempfile, threading, time +from urllib.parse import urlsplit -BOLT = "/root/bolt/target/release/bolt" +# 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物 +BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt") PORT = 18080 +PORT2 = 18081 # 带 token + --file-root 的第二个实例 +TOKEN = "e2e-secret" passed = failed = 0 def check(name, cond, detail=""): global passed, failed @@ -14,26 +18,50 @@ def check(name, cond, detail=""): failed += 1 print(f" FAIL {name} {detail}") +tmpdir = tempfile.mkdtemp(prefix="bolt-http-e2e-") proc = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT}"], stderr=subprocess.PIPE) +proc2 = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT2}", "--token", TOKEN, "--file-root", tmpdir], stderr=subprocess.PIPE) time.sleep(0.5) -def post(obj, auth=None, origin=None, accept="application/json", path="/mcp"): - c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=30) +def request(method, path, body=None, headers=None, port=PORT, timeout=30): + c = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) + c.request(method, path, body=body, headers=headers or {}) + r = c.getresponse() + data = r.read() + hdrs = {k.lower(): v for k, v in r.getheaders()} + c.close() + return r.status, hdrs, data + +def post(obj, auth=None, origin=None, accept="application/json", path="/mcp", sid=None, port=PORT): headers = {"Content-Type": "application/json", "Accept": accept} if auth: headers["Authorization"] = auth if origin: headers["Origin"] = origin - body = json.dumps(obj) - c.request("POST", path, body=body, headers=headers) - r = c.getresponse() - data = r.read() - ct = r.getheader("Content-Type", "") - c.close() - return r.status, ct, data + if sid: headers["Mcp-Session-Id"] = sid + st, hdrs, data = request("POST", path, json.dumps(obj), headers, port=port) + return st, hdrs.get("content-type", ""), data -# 1. initialize(JSON 响应) -st, ct, data = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}) +def post_h(obj, sid=None, accept="application/json", port=PORT, auth=None): + """返回 (status, headers, data)""" + headers = {"Content-Type": "application/json", "Accept": accept} + if sid: headers["Mcp-Session-Id"] = sid + if auth: headers["Authorization"] = auth + return request("POST", "/mcp", json.dumps(obj), headers, port=port) + +def call(name, args, rid=100, sid=None, port=PORT, auth=None): + st, _, data = post_h({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": name, "arguments": args}}, sid=sid, port=port, auth=auth) + return json.loads(data) + +def sse_events(data): + return [json.loads(line[len(b"data: "):]) for line in data.split(b"\r\n") if line.startswith(b"data: ")] + +INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}} + +# 1. initialize(JSON 响应 + 签发 Mcp-Session-Id) +st, hdrs, data = post_h(INIT) m = json.loads(data) -check("init json", st == 200 and "application/json" in ct and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {ct} {data[:100]}") +sid_a = hdrs.get("mcp-session-id") +check("init json", st == 200 and "application/json" in hdrs.get("content-type", "") and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {data[:100]}") +check("init issues session id", bool(sid_a) and len(sid_a) == 32, str(hdrs)) # 2. SSE 响应(accept 含 text/event-stream) st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, text/event-stream") @@ -51,8 +79,7 @@ m = json.loads(data) sc = m["result"]["structuredContent"] check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc)) -# 5. GET SSE 流:开流收心跳,并发命令进度通知广播 -got_event = threading.Event() +# 5. GET SSE 流:开流收心跳,同作用域命令的进度通知推送到流上 got_heartbeat = threading.Event() msgs = [] def sse_get(): @@ -71,6 +98,7 @@ def sse_get(): buf = b"" if msgs and (": ping" in msgs[-1]): got_heartbeat.set() + break c.close() t = threading.Thread(target=sse_get) t.start() @@ -80,20 +108,29 @@ st, ct, data = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params" m = json.loads(data) check("long cmd done", m["result"]["structuredContent"]["stdout"].count("h-") == 4, str(m)[:100]) saw_progress = any("notifications/progress" in x and "h1" in x for x in msgs) -check("sse got progress broadcast", saw_progress, str(msgs)[:200]) +check("sse got progress on GET stream", saw_progress, str(msgs)[:200]) t.join(timeout=25) -saw_hb = got_heartbeat.is_set() -check("sse heartbeat", saw_hb, str(msgs)[:200]) +check("sse heartbeat", got_heartbeat.is_set(), str(msgs)[:200]) -# 6. 取消:连接 A 长命令,连接 B 发 cancelled -st, ct, data = post({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo nothing"}}}) # 预热(连接池无关,每次新连接) +# 5b. POST + SSE:进度通知与结果在同一个响应流里 +st, hdrs, data = post_h({"jsonrpc": "2.0", "id": 41, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "for x in 1 2 3; do echo p-$x; sleep 0.3; done"}, "_meta": {"progressToken": "p1"}}}, accept="application/json, text/event-stream", sid=sid_a) +evs = sse_events(data) +prog = [e for e in evs if e.get("method") == "notifications/progress" and e["params"]["progressToken"] == "p1"] +final = [e for e in evs if e.get("id") == 41] +check("post sse progress + result", st == 200 and prog and final and final[0]["result"]["structuredContent"]["stdout"].count("p-") == 3, str(evs)[:300]) +check("post sse result is last", evs and evs[-1].get("id") == 41, str(evs)[-200:]) +# 6. 取消:连接 A 长命令,连接 B 发 cancelled(同一作用域) def long_cmd(): return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}}) res = {} t2 = threading.Thread(target=lambda: res.update(r=long_cmd())) t2.start() time.sleep(0.5) +# 其他会话发同 id 的取消不能误杀 +st, _, _ = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}}, sid=sid_a) +time.sleep(0.3) +check("cancel from other scope ignored", t2.is_alive()) st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}}) check("cancel notif 202", st == 202) t2.join(timeout=10) @@ -112,24 +149,16 @@ check("http concurrent", json.loads(results[10][2])["result"]["structuredContent # 8. 错误路径 st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"}) check("unknown method", st == 200) -c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) -c.request("POST", "/mcp", body=b"this is {not json", headers={"Content-Type": "application/json", "Accept": "application/json"}) -r = c.getresponse(); bd = r.read(); c.close() -check("bad json -> 400", r.status == 400, str(r.status)) +st, _, _ = request("POST", "/mcp", b"this is {not json", {"Content-Type": "application/json", "Accept": "application/json"}) +check("bad json -> 400", st == 400, str(st)) st, _, data = post({"jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": "nope", "arguments": {}}}) check("unknown tool -32602", json.loads(data)["error"]["code"] == -32602) -c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) -c.request("GET", "/other", headers={"Accept": "text/event-stream"}) -r = c.getresponse(); r.read(); c.close() -check("404 other path", r.status == 404) -c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) -c.request("PUT", "/mcp") -r = c.getresponse(); r.read(); c.close() -check("405 method", r.status == 405) -c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) -c.request("OPTIONS", "/mcp") -r = c.getresponse(); r.read(); c.close() -check("options cors", r.status == 204 and r.getheader("Access-Control-Allow-Origin") == "*") +st, _, _ = request("GET", "/other", headers={"Accept": "text/event-stream"}) +check("404 other path", st == 404) +st, _, _ = request("PUT", "/mcp") +check("405 method", st == 405) +st, hdrs, _ = request("OPTIONS", "/mcp") +check("options cors", st == 204 and hdrs.get("access-control-allow-origin") == "*" and "Mcp-Session-Id" in hdrs.get("access-control-allow-headers", "")) # 9. origin 校验 st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com") @@ -144,10 +173,81 @@ r = c.getresponse() c.close() check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type")) +# 11. MCP 会话:隔离、未知 ID → 404、DELETE 终止 +st, hdrs, _ = post_h(INIT) +sid_b = hdrs.get("mcp-session-id") +r = call("shell_open", {}, sid=sid_a) +shell_id = r["result"]["structuredContent"]["session_id"] +r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b) +check("shell session isolated", r["result"].get("isError") is True, str(r)) +r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_a) +check("shell session owner ok", r["result"].get("isError") is None, str(r)) +st, _, _ = post_h({"jsonrpc": "2.0", "id": 60, "method": "ping"}, sid="0" * 32) +check("unknown session 404", st == 404, str(st)) +st, _, _ = request("DELETE", "/mcp", headers={"Mcp-Session-Id": sid_a}) +check("delete session", st == 200, str(st)) +st, _, _ = post_h({"jsonrpc": "2.0", "id": 61, "method": "ping"}, sid=sid_a) +check("deleted session 404", st == 404, str(st)) +r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b) +check("deleted session shells closed", r["result"].get("isError") is True) + +# 12. HTTP 客户端不能用 notifications/exit 关停服务 +post({"jsonrpc": "2.0", "method": "notifications/exit"}) +time.sleep(0.4) +st, _, _ = post({"jsonrpc": "2.0", "id": 70, "method": "ping"}) +check("exit notif ignored over http", st == 200 and proc.poll() is None) + +# 13. tools/list 含 file_link +st, _, data = post({"jsonrpc": "2.0", "id": 71, "method": "tools/list"}) +names = [t["name"] for t in json.loads(data)["result"]["tools"]] +check("tools/list http", names[-4:] == ["file_read", "file_write", "file_stat", "file_link"], str(names)) + +# 14. /files:Bearer 认证、签名链接、Range、chunked 上传、目录限制 +AUTH = {"Authorization": f"Bearer {TOKEN}"} +fpath = os.path.join(tmpdir, "data.bin") +payload = os.urandom(300_000) +# 未认证请求在读取 body 前即返回 401 并关闭连接,这里用小 body 避免客户端写入时 broken pipe +st, _, _ = request("PUT", f"/files?path={fpath}", b"x", port=PORT2) +check("files put needs token", st == 401 and not os.path.exists(fpath), str(st)) +st, _, data = request("PUT", f"/files?path={fpath}", payload, AUTH, port=PORT2) +info = json.loads(data) +check("files put", st == 201 and info["sha256"] == hashlib.sha256(payload).hexdigest(), f"{st} {data[:200]}") +st, hdrs, data = request("GET", f"/files?path={fpath}", headers=AUTH, port=PORT2) +check("files get", st == 200 and data == payload and hdrs.get("accept-ranges") == "bytes") +st, hdrs, data = request("GET", f"/files?path={fpath}", headers={**AUTH, "Range": "bytes=100-199"}, port=PORT2) +check("files range", st == 206 and data == payload[100:200] and hdrs.get("content-range") == f"bytes 100-199/{len(payload)}", f"{st} {hdrs}") +st, _, _ = request("GET", "/files?path=/etc/hosts", headers=AUTH, port=PORT2) +check("files outside root 403", st == 403, str(st)) +# chunked 上传(http.client 对迭代器 body 使用 chunked 编码) +cpath = os.path.join(tmpdir, "chunked.txt") +c = http.client.HTTPConnection("127.0.0.1", PORT2, timeout=10) +c.request("PUT", f"/files?path={cpath}&mkdirs=1", body=iter([b"hello ", b"chunked ", b"world"]), headers=AUTH, encode_chunked=True) +r = c.getresponse(); r.read(); c.close() +check("files chunked put", r.status == 201 and open(cpath, "rb").read() == b"hello chunked world", str(r.status)) +# 签名链接:不带 token 也能访问,且仅限签名的路径/方法 +st, _, data = post_h(INIT, port=PORT2, auth=AUTH["Authorization"]) +r = call("file_link", {"path": fpath}, port=PORT2, auth=AUTH["Authorization"]) +url = r["result"]["structuredContent"]["url"] +u = urlsplit(url) +check("file_link url", u.netloc == f"127.0.0.1:{PORT2}" and u.path == "/files" and "curl" in r["result"]["structuredContent"]["curl"], url) +st, _, data = request("GET", f"{u.path}?{u.query}", port=PORT2) +check("signed get", st == 200 and data == payload, str(st)) +st, _, _ = request("PUT", f"{u.path}?{u.query}", b"x", port=PORT2) +check("signed get cannot put", st == 403, str(st)) +r = call("file_link", {"path": os.path.join(tmpdir, "up", "new.txt"), "method": "PUT", "mkdirs": True}, port=PORT2, auth=AUTH["Authorization"]) +u = urlsplit(r["result"]["structuredContent"]["url"]) +st, _, data = request("PUT", f"{u.path}?{u.query}", b"uploaded via link", {"Expect": "100-continue"}, port=PORT2) +check("signed put", st == 201 and open(os.path.join(tmpdir, "up", "new.txt"), "rb").read() == b"uploaded via link", f"{st} {data[:200]}") +st, _, _ = request("GET", f"{u.path}?{u.query.replace('sig=', 'sig=0')}", port=PORT2) +check("bad signature 403", st == 403, str(st)) +r = call("file_link", {"path": "/etc/hosts"}, port=PORT2, auth=AUTH["Authorization"]) +check("file_link outside root", r["result"].get("isError") is True) + proc.kill() +proc2.kill() err = proc.stderr.read().decode() +shutil.rmtree(tmpdir, ignore_errors=True) print(f"\n=== HTTP: {passed} passed, {failed} failed ===") -if err.strip(): +if failed and err.strip(): print("bolt stderr:", err[:400]) -sys_exit = 1 if failed else 0 -import sys; sys.exit(sys_exit) \ No newline at end of file +sys.exit(1 if failed else 0) diff --git a/docs/bolt_stdio_test.py b/docs/bolt_stdio_test.py index 03bd2a4..fe1d453 100644 --- a/docs/bolt_stdio_test.py +++ b/docs/bolt_stdio_test.py @@ -3,8 +3,9 @@ import faulthandler, json, os, subprocess, sys, threading, time -BOLT = "/root/bolt/target/release/bolt" -proc = subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) +# 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物 +BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt") +proc =subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) sent = {} next_id = [0] @@ -79,7 +80,7 @@ send_notif("notifications/initialized") i = send("tools/list") r = wait_id(i) names = [t["name"] for t in r["result"]["tools"]] -check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close"], str(names)) +check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close", "file_read", "file_write", "file_stat"], str(names)) # 3. run_command 基本 i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}}) @@ -138,25 +139,68 @@ i2 = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep r1, r2 = wait_id(i1), wait_id(i2) check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO") -# 9. shell 会话 +# 9. shell 会话(结果为标准 MCP 格式:content + structuredContent) i = send("tools/call", {"name": "shell_open", "arguments": {}}) r = wait_id(i) -sid = r["result"]["session_id"] -check("shell open", isinstance(sid, int)) +sid = r["result"]["structuredContent"]["session_id"] +check("shell open", isinstance(sid, int) and isinstance(r["result"]["content"], list), str(r)) send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}}) # 输出先于退出标志到达,循环读取直到会话退出 all_out = "" for _ in range(10): i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}}) r = wait_id(i) - res = r["result"] + res = r["result"]["structuredContent"] all_out += res["output"] if res["exited"]: break check("shell read output", "IN_TMP" in all_out and res["exited"], f"out={all_out!r}") i = send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}}) r = wait_id(i) -check("shell close", r["result"]["closed"] is True) +check("shell close", r["result"]["structuredContent"]["closed"] is True) + +# 9b. shell_read 阻塞等待时可被取消 +i = send("tools/call", {"name": "shell_open", "arguments": {}}) +sid = wait_id(i)["result"]["structuredContent"]["session_id"] +i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 0}}) +time.sleep(0.3) +send_notif("notifications/cancelled", {"requestId": i}) +r = wait_id(i, timeout=5) +check("shell_read cancel", r["result"]["structuredContent"]["cancelled"] is True, str(r)) +wait_id(send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}})) + +# 9c. 后台进程占用输出管道时不阻塞 +t0 = time.time() +i = send("tools/call", {"name": "run_command", "arguments": {"command": "(sleep 5; echo late) & echo now"}}) +r = wait_id(i) +sc = r["result"]["structuredContent"] +check("background detach", sc["stdout"] == "now\n" and sc["detached"] and time.time() - t0 < 3, f"{sc} dt={time.time() - t0:.1f}") + +# 9d. 文件读写(文本 / base64 / 分块) +import base64, hashlib, tempfile +tmpdir = tempfile.mkdtemp(prefix="bolt-e2e-") +fp = os.path.join(tmpdir, "sub", "hello.txt") +i = send("tools/call", {"name": "file_write", "arguments": {"path": fp, "content": "你好, bolt\n", "mkdirs": True}}) +sc = wait_id(i)["result"]["structuredContent"] +check("file_write text", sc["sha256"] == hashlib.sha256("你好, bolt\n".encode()).hexdigest(), str(sc)) +i = send("tools/call", {"name": "file_read", "arguments": {"path": fp, "length": 4}}) +content = wait_id(i)["result"]["content"] +meta = json.loads(content[0]["text"]) +check("file_read chunk utf8", content[1]["text"] == "你" and meta["next_offset"] == 3 and not meta["eof"], str(content)) +blob = bytes(range(256)) +bp = os.path.join(tmpdir, "blob.bin") +i = send("tools/call", {"name": "file_write", "arguments": {"path": bp, "content": base64.b64encode(blob).decode(), "encoding": "base64"}}) +wait_id(i) +i = send("tools/call", {"name": "file_read", "arguments": {"path": bp}}) +content = wait_id(i)["result"]["content"] +check("file_read binary", json.loads(content[0]["text"])["encoding"] == "base64" and base64.b64decode(content[1]["text"]) == blob) +i = send("tools/call", {"name": "file_stat", "arguments": {"path": bp, "sha256": True}}) +sc = wait_id(i)["result"]["structuredContent"] +check("file_stat sha256", sc["size"] == 256 and sc["sha256"] == hashlib.sha256(blob).hexdigest(), str(sc)) +i = send("tools/call", {"name": "file_link", "arguments": {"path": bp}}) +r = wait_id(i) +check("file_link stdio unavailable", "error" in r and r["error"]["code"] == -32602 or r["result"].get("isError") is True, str(r)) +import shutil; shutil.rmtree(tmpdir, ignore_errors=True) # 10. stdio 关闭 → 进程退出 send_notif("notifications/exit") diff --git a/src/main.rs b/src/main.rs index c4cc9d8..1e78b0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,46 +1,55 @@ //! bolt — 极速单文件 MCP 命令执行服务器 //! -//! AI(Claude Desktop / Claude Code / Cursor 等 MCP 客户端)连接后即可在服务器上执行命令。 +//! AI(Claude Desktop / Claude Code / Cursor 等 MCP 客户端)连接后即可在服务器上执行命令、传输文件。 //! //! 特性: //! * 零框架、纯手写 JSON-RPC 2.0 + MCP 协议(仅依赖 serde_json,Unix 下加 libc) -//! * 双传输:stdio(本地 AI 客户端默认方式)与 Streamable HTTP(远程连接) +//! * 双传输:stdio(本地 AI 客户端默认方式)与 Streamable HTTP(远程连接,支持 Mcp-Session-Id) //! * 工具: //! - run_command 执行 shell 命令(支持管道/重定向/超时/环境变量/输出截断/进度通知) //! - shell_open 开启持久化交互式 shell 会话(状态保留,可连续对话) //! - shell_write 向会话写入输入 //! - shell_read 读取会话输出(可阻塞等待) //! - shell_close 关闭会话 +//! - file_read 读取文件(文本 / base64 / 图片,支持分块) +//! - file_write 写入文件(原子替换 / 新建 / 追加,支持 base64) +//! - file_stat 文件信息与 SHA-256 +//! - file_link 生成带签名的 HTTP 直传链接(大文件上传/下载不经过模型) +//! * HTTP 直传:GET/PUT /files?path=...(Bearer 认证或签名链接,支持 Range / chunked / 100-continue) //! * 取消:客户端发送 notifications/cancelled 可杀掉进行中的命令(Unix 下按进程组杀,连带子进程) -//! * 安全:命令 allow/deny 前缀白黑名单、HTTP Bearer Token、Origin 校验、默认本机绑定 -//! * 性能:单线程事件循环 + 复用缓冲区、原子计数、无锁热路径、LTO fat + codegen-units=1 +//! * 安全:命令 allow/deny 名单、文件目录限制(--file-root)、HTTP Bearer Token、Origin 校验、默认本机绑定 +//! * 性能:复用缓冲区、原子计数、LTO fat + codegen-units=1;并发调用与连接数有上限 //! //! 用法: //! bolt # stdio 模式(默认,MCP 客户端拉起本进程) //! bolt --http 127.0.0.1:8080 # Streamable HTTP 模式,远程 AI 客户端连接 -//! bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" +//! bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" --file-root /srv/data //! //! 环境变量(CLI 参数优先):BOLT_TOKEN / BOLT_ALLOW / BOLT_DENY / BOLT_TIMEOUT_MS / -//! BOLT_CWD / BOLT_MAX_OUTPUT / BOLT_ALLOW_ORIGIN +//! BOLT_CWD / BOLT_MAX_OUTPUT / BOLT_ALLOW_ORIGIN / BOLT_FILE_ROOT / BOLT_MAX_UPLOAD //! //! 客户端配置示例: //! Claude Code(本地): claude mcp add --transport stdio bolt -- /abs/path/bolt //! Claude Code(远程): claude mcp add --transport http bolt http://host:8080/mcp //! 通用 MCP 客户端: {"mcpServers": {"bolt": {"command": "/abs/path/bolt"}}} +use std::borrow::Cow; use std::collections::HashMap; use std::env; -use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::{mpsc, Arc, Condvar, Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::sync::OnceLock; use serde_json::{json, Value}; @@ -54,14 +63,30 @@ const LATEST_PROTOCOL: &str = "2025-06-18"; const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; // stdio 单条消息上限 const MAX_HTTP_HEAD: usize = 64 * 1024; // HTTP 请求头上限 -const MAX_HTTP_BODY: usize = 4 * 1024 * 1024; // HTTP body 上限 +const MAX_HTTP_BODY: usize = 16 * 1024 * 1024; // /mcp 请求 body 上限(file_write 的 base64 内容走这里) const DEFAULT_TIMEOUT_MS: u64 = 30_000; // 命令默认超时 const MAX_TIMEOUT_MS: u64 = 6 * 3600 * 1000; // 命令超时上限(6h) const DEFAULT_MAX_OUTPUT: usize = 1024 * 1024; // 单流默认捕获上限 const MAX_MAX_OUTPUT: usize = 64 * 1024 * 1024; -const SESSION_BUF_CAP: usize = 16 * 1024 * 1024; // 会话环形缓冲上限 +const PIPE_DRAIN_GRACE: Duration = Duration::from_millis(300); // 主进程退出后等待输出管道关闭的宽限 +const SESSION_BUF_CAP: usize = 16 * 1024 * 1024; // 会话输出缓冲上限 const SESSION_WRITE_TIMEOUT: Duration = Duration::from_secs(10); // 会话写入超时 +const SESSION_IDLE: Duration = Duration::from_secs(30 * 60); // shell 会话空闲回收 +const MAX_SHELL_SESSIONS: usize = 64; +const MCP_SESSION_IDLE: Duration = Duration::from_secs(24 * 3600); // MCP 会话(Mcp-Session-Id)空闲回收 +const MAX_MCP_SESSIONS: usize = 1024; +const JANITOR_INTERVAL: Duration = Duration::from_secs(30); +const MAX_CONCURRENT_CALLS: usize = 128; // 同时进行的工具调用上限 +const MAX_HTTP_CONNS: usize = 512; // 同时打开的 HTTP 连接上限 const SSE_HEARTBEAT: Duration = Duration::from_secs(10); // SSE 保活间隔 +const DEFAULT_READ_LEN: usize = 256 * 1024; // file_read 默认单次读取 +const MAX_READ_LEN: usize = 8 * 1024 * 1024; +const DEFAULT_MAX_UPLOAD: u64 = 2 * 1024 * 1024 * 1024; // PUT /files 默认上限 +const DEFAULT_LINK_TTL: u64 = 600; // file_link 默认有效期(秒) +const MAX_LINK_TTL: u64 = 24 * 3600; + +const STDIO_SCOPE: &str = "stdio"; // stdio 模式下唯一的客户端作用域 +const ANON_SCOPE: &str = "-"; // HTTP 下未携带 Mcp-Session-Id 的客户端共用的作用域 // --------------------------------------------------------------------------- // 配置 @@ -81,6 +106,25 @@ struct Config { cwd: Option, max_output: usize, allow_origins: Vec, + file_roots: Vec, // 已规范化;为空表示不限制目录 + max_upload: u64, +} + +impl Default for Config { + fn default() -> Self { + Config { + mode: Mode::Stdio, + token: None, + allow: Vec::new(), + deny: Vec::new(), + timeout_ms: DEFAULT_TIMEOUT_MS, + cwd: None, + max_output: DEFAULT_MAX_OUTPUT, + allow_origins: Vec::new(), + file_roots: Vec::new(), + max_upload: DEFAULT_MAX_UPLOAD, + } + } } fn split_list(s: &str) -> Vec { @@ -103,15 +147,21 @@ fn parse_size(s: &str) -> Option { num.parse::().ok().map(|n| n.saturating_mul(mult)) } +fn env_nonempty(k: &str) -> Option { + env::var(k).ok().filter(|s| !s.is_empty()) +} + fn parse_args() -> Option { let mut mode = Mode::Stdio; let mut token: Option = None; - let mut allow: Vec = Vec::new(); - let mut deny: Vec = Vec::new(); - let mut timeout_ms = DEFAULT_TIMEOUT_MS; + let mut allow: Option> = None; + let mut deny: Option> = None; + let mut timeout_ms: Option = None; let mut cwd: Option = None; - let mut max_output = DEFAULT_MAX_OUTPUT; + let mut max_output: Option = None; let mut allow_origins: Vec = Vec::new(); + let mut file_roots_raw: Option> = None; + let mut max_upload: Option = None; let args: Vec = env::args().skip(1).collect(); let mut i = 0; @@ -123,14 +173,21 @@ fn parse_args() -> Option { }; match a { "-h" | "--help" => return None, - "--http" => mode = Mode::Http(next(&mut i)?), + "--http" => { + let addr = next(&mut i)?; + // ":8080" 视为仅本机监听 + let addr = if addr.starts_with(':') { format!("127.0.0.1{addr}") } else { addr }; + mode = Mode::Http(addr) + } "--token" => token = next(&mut i), - "--allow" => allow = split_list(&next(&mut i)?), - "--deny" => deny = split_list(&next(&mut i)?), - "--timeout-ms" => timeout_ms = next(&mut i)?.parse().ok()?, + "--allow" => allow = Some(split_list(&next(&mut i)?)), + "--deny" => deny = Some(split_list(&next(&mut i)?)), + "--timeout-ms" => timeout_ms = Some(next(&mut i)?.parse().ok()?), "--cwd" => cwd = next(&mut i), - "--max-output" => max_output = parse_size(&next(&mut i)?)?, + "--max-output" => max_output = Some(parse_size(&next(&mut i)?)?), "--allow-origin" => allow_origins.push(next(&mut i)?), + "--file-root" => file_roots_raw = Some(split_list(&next(&mut i)?)), + "--max-upload" => max_upload = Some(parse_size(&next(&mut i)?)? as u64), _ => { eprintln!("[bolt] 未知参数: {a}"); return None; @@ -139,31 +196,37 @@ fn parse_args() -> Option { i += 1; } // 环境变量回退(CLI 优先) - if token.is_none() { - token = env::var("BOLT_TOKEN").ok().filter(|s| !s.is_empty()); - } - if allow.is_empty() { - allow = split_list(&env::var("BOLT_ALLOW").unwrap_or_default()); - } - if deny.is_empty() { - deny = split_list(&env::var("BOLT_DENY").unwrap_or_default()); - } - if let Ok(v) = env::var("BOLT_TIMEOUT_MS") { - if let Ok(n) = v.parse() { - timeout_ms = n; - } - } - if cwd.is_none() { - cwd = env::var("BOLT_CWD").ok().filter(|s| !s.is_empty()); - } - if let Ok(v) = env::var("BOLT_MAX_OUTPUT") { - if let Some(n) = parse_size(&v) { - max_output = n; - } - } + let token = token.or_else(|| env_nonempty("BOLT_TOKEN")); + let allow = allow.unwrap_or_else(|| split_list(&env::var("BOLT_ALLOW").unwrap_or_default())); + let deny = deny.unwrap_or_else(|| split_list(&env::var("BOLT_DENY").unwrap_or_default())); + let timeout_ms = timeout_ms + .or_else(|| env::var("BOLT_TIMEOUT_MS").ok().and_then(|v| v.parse().ok())) + .unwrap_or(DEFAULT_TIMEOUT_MS); + let cwd = cwd.or_else(|| env_nonempty("BOLT_CWD")); + let max_output = max_output + .or_else(|| env::var("BOLT_MAX_OUTPUT").ok().and_then(|v| parse_size(&v))) + .unwrap_or(DEFAULT_MAX_OUTPUT); if allow_origins.is_empty() { allow_origins = split_list(&env::var("BOLT_ALLOW_ORIGIN").unwrap_or_default()); } + let file_roots_raw = file_roots_raw.unwrap_or_else(|| split_list(&env::var("BOLT_FILE_ROOT").unwrap_or_default())); + let max_upload = max_upload + .or_else(|| env::var("BOLT_MAX_UPLOAD").ok().and_then(|v| parse_size(&v)).map(|n| n as u64)) + .unwrap_or(DEFAULT_MAX_UPLOAD); + let mut file_roots = Vec::new(); + for r in &file_roots_raw { + match fs::canonicalize(r) { + Ok(p) if p.is_dir() => file_roots.push(p), + Ok(_) => { + eprintln!("[bolt] --file-root 不是目录: {r}"); + std::process::exit(2); + } + Err(e) => { + eprintln!("[bolt] --file-root 无效 {r}: {e}"); + std::process::exit(2); + } + } + } Some(Config { mode, token, @@ -173,30 +236,35 @@ fn parse_args() -> Option { cwd, max_output, allow_origins, + file_roots, + max_upload, }) } fn print_help() { println!( r#"bolt {VERSION} — 极速单文件 MCP 命令执行服务器 -AI 通过 MCP(stdio 或 Streamable HTTP)连接后即可在服务器上执行命令。 +AI 通过 MCP(stdio 或 Streamable HTTP)连接后即可在服务器上执行命令、传输文件。 用法: bolt [选项] 选项: - --http 启用 Streamable HTTP 传输并监听(如 127.0.0.1:8080 或 :8080) + --http 启用 Streamable HTTP 传输并监听(如 127.0.0.1:8080;":8080" 等价于 127.0.0.1:8080) 缺省为 stdio 模式(由 MCP 客户端拉起本进程) --token HTTP 模式要求 Authorization: Bearer - --allow 命令白名单(命令首词前缀匹配,非空时仅允许列出的命令) - --deny 命令黑名单(前缀匹配) + --allow 命令白名单(按程序名精确匹配;非空时 shell 模式禁止管道/重定向/命令串联等元字符) + --deny 命令黑名单(按程序名精确匹配命令中每一段;尽力而为,不能替代白名单) --timeout-ms 命令默认超时毫秒(0 = 无限,工具参数可覆盖;默认 30000) - --cwd 命令默认工作目录 + --cwd 命令与相对文件路径的默认工作目录 --max-output 单流输出捕获上限,支持 K/M/G 后缀(默认 1M) --allow-origin 额外允许的 HTTP Origin(默认仅 localhost/127.0.0.1) + --file-root 文件工具与 /files 只能访问这些目录(配置了 --allow 时必须设置,否则文件功能禁用) + --max-upload PUT /files 单次上传上限,支持 K/M/G 后缀(默认 2G) -h, --help 显示帮助 -环境变量(CLI 优先):BOLT_TOKEN BOLT_ALLOW BOLT_DENY BOLT_TIMEOUT_MS BOLT_CWD BOLT_MAX_OUTPUT BOLT_ALLOW_ORIGIN +环境变量(CLI 优先):BOLT_TOKEN BOLT_ALLOW BOLT_DENY BOLT_TIMEOUT_MS BOLT_CWD BOLT_MAX_OUTPUT + BOLT_ALLOW_ORIGIN BOLT_FILE_ROOT BOLT_MAX_UPLOAD 客户端配置: Claude Code 本地: claude mcp add --transport stdio bolt -- /abs/path/bolt @@ -207,34 +275,41 @@ AI 通过 MCP(stdio 或 Streamable HTTP)连接后即可在服务器上执行命 } // --------------------------------------------------------------------------- -// 出站通道:stdio 单写者锁 / HTTP 广播到 SSE 流 +// 出站通道:stdio 单写者锁 / HTTP 按作用域推送到 GET SSE 流 / POST 的 SSE 响应流 // --------------------------------------------------------------------------- struct Hub { - map: Mutex>>, + map: Mutex)>>, // 流 id -> (作用域, 发送端) next: AtomicU64, } impl Hub { - fn add(&self, tx: mpsc::Sender) -> u64 { + fn add(&self, scope: &str, tx: mpsc::Sender) -> u64 { let id = self.next.fetch_add(1, Ordering::Relaxed) + 1; - self.map.lock().unwrap().insert(id, tx); + self.map.lock().unwrap().insert(id, (scope.to_string(), tx)); id } fn remove(&self, id: u64) { self.map.lock().unwrap().remove(&id); } - /// 广播(发送失败即剔除死连接;mpsc 无界通道,不阻塞) - fn broadcast(&self, msg: &str) { + /// 推送到该作用域的全部 GET 流(发送失败即剔除死连接;mpsc 无界通道,不阻塞) + fn send_to(&self, scope: &str, msg: &str) { let mut map = self.map.lock().unwrap(); - map.retain(|_, tx| tx.send(msg.to_string()).is_ok()); + map.retain(|_, (s, tx)| s != scope || tx.send(msg.to_string()).is_ok()); + } + /// 关闭该作用域的全部 GET 流(丢弃发送端,流线程随即退出) + fn drop_scope(&self, scope: &str) { + self.map.lock().unwrap().retain(|_, (s, _)| s != scope); } } #[derive(Clone)] enum Sink { Stdio(Arc>), - Http(Arc), + Hub(Arc, String), + Chan(mpsc::Sender), + #[cfg(test)] + Null, } impl Sink { @@ -248,45 +323,67 @@ impl Sink { let _ = g.flush(); } } - Sink::Http(h) => h.broadcast(msg), + Sink::Hub(h, scope) => h.send_to(scope, msg), + Sink::Chan(tx) => { + let _ = tx.send(msg.to_string()); + } + #[cfg(test)] + Sink::Null => {} } } } +/// 单个请求的上下文:客户端作用域(隔离会话与取消)、通知出口、对外 URL(file_link 用) +#[derive(Clone)] +struct Ctx { + scope: String, + sink: Sink, + base_url: Option, +} + // --------------------------------------------------------------------------- // 应用状态 // --------------------------------------------------------------------------- struct App { cfg: Config, - sink: Sink, + tools: Value, + auth: Option>, // 期望的 Authorization 头完整值 + stdout: Arc>, hub: Arc, exit_flag: Arc, - inflight: Mutex>>, // 进行中的命令(id -> 可杀句柄) + inflight: Mutex>>, // 进行中的调用(作用域+id -> 可杀句柄) sessions: Mutex>>, next_session: AtomicU64, + mcp_sessions: Mutex>, // Mcp-Session-Id -> 最近活动时间 + active_calls: AtomicUsize, + active_conns: AtomicUsize, + link_key: [u8; 32], // file_link 签名密钥(进程启动时随机生成,重启后旧链接失效) } type Shared = Arc; impl App { fn new(cfg: Config) -> App { - let hub = Arc::new(Hub { - map: Mutex::new(HashMap::new()), - next: AtomicU64::new(0), - }); - let sink = match &cfg.mode { - Mode::Stdio => Sink::Stdio(Arc::new(Mutex::new(io::stdout()))), - Mode::Http(_) => Sink::Http(hub.clone()), - }; + let mut link_key = [0u8; 32]; + random_bytes(&mut link_key); App { + tools: build_tools(&cfg), + auth: cfg.token.as_ref().map(|t| format!("Bearer {t}").into_bytes()), cfg, - sink, - hub, + stdout: Arc::new(Mutex::new(io::stdout())), + hub: Arc::new(Hub { + map: Mutex::new(HashMap::new()), + next: AtomicU64::new(0), + }), exit_flag: Arc::new(AtomicBool::new(false)), inflight: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), next_session: AtomicU64::new(0), + mcp_sessions: Mutex::new(HashMap::new()), + active_calls: AtomicUsize::new(0), + active_conns: AtomicUsize::new(0), + link_key, } } } @@ -294,12 +391,18 @@ impl App { /// 进程组杀掉 pid 对应的整棵进程树 #[cfg(unix)] fn kill_tree(pid: u32) { + if pid == 0 { + return; + } unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } } #[cfg(windows)] fn kill_tree(pid: u32) { + if pid == 0 { + return; + } let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .stdin(Stdio::null()) @@ -309,22 +412,13 @@ fn kill_tree(pid: u32) { } /// 可取消句柄:注册 pid 前收到取消则标记,注册后立即补杀 +#[derive(Default)] struct Killable { pid: AtomicU32, want_kill: AtomicBool, cancelled: AtomicBool, } -impl Default for Killable { - fn default() -> Self { - Killable { - pid: AtomicU32::new(0), - want_kill: AtomicBool::new(false), - cancelled: AtomicBool::new(false), - } - } -} - impl Killable { fn register_pid(&self, pid: u32) { self.pid.store(pid, Ordering::SeqCst); @@ -342,22 +436,46 @@ impl Killable { kill_tree(p); } } + fn is_killed(&self) -> bool { + self.want_kill.load(Ordering::SeqCst) + } } -fn id_key(id: &Value) -> String { - serde_json::to_string(id).unwrap_or_default() +/// 进行中调用的键:按客户端作用域隔离,避免不同客户端的相同请求 id 互相干扰 +fn inflight_key(scope: &str, id: &Value) -> String { + format!("{scope}\n{}", serde_json::to_string(id).unwrap_or_default()) } -fn register_inflight(app: &Shared, id: &Value) -> Arc { +fn register_inflight(app: &Shared, scope: &str, id: &Value) -> Arc { let k = Arc::new(Killable::default()); - app.inflight.lock().unwrap().insert(id_key(id), k.clone()); + app.inflight.lock().unwrap().insert(inflight_key(scope, id), k.clone()); k } -fn unregister_inflight(app: &Shared, id: &Value, k: &Arc) { +fn unregister_inflight(app: &Shared, scope: &str, id: &Value, k: &Arc) { + let key = inflight_key(scope, id); let mut m = app.inflight.lock().unwrap(); - if m.get(&id_key(id)).map(|x| Arc::ptr_eq(x, k)).unwrap_or(false) { - m.remove(&id_key(id)); + if m.get(&key).map(|x| Arc::ptr_eq(x, k)).unwrap_or(false) { + m.remove(&key); + } +} + +/// 并发工具调用名额(RAII 归还) +struct CallSlot(Shared); + +impl CallSlot { + fn acquire(app: &Shared) -> Option { + if app.active_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CONCURRENT_CALLS { + app.active_calls.fetch_sub(1, Ordering::SeqCst); + return None; + } + Some(CallSlot(app.clone())) + } +} + +impl Drop for CallSlot { + fn drop(&mut self) { + self.0.active_calls.fetch_sub(1, Ordering::SeqCst); } } @@ -374,20 +492,444 @@ const PARSE_ERROR: &str = /// 工具执行错误(非协议错误):结果中 isError: true fn tool_error(msg: &str) -> Value { - json!({"content": [{"type": "text", "text": format!("error: {msg}")}], "isError": true}) + json!({"content": [{"type": "text", "text": msg}], "isError": true}) } -/// 命令白/黑名单检查(first_token 为命令首词) -fn policy_check(cfg: &Config, first_token: &str) -> Result<(), String> { - if !cfg.deny.is_empty() && cfg.deny.iter().any(|d| first_token.starts_with(d)) { - return Err(format!("命令 '{first_token}' 被服务器策略禁止")); +/// 工具成功结果:同时提供 content(文本 JSON)与 structuredContent +fn tool_ok(v: Value) -> Value { + json!({"content": [{"type": "text", "text": v.to_string()}], "structuredContent": v}) +} + +fn wrap(r: Result) -> Value { + match r { + Ok(v) => tool_ok(v), + Err(e) => tool_error(&e), } - if !cfg.allow.is_empty() && !cfg.allow.iter().any(|a| first_token.starts_with(a)) { - return Err(format!("命令 '{first_token}' 不在服务器白名单中")); +} + +type ArgResult = Result; + +fn req_str<'a>(a: &'a Value, k: &str) -> ArgResult<&'a str> { + a.get(k).and_then(Value::as_str).ok_or_else(|| (-32602, format!("'{k}' 必须是字符串"))) +} + +fn opt_str<'a>(a: &'a Value, k: &str) -> ArgResult> { + match a.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().map(Some).ok_or_else(|| (-32602, format!("'{k}' 必须是字符串"))), + } +} + +fn req_u64(a: &Value, k: &str) -> ArgResult { + a.get(k).and_then(Value::as_u64).ok_or_else(|| (-32602, format!("'{k}' 必须是非负整数"))) +} + +fn opt_u64(a: &Value, k: &str) -> ArgResult> { + match a.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| (-32602, format!("'{k}' 必须是非负整数"))), + } +} + +fn opt_bool(a: &Value, k: &str) -> ArgResult> { + match a.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_bool().map(Some).ok_or_else(|| (-32602, format!("'{k}' 必须是布尔值"))), + } +} + +// --------------------------------------------------------------------------- +// 命令策略 +// --------------------------------------------------------------------------- +// +// 白名单(allow 非空): +// * 按程序名(路径最后一段)精确匹配,`git` 不再放行 `gitk` +// * shell 模式下禁止元字符(; & | ` $ < > ( ) { } 换行),杜绝 `git x; rm -rf ~` 之类的串联绕过 +// * 注意:白名单内的程序若自身能执行任意命令(sh、env、docker、find -exec、git -c ...),白名单无法约束 +// 黑名单(deny):检查命令按元字符切分后每一段的程序名;只是尽力而为,真正的限制请用白名单。 + +const SHELL_META: &str = ";&|`$<>(){}\n\r"; + +fn is_meta(c: char) -> bool { + SHELL_META.contains(c) || (cfg!(windows) && "^%!".contains(c)) +} + +/// 程序名归一化:去掉引号(Unix 下还去掉反斜杠转义),取路径最后一段;Windows 下忽略大小写与可执行扩展名 +fn program_name(tok: &str) -> String { + let mut t: String = tok.chars().filter(|c| !matches!(c, '"' | '\'')).collect(); + if cfg!(unix) { + t = t.replace('\\', ""); + } + let base = t.rsplit(['/', '\\']).next().unwrap_or("").to_string(); + if cfg!(windows) { + let lower = base.to_ascii_lowercase(); + for ext in [".exe", ".cmd", ".bat", ".com"] { + if let Some(s) = lower.strip_suffix(ext) { + return s.to_string(); + } + } + return lower; + } + base +} + +/// 形如 NAME=value 的前置环境变量赋值 +fn is_assignment(w: &str) -> bool { + match w.find('=') { + Some(i) if i > 0 => w[..i].chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), + _ => false, + } +} + +/// shell 命令行中各段(按元字符切分)要执行的程序名 +fn segment_programs(cmd: &str) -> Vec { + cmd.split(is_meta) + .filter_map(|seg| seg.split_whitespace().find(|w| !is_assignment(w))) + .map(program_name) + .filter(|s| !s.is_empty()) + .collect() +} + +fn name_listed(list: &[String], name: &str) -> bool { + list.iter().any(|x| program_name(x) == name) +} + +/// 命令策略检查。shell=true 时 command 为 shell 命令行;shell=false 时 command 为程序路径 +fn policy_check(cfg: &Config, command: &str, shell: bool) -> Result<(), String> { + if cfg.allow.is_empty() && cfg.deny.is_empty() { + return Ok(()); + } + let progs = if shell { + if !cfg.allow.is_empty() { + if let Some(c) = command.chars().find(|&c| is_meta(c)) { + return Err(format!( + "服务器启用了命令白名单:shell 模式下不允许使用元字符 {:?}(管道/重定向/命令串联/变量展开等);需要复杂参数时请用 shell=false + args", + c + )); + } + } + segment_programs(command) + } else { + vec![program_name(command)] + }; + for p in &progs { + if name_listed(&cfg.deny, p) { + return Err(format!("命令 '{p}' 被服务器策略禁止")); + } + if !cfg.allow.is_empty() && !name_listed(&cfg.allow, p) { + return Err(format!("命令 '{p}' 不在服务器白名单中")); + } } Ok(()) } +// --------------------------------------------------------------------------- +// 编码 / 摘要 / 随机数(手写,保持零额外依赖) +// --------------------------------------------------------------------------- + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_encode(data: &[u8]) -> String { + let mut out = Vec::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + out.push(B64[(n >> 18) as usize & 63]); + out.push(B64[(n >> 12) as usize & 63]); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] } else { b'=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] } else { b'=' }); + } + String::from_utf8(out).unwrap() +} + +/// 解码标准或 URL-safe base64,忽略空白,填充可省略 +fn base64_decode(s: &str) -> Result, String> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + let mut pad = false; + for &c in s.as_bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' => { + pad = true; + continue; + } + b' ' | b'\n' | b'\r' | b'\t' => continue, + _ => return Err(format!("base64 含非法字符 {:?}", c as char)), + }; + if pad { + return Err("base64 填充符 '=' 之后不能再有数据".into()); + } + acc = (acc << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + if bits >= 6 { + return Err("base64 长度非法".into()); + } + Ok(out) +} + +const K256: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +struct Sha256 { + h: [u32; 8], + buf: [u8; 64], + len: usize, + total: u64, +} + +impl Sha256 { + fn new() -> Sha256 { + Sha256 { + h: [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ], + buf: [0; 64], + len: 0, + total: 0, + } + } + + fn update(&mut self, mut data: &[u8]) { + self.total = self.total.wrapping_add(data.len() as u64); + if self.len > 0 { + let take = (64 - self.len).min(data.len()); + self.buf[self.len..self.len + take].copy_from_slice(&data[..take]); + self.len += take; + data = &data[take..]; + if self.len == 64 { + let b = self.buf; + self.compress(&b); + self.len = 0; + } + } + while data.len() >= 64 { + let (blk, rest) = data.split_at(64); + self.compress(blk.try_into().unwrap()); + data = rest; + } + if !data.is_empty() { + self.buf[..data.len()].copy_from_slice(data); + self.len = data.len(); + } + } + + fn compress(&mut self, blk: &[u8; 64]) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([blk[4 * i], blk[4 * i + 1], blk[4 * i + 2], blk[4 * i + 3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.h; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let t1 = h.wrapping_add(s1).wrapping_add(ch).wrapping_add(K256[i]).wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (s, v) in self.h.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *s = s.wrapping_add(v); + } + } + + fn finish(mut self) -> [u8; 32] { + let bits = self.total.wrapping_mul(8); + self.update(&[0x80]); + while self.len != 56 { + self.update(&[0]); + } + self.update(&bits.to_be_bytes()); + let mut out = [0u8; 32]; + for (i, v) in self.h.iter().enumerate() { + out[4 * i..4 * i + 4].copy_from_slice(&v.to_be_bytes()); + } + out + } +} + +fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(data); + h.finish() +} + +fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] { + let mut k = [0u8; 64]; + if key.len() > 64 { + k[..32].copy_from_slice(&sha256(key)); + } else { + k[..key.len()].copy_from_slice(key); + } + let mut ipad = [0x36u8; 64]; + let mut opad = [0x5cu8; 64]; + for i in 0..64 { + ipad[i] ^= k[i]; + opad[i] ^= k[i]; + } + let mut h = Sha256::new(); + h.update(&ipad); + h.update(msg); + let inner = h.finish(); + let mut h = Sha256::new(); + h.update(&opad); + h.update(&inner); + h.finish() +} + +fn hex(b: &[u8]) -> String { + const D: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(b.len() * 2); + for &x in b { + s.push(D[(x >> 4) as usize] as char); + s.push(D[(x & 15) as usize] as char); + } + s +} + +/// 常量时间比较(长度不同直接返回 false) +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +#[cfg(unix)] +fn random_bytes(buf: &mut [u8]) { + File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(buf)) + .expect("读取 /dev/urandom 失败"); +} + +#[cfg(windows)] +mod win { + pub type CtrlHandler = unsafe extern "system" fn(u32) -> i32; + #[link(name = "kernel32")] + extern "system" { + pub fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; + } + #[link(name = "bcrypt")] + extern "system" { + pub fn BCryptGenRandom(alg: *mut core::ffi::c_void, buf: *mut u8, len: u32, flags: u32) -> i32; + } +} + +#[cfg(windows)] +fn random_bytes(buf: &mut [u8]) { + const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 2; + let r = unsafe { + win::BCryptGenRandom(std::ptr::null_mut(), buf.as_mut_ptr(), buf.len() as u32, BCRYPT_USE_SYSTEM_PREFERRED_RNG) + }; + assert!(r == 0, "BCryptGenRandom 失败: {r}"); +} + +fn random_hex(n: usize) -> String { + let mut b = vec![0u8; n]; + random_bytes(&mut b); + hex(&b) +} + +/// URL 查询参数值编码(保留 unreserved 字符与 '/') +fn pct_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// 百分号解码('+' 按字面处理);结果须为合法 UTF-8 +fn pct_decode(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let h = s.get(i + 1..i + 3)?; + out.push(u8::from_str_radix(h, 16).ok()?); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +fn parse_query(q: &str) -> HashMap { + q.split('&') + .filter(|kv| !kv.is_empty()) + .filter_map(|kv| { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + Some((pct_decode(k)?, pct_decode(v)?)) + }) + .collect() +} + +/// 不切断多字节字符的最大前缀长度:末尾若是不完整的 UTF-8 序列则去掉 +fn utf8_complete_len(b: &[u8]) -> usize { + let n = b.len(); + for back in 1..=n.min(4) { + let c = b[n - back]; + if c & 0xC0 == 0x80 { + continue; // 续字节 + } + let need = if c < 0x80 { + 1 + } else if c & 0xE0 == 0xC0 { + 2 + } else if c & 0xF0 == 0xE0 { + 3 + } else if c & 0xF8 == 0xF0 { + 4 + } else { + return n; + }; + return if back < need { n - back } else { n }; + } + n +} + +fn unix_now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + // --------------------------------------------------------------------------- // 工具:run_command 执行引擎 // --------------------------------------------------------------------------- @@ -472,6 +1014,7 @@ struct ExecOutcome { signal: Option, timed_out: bool, cancelled: bool, + detached: bool, duration_ms: u64, stdout: String, stderr: String, @@ -479,32 +1022,21 @@ struct ExecOutcome { trunc_err: bool, } +#[derive(Default)] struct OutBuf { bytes: Vec, truncated: bool, } -impl Default for OutBuf { - fn default() -> Self { - OutBuf { - bytes: Vec::new(), - truncated: false, - } - } -} - -struct OutShared { - stdout: Arc>, - stderr: Arc>, - total: Arc, -} - -/// 管道读取循环:读取到 cap 后继续排空(防止子进程写满管道阻塞),只标记截断 +/// 管道读取循环:读取到 cap 后继续排空(防止子进程写满管道阻塞),只标记截断; +/// detached 后(调用已返回,后台进程仍占用管道)继续排空但不再保存。 fn pipe_read_loop( mut pipe: R, out: Arc>, total: Arc, cap: usize, + detached: Arc, + fin: mpsc::Sender<()>, ) { let mut chunk = [0u8; 16 * 1024]; loop { @@ -512,6 +1044,9 @@ fn pipe_read_loop( Ok(0) => break, Ok(n) => { total.fetch_add(n as u64, Ordering::Relaxed); + if detached.load(Ordering::Relaxed) { + continue; + } let mut b = out.lock().unwrap(); if b.bytes.len() < cap { let take = (cap - b.bytes.len()).min(n); @@ -527,6 +1062,7 @@ fn pipe_read_loop( Err(_) => break, } } + let _ = fin.send(()); } /// 进度通知循环:每 200ms 上报已读字节数 @@ -551,7 +1087,13 @@ fn progress_loop(token: Value, total: Arc, done: Arc, sin } } -fn exec(req: &RunReq, killable: &Killable, progress: Option, app: &Shared) -> Result { +/// 捕获的字节转文本;cut 时去掉末尾被截断的半个字符 +fn buf_text(b: &[u8], cut: bool) -> String { + let end = if cut { utf8_complete_len(b) } else { b.len() }; + String::from_utf8_lossy(&b[..end]).into_owned() +} + +fn exec(req: &RunReq, killable: &Killable, progress: Option, sink: Sink) -> Result { // 构建命令 let mut cmd = if req.shell { #[cfg(windows)] @@ -593,37 +1135,34 @@ fn exec(req: &RunReq, killable: &Killable, progress: Option, app: &Shared .map_err(|e| format!("启动命令失败: {e}"))?; killable.register_pid(child.id()); - let out = Arc::new(OutShared { - stdout: Arc::new(Mutex::new(OutBuf::default())), - stderr: Arc::new(Mutex::new(OutBuf::default())), - total: Arc::new(AtomicU64::new(0)), - }); + let out_buf = Arc::new(Mutex::new(OutBuf::default())); + let err_buf = Arc::new(Mutex::new(OutBuf::default())); + let total = Arc::new(AtomicU64::new(0)); + let detached_flag = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false)); + let (fin_tx, fin_rx) = mpsc::channel::<()>(); let stdout_pipe = child.stdout.take().unwrap(); let stderr_pipe = child.stderr.take().unwrap(); let t_out = { - let buf = out.stdout.clone(); - let total = out.total.clone(); + let (buf, total, det, fin) = (out_buf.clone(), total.clone(), detached_flag.clone(), fin_tx.clone()); let cap = req.max_output; thread::Builder::new() .name("bolt-out".into()) - .spawn(move || pipe_read_loop(stdout_pipe, buf, total, cap)) + .spawn(move || pipe_read_loop(stdout_pipe, buf, total, cap, det, fin)) .unwrap() }; let t_err = { - let buf = out.stderr.clone(); - let total = out.total.clone(); + let (buf, total, det, fin) = (err_buf.clone(), total.clone(), detached_flag.clone(), fin_tx); let cap = req.max_output; thread::Builder::new() .name("bolt-err".into()) - .spawn(move || pipe_read_loop(stderr_pipe, buf, total, cap)) + .spawn(move || pipe_read_loop(stderr_pipe, buf, total, cap, det, fin)) .unwrap() }; let t_prog = progress.map(|tok| { - let total = out.total.clone(); + let total = total.clone(); let done = done.clone(); - let sink = app.sink.clone(); thread::Builder::new() .name("bolt-progress".into()) .spawn(move || progress_loop(tok, total, done, sink)) @@ -632,11 +1171,10 @@ fn exec(req: &RunReq, killable: &Killable, progress: Option, app: &Shared // 收割线程独占 child.wait(),避免僵尸进程 let (tx, rx) = mpsc::channel(); - let mut child2 = child; let reaper = thread::Builder::new() .name("bolt-reaper".into()) .spawn(move || { - let st = child2.wait(); + let st = child.wait(); let _ = tx.send(st); }) .unwrap(); @@ -656,25 +1194,46 @@ fn exec(req: &RunReq, killable: &Killable, progress: Option, app: &Shared Err(mpsc::RecvTimeoutError::Disconnected) => None, } }; + // 主进程已收割:之后到达的取消不再向(可能已被复用的)进程组发信号 + killable.pid.store(0, Ordering::SeqCst); + // 主进程退出后,后台进程(如 `server &`)可能仍占用输出管道:只等一个宽限期,不无限阻塞 + let grace_end = Instant::now() + PIPE_DRAIN_GRACE; + let mut finished = 0; + while finished < 2 { + match fin_rx.recv_timeout(grace_end.saturating_duration_since(Instant::now())) { + Ok(()) => finished += 1, + Err(_) => break, + } + } + let detached = finished < 2; + if detached { + detached_flag.store(true, Ordering::SeqCst); // 读取线程继续排空管道,随后台进程结束而退出 + } else { + let _ = t_out.join(); + let _ = t_err.join(); + } done.store(true, Ordering::Relaxed); - let _ = t_out.join(); - let _ = t_err.join(); if let Some(t) = t_prog { let _ = t.join(); } let _ = reaper.join(); - let stdout = String::from_utf8_lossy(&out.stdout.lock().unwrap().bytes).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr.lock().unwrap().bytes).into_owned(); - let trunc_out = out.stdout.lock().unwrap().truncated; - let trunc_err = out.stderr.lock().unwrap().truncated; + let (stdout, trunc_out) = { + let b = out_buf.lock().unwrap(); + (buf_text(&b.bytes, b.truncated || detached), b.truncated) + }; + let (stderr, trunc_err) = { + let b = err_buf.lock().unwrap(); + (buf_text(&b.bytes, b.truncated || detached), b.truncated) + }; Ok(ExecOutcome { exit_code: status.as_ref().and_then(ExitStatus::code), - signal: status.as_ref().map(exit_signal).unwrap_or(None), + signal: status.as_ref().and_then(exit_signal), timed_out, cancelled: killable.cancelled.load(Ordering::SeqCst), + detached, duration_ms: start.elapsed().as_millis() as u64, stdout, stderr, @@ -694,41 +1253,100 @@ fn exit_signal(_st: &ExitStatus) -> Option { } fn run_result(o: &ExecOutcome, cwd: Option<&str>) -> Value { - let result = json!({ + tool_ok(json!({ "exit_code": o.exit_code, "signal": o.signal, "timed_out": o.timed_out, "cancelled": o.cancelled, + "detached": o.detached, "duration_ms": o.duration_ms, "stdout": o.stdout, "stderr": o.stderr, "truncated_stdout": o.trunc_out, "truncated_stderr": o.trunc_err, "cwd": cwd, - }); - json!({ - "content": [{"type": "text", "text": result.to_string()}], - "structuredContent": result, - }) + })) } // --------------------------------------------------------------------------- // 工具:shell 持久会话 // --------------------------------------------------------------------------- -struct Session { - pid: u32, - stdin: Mutex>, - out: Arc, +#[derive(Clone, Copy)] +struct ExitInfo { + code: Option, + signal: Option, +} + +/// 会话输出缓冲:偏移均为自会话开始的绝对字节数,裁剪前部后读取位置仍然正确 +struct SessBuf { + bytes: Vec, + base: u64, // bytes[0] 的绝对偏移 + read: u64, // 已读到的绝对偏移 + exit: Option, } struct SessionOut { - bytes: Mutex>, - off: Mutex, // 已读偏移(相对当前 Vec,裁剪后可能失效) - gen: AtomicU64, + buf: Mutex, cv: Condvar, - exited: Mutex>, - dropped: AtomicU64, +} + +impl SessionOut { + fn new() -> SessionOut { + SessionOut { + buf: Mutex::new(SessBuf { + bytes: Vec::new(), + base: 0, + read: 0, + exit: None, + }), + cv: Condvar::new(), + } + } +} + +struct Session { + pid: u32, + owner: String, // 所属客户端作用域 + stdin: Mutex>, + out: Arc, + last_used: Mutex, + active: AtomicUsize, // 进行中的 write/read 调用数(空闲回收时跳过) +} + +/// 标记会话正在被使用(RAII) +struct SessionUse<'a>(&'a Session); + +impl<'a> SessionUse<'a> { + fn new(s: &'a Session) -> SessionUse<'a> { + s.active.fetch_add(1, Ordering::SeqCst); + *s.last_used.lock().unwrap() = Instant::now(); + SessionUse(s) + } +} + +impl Drop for SessionUse<'_> { + fn drop(&mut self) { + *self.0.last_used.lock().unwrap() = Instant::now(); + self.0.active.fetch_sub(1, Ordering::SeqCst); + } +} + +/// 追加会话输出;超过 cap 时丢弃最早的一半(对齐到字符边界) +fn push_output(out: &SessionOut, data: &[u8], cap: usize) { + let mut b = out.buf.lock().unwrap(); + if b.bytes.len() + data.len() > cap && !b.bytes.is_empty() { + let len = b.bytes.len(); + let mut drop_n = (len / 2).max(len + data.len() - cap).min(len); + while drop_n < len && b.bytes[drop_n] & 0xC0 == 0x80 { + drop_n += 1; + } + b.bytes.drain(..drop_n); + b.base += drop_n as u64; + } + b.bytes.extend_from_slice(data); + drop(b); + out.cv.notify_all(); } fn session_read_loop(mut pipe: R, out: Arc) { @@ -736,31 +1354,24 @@ fn session_read_loop(mut pipe: R, out: Arc loop { match pipe.read(&mut chunk) { Ok(0) => break, - Ok(n) => { - let mut b = out.bytes.lock().unwrap(); - if b.len() >= SESSION_BUF_CAP { - let drop_n = b.len() / 2; - out.dropped.fetch_add(drop_n as u64, Ordering::Relaxed); - b.drain(..drop_n); - } - b.extend_from_slice(&chunk[..n]); - out.gen.fetch_add(1, Ordering::Relaxed); - out.cv.notify_all(); - } + Ok(n) => push_output(&out, &chunk[..n], SESSION_BUF_CAP), Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, Err(_) => break, } } } -fn shell_open(app: &Shared, shell: Option<&str>, cwd: Option<&str>) -> Result { +fn shell_open(app: &Shared, scope: &str, shell: Option<&str>, cwd: Option<&str>) -> Result { + if app.sessions.lock().unwrap().len() >= MAX_SHELL_SESSIONS { + return Err(format!("会话数已达上限 {MAX_SHELL_SESSIONS},请先 shell_close 不再使用的会话")); + } let shell = shell.unwrap_or(if cfg!(windows) { "cmd" } else { "sh" }); - policy_check(&app.cfg, shell)?; + policy_check(&app.cfg, shell, false)?; let mut cmd = Command::new(shell); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(c) = cwd { + if let Some(c) = cwd.or(app.cfg.cwd.as_deref()) { if !Path::new(c).is_dir() { return Err(format!("工作目录不存在: {c}")); } @@ -780,14 +1391,7 @@ fn shell_open(app: &Shared, shell: Option<&str>, cwd: Option<&str>) -> Result, cwd: Option<&str>) -> Result ExitInfo { + code: s.code(), + signal: exit_signal(&s), + }, + Err(_) => ExitInfo { code: None, signal: None }, + }; + out_r.buf.lock().unwrap().exit = Some(info); + out_r.cv.notify_all(); + }) + .unwrap(); let id = app.next_session.fetch_add(1, Ordering::Relaxed) + 1; app.sessions.lock().unwrap().insert( id, Arc::new(Session { pid, + owner: scope.to_string(), stdin: Mutex::new(Some(stdin)), out, + last_used: Mutex::new(Instant::now()), + active: AtomicUsize::new(0), }), ); Ok(json!({"session_id": id, "shell": shell, "pid": pid})) } -fn shell_write(session: &Session, input: &str, newline: bool) -> Result { - if session.out.exited.lock().unwrap().is_some() { +fn get_session(app: &Shared, scope: &str, id: u64) -> Result, String> { + match app.sessions.lock().unwrap().get(&id) { + Some(s) if s.owner == scope => Ok(s.clone()), + _ => Err(format!("会话不存在: {id}")), + } +} + +fn shell_write(cfg: &Config, session: &Session, input: &str, newline: bool) -> Result { + // 会话内输入同样是命令:逐行套用命令策略 + if !cfg.allow.is_empty() || !cfg.deny.is_empty() { + for line in input.lines() { + policy_check(cfg, line, true)?; + } + } + if session.out.buf.lock().unwrap().exit.is_some() { return Err("会话已退出".into()); } let mut data = input.to_string(); @@ -856,154 +1480,672 @@ fn shell_write(session: &Session, input: &str, newline: bool) -> Result Value { - let out = &session.out; +fn session_read_result(output: String, exit: Option, truncated: bool, dropped: u64, cancelled: bool) -> Value { + json!({ + "output": output, + "exited": exit.is_some(), + "exit_code": exit.and_then(|e| e.code), + "signal": exit.and_then(|e| e.signal), + "truncated": truncated, + "dropped_bytes": dropped, + "cancelled": cancelled, + }) +} + +fn shell_read(session: &Session, timeout_ms: u64, max_bytes: usize, killable: &Killable) -> Value { + let max_bytes = max_bytes.clamp(1024, MAX_MAX_OUTPUT); let deadline = (timeout_ms != 0).then(|| Instant::now() + Duration::from_millis(timeout_ms)); + let out = &session.out; + let mut b = out.buf.lock().unwrap(); loop { - // 1) 有新输出则返回 - { - let b = out.bytes.lock().unwrap(); - let mut off = out.off.lock().unwrap(); - if *off > b.len() { - *off = 0; // 缓冲被裁剪,重置偏移 - } - if *off < b.len() { - let take = (b.len() - *off).min(max_bytes); - let s = String::from_utf8_lossy(&b[*off..*off + take]).into_owned(); - *off += take; - let truncated = *off < b.len(); - drop(off); - let exited = *out.exited.lock().unwrap(); - let dropped = out.dropped.load(Ordering::Relaxed); - let prefix = if dropped > 0 { - format!("[…已丢弃 {dropped} 字节…]\n") - } else { - String::new() - }; - return json!({ - "output": prefix + &s, - "exited": exited.is_some(), - "exit_code": exited, - "truncated": truncated, - }); + // 1) 读取位置已被裁剪:跳到缓冲开头并报告丢弃量 + let mut dropped = 0u64; + if b.read < b.base { + dropped = b.base - b.read; + b.read = b.base; + } + let start = (b.read - b.base) as usize; + let avail = b.bytes.len() - start; + let mut take = avail.min(max_bytes); + // 2) 不在多字节字符中间切断(会话未结束时,半个字符留到下次) + if take > 0 && (b.exit.is_none() || take < avail) { + let t = utf8_complete_len(&b.bytes[start..start + take]); + if t > 0 { + take = t; + } else if b.exit.is_none() { + take = 0; } } - // 2) 无新输出但已退出 - if let Some(code) = *out.exited.lock().unwrap() { - return json!({"output": "", "exited": true, "exit_code": code, "truncated": false}); + if take > 0 || dropped > 0 { + let text = String::from_utf8_lossy(&b.bytes[start..start + take]).into_owned(); + b.read += take as u64; + let more = ((b.read - b.base) as usize) < b.bytes.len(); + // 还有未读输出时不报告退出,避免调用方提前停止读取 + let exit = if more { None } else { b.exit }; + let mut output = String::new(); + if dropped > 0 { + output.push_str(&format!("[…输出过多,已丢弃 {dropped} 字节未读输出…]\n")); + } + output.push_str(&text); + return session_read_result(output, exit, more, dropped, false); } - // 3) 等待新输出或退出 + // 3) 无新输出但已退出 + if b.exit.is_some() { + return session_read_result(String::new(), b.exit, false, 0, false); + } + if killable.is_killed() { + return session_read_result(String::new(), None, false, 0, true); + } + // 4) 等待新输出或退出(分片等待,以便响应取消) let wait = match deadline { - Some(d) => d.saturating_duration_since(Instant::now()), - None => Duration::from_secs(365 * 24 * 3600), + Some(d) => { + let w = d.saturating_duration_since(Instant::now()); + if w.is_zero() { + return session_read_result(String::new(), None, false, 0, false); + } + w + } + None => Duration::from_secs(3600), }; - if wait.is_zero() { - return json!({"output": "", "exited": false, "exit_code": null, "truncated": false}); - } - let gen = out.gen.load(Ordering::Relaxed); - let (guard, tres) = out.cv.wait_timeout(out.bytes.lock().unwrap(), wait).unwrap(); - drop(guard); - if out.gen.load(Ordering::Relaxed) != gen || out.exited.lock().unwrap().is_some() { - continue; // 有新事件,重新检查 - } - if tres.timed_out() { - return json!({"output": "", "exited": false, "exit_code": null, "truncated": false}); - } + b = out.cv.wait_timeout(b, wait.min(Duration::from_millis(200))).unwrap().0; } } -fn shell_close(app: &Shared, id: u64) -> Result { - let s = app - .sessions - .lock() - .unwrap() - .remove(&id) - .ok_or_else(|| format!("会话不存在: {id}"))?; - kill_tree(s.pid); +/// 杀掉会话进程树并关闭 stdin(已退出的会话不再发信号,避免 pid 复用误杀) +fn close_session(s: &Session) { + if s.out.buf.lock().unwrap().exit.is_none() { + kill_tree(s.pid); + } drop(s.stdin.lock().unwrap().take()); // 关闭 stdin,子进程看到 EOF +} + +fn shell_close(app: &Shared, scope: &str, id: u64) -> Result { + let s = { + let mut m = app.sessions.lock().unwrap(); + match m.get(&id) { + Some(s) if s.owner == scope => m.remove(&id).unwrap(), + _ => return Err(format!("会话不存在: {id}")), + } + }; + close_session(&s); Ok(json!({"closed": true, "session_id": id})) } // --------------------------------------------------------------------------- -// 工具列表(静态) +// 文件:路径限制与读写(MCP 工具与 HTTP /files 共用) // --------------------------------------------------------------------------- -static TOOLS: OnceLock = OnceLock::new(); +type FErr = (u16, String); // (HTTP 状态码, 错误信息) -fn tools_list() -> &'static Value { - TOOLS.get_or_init(|| { - json!({ - "tools": [ - { - "name": "run_command", - "description": "在服务器上执行 shell 命令并捕获输出。shell=true(默认)时支持完整 shell 语法:管道、重定向、&&、环境变量等;shell=false 时配合 args 直接执行二进制(不经 shell)。输出超限自动截断,超时自动杀进程,客户端可通过 notifications/cancelled 取消。", - "inputSchema": { - "type": "object", - "properties": { - "command": {"type": "string", "description": "要执行的命令行(shell=true 时为 shell 语法)"}, - "args": {"type": "array", "items": {"type": "string"}, "description": "shell=false 时的直接执行参数"}, - "shell": {"type": "boolean", "description": "是否经系统 shell 执行(sh -c / cmd /C)。默认 true"}, - "cwd": {"type": "string", "description": "工作目录,默认服务器配置目录"}, - "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "附加环境变量"}, - "timeout_ms": {"type": "integer", "description": "超时毫秒,0=无限,默认 30000,上限 21600000"}, - "max_output_bytes": {"type": "integer", "description": "stdout/stderr 各自捕获上限,默认 1048576"} - }, - "required": ["command"] - } - }, - { - "name": "shell_open", - "description": "启动持久化交互式 shell 会话(cwd、环境变量等状态跨次保留)。与 shell_write / shell_read 配合使用。无 PTY,交互式程序可能表现为非交互模式。", - "inputSchema": { - "type": "object", - "properties": { - "shell": {"type": "string", "description": "shell 程序,如 sh/bash/zsh/fish,默认 sh(windows 为 cmd)"}, - "cwd": {"type": "string", "description": "会话工作目录"} - } - } - }, - { - "name": "shell_write", - "description": "向会话写入一行输入(自动补换行)。", - "inputSchema": { - "type": "object", - "properties": { - "session_id": {"type": "integer", "description": "shell_open 返回的会话 ID"}, - "input": {"type": "string", "description": "要写入的内容"}, - "newline": {"type": "boolean", "description": "是否自动补换行,默认 true"} - }, - "required": ["session_id", "input"] - } - }, - { - "name": "shell_read", - "description": "读取会话自上次读取以来的新输出;若无输出则阻塞至 timeout_ms(0 = 等到会话退出)。", - "inputSchema": { - "type": "object", - "properties": { - "session_id": {"type": "integer"}, - "timeout_ms": {"type": "integer", "description": "等待毫秒,默认 5000,0=等到退出"}, - "max_bytes": {"type": "integer", "description": "本次最多返回字节数,默认 1048576"} - }, - "required": ["session_id"] - } - }, - { - "name": "shell_close", - "description": "关闭并杀掉会话进程树。", - "inputSchema": { - "type": "object", - "properties": { - "session_id": {"type": "integer"} - }, - "required": ["session_id"] - } - } - ] - }) +fn ferr(status: u16, msg: impl Into) -> FErr { + (status, msg.into()) +} + +fn io_ferr(e: io::Error, what: &str, p: &Path) -> FErr { + let status = match e.kind() { + io::ErrorKind::NotFound => 404, + io::ErrorKind::PermissionDenied => 403, + io::ErrorKind::AlreadyExists => 409, + _ => 500, + }; + (status, format!("{what} {}: {e}", display_path(p))) +} + +/// 去掉 Windows canonicalize 产生的 \\?\ 前缀,便于阅读 +fn display_path(p: &Path) -> String { + let s = p.to_string_lossy(); + if cfg!(windows) { + if let Some(r) = s.strip_prefix(r"\\?\") { + if !r.starts_with("UNC") { + return r.to_string(); + } + } + } + s.into_owned() +} + +/// 文件功能是否可用:配置了命令白名单但未限制目录时禁用(否则写 authorized_keys / crontab 即可绕过白名单) +fn files_enabled(cfg: &Config) -> Result<(), String> { + if cfg.file_roots.is_empty() && !cfg.allow.is_empty() { + return Err("文件功能已禁用:服务器配置了命令白名单,需同时用 --file-root 指定允许访问的目录".into()); + } + Ok(()) +} + +fn within_roots(cfg: &Config, p: &Path) -> bool { + cfg.file_roots.iter().any(|r| p.starts_with(r)) +} + +/// 解析并校验文件路径:相对路径基于 --cwd;已存在的路径解析符号链接; +/// 不存在的路径规范化最近的已存在祖先后拼接剩余部分(剩余部分不允许 . / ..)。 +fn resolve_path(cfg: &Config, p: &str, mkdirs: bool) -> Result { + files_enabled(cfg).map_err(|e| ferr(403, e))?; + if p.is_empty() { + return Err(ferr(400, "'path' 不能为空")); + } + let raw = PathBuf::from(p); + let abs = if raw.is_absolute() { + raw + } else { + let base = match &cfg.cwd { + Some(c) => PathBuf::from(c), + None => env::current_dir().map_err(|e| ferr(500, format!("获取当前目录失败: {e}")))?, + }; + base.join(raw) + }; + let restricted = !cfg.file_roots.is_empty(); + let resolved = if fs::symlink_metadata(&abs).is_ok() { + let c = fs::canonicalize(&abs).map_err(|e| io_ferr(e, "解析路径失败", &abs))?; + if restricted && !within_roots(cfg, &c) { + return Err(ferr(403, format!("路径不在允许访问的目录内: {}", display_path(&c)))); + } + c + } else if !restricted { + abs + } else { + let mut anc = abs.as_path(); + let mut rest: Vec = Vec::new(); + loop { + match anc.components().next_back() { + Some(Component::Normal(n)) => rest.push(n.to_os_string()), + _ => return Err(ferr(403, "路径中不存在的部分不能包含 . 或 ..")), + } + anc = anc.parent().ok_or_else(|| ferr(404, format!("路径不存在: {p}")))?; + if fs::symlink_metadata(anc).is_ok() { + break; + } + } + let base = fs::canonicalize(anc).map_err(|e| io_ferr(e, "解析路径失败", anc))?; + if !within_roots(cfg, &base) { + return Err(ferr(403, format!("路径不在允许访问的目录内: {}", display_path(&base)))); + } + let mut r = base; + for n in rest.iter().rev() { + r.push(n); + } + r + }; + if mkdirs { + if let Some(parent) = resolved.parent() { + fs::create_dir_all(parent).map_err(|e| io_ferr(e, "创建目录失败", parent))?; + } + } + Ok(resolved) +} + +fn mtime_secs(md: &fs::Metadata) -> Option { + md.modified().ok()?.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()) +} + +#[cfg(unix)] +fn mode_str(md: &fs::Metadata) -> Option { + use std::os::unix::fs::PermissionsExt; + Some(format!("{:o}", md.permissions().mode() & 0o7777)) +} +#[cfg(not(unix))] +fn mode_str(_md: &fs::Metadata) -> Option { + None +} + +fn image_mime(p: &Path) -> Option<&'static str> { + let ext = p.extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => return None, }) } +#[derive(Clone, Copy, PartialEq)] +enum WriteMode { + Overwrite, + Create, + Append, +} + +impl WriteMode { + fn parse(s: &str) -> Option { + match s { + "overwrite" => Some(WriteMode::Overwrite), + "create" => Some(WriteMode::Create), + "append" => Some(WriteMode::Append), + _ => None, + } + } + fn name(self) -> &'static str { + match self { + WriteMode::Overwrite => "overwrite", + WriteMode::Create => "create", + WriteMode::Append => "append", + } + } +} + +const TOO_LARGE: &str = "数据超过上传上限"; + +fn is_too_large(e: &io::Error) -> bool { + e.get_ref().map(|x| x.to_string() == TOO_LARGE).unwrap_or(false) +} + +/// 流式复制并计算 SHA-256 +fn copy_hashed(src: &mut dyn Read, dst: &mut File, limit: u64, p: &Path) -> Result<(u64, [u8; 32]), FErr> { + let mut h = Sha256::new(); + let mut n = 0u64; + let mut buf = vec![0u8; 64 * 1024]; + loop { + let r = match src.read(&mut buf) { + Ok(0) => break, + Ok(r) => r, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if is_too_large(&e) => return Err(ferr(413, format!("{TOO_LARGE}({limit} 字节)"))), + Err(e) => return Err(ferr(400, format!("读取上传数据失败: {e}"))), + }; + n += r as u64; + if n > limit { + return Err(ferr(413, format!("{TOO_LARGE}({limit} 字节)"))); + } + h.update(&buf[..r]); + dst.write_all(&buf[..r]).map_err(|e| io_ferr(e, "写入失败", p))?; + } + Ok((n, h.finish())) +} + +/// 写文件:overwrite 先写同目录临时文件再 rename(原子替换,保留原权限);create 要求文件不存在;append 追加 +fn write_file(cfg: &Config, path: &str, mode: WriteMode, mkdirs: bool, src: &mut dyn Read, limit: u64) -> Result { + let p = resolve_path(cfg, path, mkdirs)?; + let existed = match fs::metadata(&p) { + Ok(md) if md.is_dir() => return Err(ferr(409, format!("目标是目录: {}", display_path(&p)))), + Ok(_) => true, + Err(_) => false, + }; + let (n, digest) = match mode { + WriteMode::Create => { + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open(&p) + .map_err(|e| io_ferr(e, "创建文件失败(create 模式要求文件不存在)", &p))?; + match copy_hashed(src, &mut f, limit, &p) { + Ok(r) => r, + Err(e) => { + drop(f); + let _ = fs::remove_file(&p); + return Err(e); + } + } + } + WriteMode::Append => { + let mut f = OpenOptions::new() + .append(true) + .create(true) + .open(&p) + .map_err(|e| io_ferr(e, "打开文件失败", &p))?; + copy_hashed(src, &mut f, limit, &p)? + } + WriteMode::Overwrite => { + let dir = p.parent().ok_or_else(|| ferr(400, "无效路径"))?; + let name = p.file_name().ok_or_else(|| ferr(400, "无效路径"))?.to_string_lossy().into_owned(); + let tmp = dir.join(format!(".{name}.bolt-{}.tmp", random_hex(4))); + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| io_ferr(e, "创建临时文件失败", &tmp))?; + let r = copy_hashed(src, &mut f, limit, &p).and_then(|r| { + f.sync_all().map_err(|e| io_ferr(e, "写入失败", &p))?; + Ok(r) + }); + if let Ok(md) = fs::metadata(&p) { + let _ = fs::set_permissions(&tmp, md.permissions()); + } + drop(f); + let r = match r { + Ok(r) => r, + Err(e) => { + let _ = fs::remove_file(&tmp); + return Err(e); + } + }; + if let Err(e) = fs::rename(&tmp, &p) { + let _ = fs::remove_file(&tmp); + return Err(io_ferr(e, "替换目标文件失败", &p)); + } + r + } + }; + let size = fs::metadata(&p).map(|m| m.len()).unwrap_or(0); + Ok(json!({ + "path": display_path(&p), + "mode": mode.name(), + "created": !existed, + "bytes_written": n, + "size": size, + "sha256": hex(&digest), + })) +} + +fn file_hash(p: &Path) -> io::Result { + let mut f = File::open(p)?; + let mut h = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + Ok(hex(&h.finish())) +} + +fn file_read(cfg: &Config, args: &Value) -> ArgResult { + let path = req_str(args, "path")?; + let offset = opt_u64(args, "offset")?.unwrap_or(0); + let length = opt_u64(args, "length")?.map(|n| n as usize).unwrap_or(DEFAULT_READ_LEN); + if length == 0 || length > MAX_READ_LEN { + return Err((-32602, format!("'length' 必须在 1..={MAX_READ_LEN}"))); + } + let enc = opt_str(args, "encoding")?.unwrap_or("auto"); + if !matches!(enc, "auto" | "text" | "base64") { + return Err((-32602, "'encoding' 必须是 auto / text / base64".into())); + } + Ok(match read_chunk(cfg, path, offset, length, enc) { + Ok(v) => v, + Err((_, e)) => tool_error(&e), + }) +} + +fn read_chunk(cfg: &Config, path: &str, offset: u64, length: usize, enc: &str) -> Result { + let p = resolve_path(cfg, path, false)?; + let md = fs::metadata(&p).map_err(|e| io_ferr(e, "读取文件信息失败", &p))?; + if md.is_dir() { + return Err(ferr(400, format!("{} 是目录;列目录请用 run_command(如 ls -la)", display_path(&p)))); + } + let size = md.len(); + if offset > size { + return Err(ferr(416, format!("offset {offset} 超出文件大小 {size}"))); + } + let mut f = File::open(&p).map_err(|e| io_ferr(e, "打开文件失败", &p))?; + f.seek(SeekFrom::Start(offset)).map_err(|e| io_ferr(e, "定位失败", &p))?; + let mut data = Vec::with_capacity(length.min((size - offset) as usize)); + f.take(length as u64).read_to_end(&mut data).map_err(|e| io_ferr(e, "读取失败", &p))?; + let at_end = offset + data.len() as u64 >= size; + let mime = image_mime(&p); + let mut n = data.len(); + let (kind, block) = if enc == "auto" && mime.is_some() && offset == 0 && at_end { + ("image", json!({"type": "image", "data": base64_encode(&data), "mimeType": mime})) + } else if enc == "base64" { + ("base64", json!({"type": "text", "text": base64_encode(&data)})) + } else { + let cut = if at_end { n } else { utf8_complete_len(&data) }; + match std::str::from_utf8(&data[..cut]) { + Ok(s) if cut > 0 || n == 0 => { + n = cut; + ("text", json!({"type": "text", "text": s})) + } + _ if enc == "text" => ("text", json!({"type": "text", "text": String::from_utf8_lossy(&data)})), + _ => ("base64", json!({"type": "text", "text": base64_encode(&data)})), + } + }; + let meta = json!({ + "path": display_path(&p), + "size": size, + "offset": offset, + "bytes": n, + "next_offset": offset + n as u64, + "eof": offset + n as u64 >= size, + "encoding": kind, + "mime_type": mime, + "mtime": mtime_secs(&md), + }); + // 第一块是元数据 JSON,第二块是内容本身(文本 / base64 文本 / 图片) + Ok(json!({"content": [{"type": "text", "text": meta.to_string()}, block]})) +} + +fn file_write(cfg: &Config, args: &Value) -> ArgResult { + let path = req_str(args, "path")?; + let content = req_str(args, "content")?; + let enc = opt_str(args, "encoding")?.unwrap_or("text"); + let mode_s = opt_str(args, "mode")?.unwrap_or("overwrite"); + let mode = WriteMode::parse(mode_s).ok_or((-32602, "'mode' 必须是 overwrite / create / append".to_string()))?; + let mkdirs = opt_bool(args, "mkdirs")?.unwrap_or(false); + let data: Cow<[u8]> = match enc { + "text" => Cow::Borrowed(content.as_bytes()), + "base64" => Cow::Owned(base64_decode(content).map_err(|e| (-32602, e))?), + _ => return Err((-32602, "'encoding' 必须是 text / base64".into())), + }; + Ok(match write_file(cfg, path, mode, mkdirs, &mut &data[..], u64::MAX) { + Ok(v) => tool_ok(v), + Err((_, e)) => tool_error(&e), + }) +} + +fn file_stat(cfg: &Config, args: &Value) -> ArgResult { + let path = req_str(args, "path")?; + let want_hash = opt_bool(args, "sha256")?.unwrap_or(false); + let p = match resolve_path(cfg, path, false) { + Ok(p) => p, + Err((_, e)) => return Ok(tool_error(&e)), + }; + let md = match fs::metadata(&p) { + Ok(md) => md, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Ok(tool_ok(json!({"path": display_path(&p), "exists": false}))); + } + Err(e) => return Ok(tool_error(&io_ferr(e, "读取文件信息失败", &p).1)), + }; + let kind = if md.is_dir() { + "dir" + } else if md.is_file() { + "file" + } else { + "other" + }; + let digest = if want_hash && md.is_file() { + match file_hash(&p) { + Ok(h) => Some(h), + Err(e) => return Ok(tool_error(&io_ferr(e, "计算哈希失败", &p).1)), + } + } else { + None + }; + Ok(tool_ok(json!({ + "path": display_path(&p), + "exists": true, + "type": kind, + "size": md.len(), + "mtime": mtime_secs(&md), + "mode": mode_str(&md), + "readonly": md.permissions().readonly(), + "sha256": digest, + }))) +} + +fn link_sig(app: &Shared, method: &str, path: &str, mode: &str, mkdirs: bool, exp: u64) -> String { + let msg = format!("{method}\n{path}\n{mode}\n{}\n{exp}", mkdirs as u8); + hex(&hmac_sha256(&app.link_key, msg.as_bytes())) +} + +fn file_link(app: &Shared, ctx: &Ctx, args: &Value) -> ArgResult { + let path = req_str(args, "path")?; + let method = opt_str(args, "method")?.unwrap_or("GET").to_ascii_uppercase(); + if method != "GET" && method != "PUT" { + return Err((-32602, "'method' 必须是 GET(下载)或 PUT(上传)".into())); + } + let ttl = opt_u64(args, "expires_in")?.unwrap_or(DEFAULT_LINK_TTL).clamp(1, MAX_LINK_TTL); + let mode = WriteMode::parse(opt_str(args, "mode")?.unwrap_or("overwrite")) + .ok_or((-32602, "'mode' 必须是 overwrite / create / append".to_string()))?; + let mkdirs = opt_bool(args, "mkdirs")?.unwrap_or(false); + let Some(base) = &ctx.base_url else { + return Ok(tool_error("file_link 仅在 HTTP 模式下可用;stdio 模式请用 file_read / file_write")); + }; + let p = match resolve_path(&app.cfg, path, false) { + Ok(p) => p, + Err((_, e)) => return Ok(tool_error(&e)), + }; + if method == "GET" && !p.is_file() { + return Ok(tool_error(&format!("文件不存在或不是普通文件: {}", display_path(&p)))); + } + let ps = display_path(&p); + let exp = unix_now() + ttl; + let (mode_s, mk) = if method == "PUT" { (mode.name(), mkdirs) } else { ("-", false) }; + let sig = link_sig(app, &method, &ps, mode_s, mk, exp); + let mut url = format!("{base}/files?path={}&exp={exp}&sig={sig}", pct_encode(&ps)); + if method == "PUT" { + url.push_str(&format!("&mode={mode_s}&mkdirs={}", mk as u8)); + } + let curl = if method == "GET" { + format!("curl -fsSL -o <本地保存路径> '{url}'") + } else { + format!("curl -fsS -T <本地文件> '{url}'") + }; + Ok(tool_ok(json!({ + "url": url, + "method": method, + "path": ps, + "expires_at": exp, + "expires_in": ttl, + "curl": curl, + }))) +} + +// --------------------------------------------------------------------------- +// 工具列表(按配置生成) +// --------------------------------------------------------------------------- + +fn build_tools(cfg: &Config) -> Value { + let mut tools = vec![ + json!({ + "name": "run_command", + "description": "在服务器上执行 shell 命令并捕获输出。shell=true(默认)时支持完整 shell 语法:管道、重定向、&&、环境变量等;shell=false 时配合 args 直接执行二进制(不经 shell)。输出超限自动截断,超时自动杀进程,客户端可通过 notifications/cancelled 取消。命令退出后若仍有后台进程占用输出(如 `server &`),短暂等待后返回 detached=true,后台进程继续运行。", + "inputSchema": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "要执行的命令行(shell=true 时为 shell 语法)"}, + "args": {"type": "array", "items": {"type": "string"}, "description": "shell=false 时的直接执行参数"}, + "shell": {"type": "boolean", "description": "是否经系统 shell 执行(sh -c / cmd /C)。默认 true"}, + "cwd": {"type": "string", "description": "工作目录,默认服务器配置目录"}, + "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "附加环境变量"}, + "timeout_ms": {"type": "integer", "description": "超时毫秒,0=无限,默认 30000,上限 21600000"}, + "max_output_bytes": {"type": "integer", "description": "stdout/stderr 各自捕获上限,默认 1048576"} + }, + "required": ["command"] + } + }), + json!({ + "name": "shell_open", + "description": "启动持久化交互式 shell 会话(cwd、环境变量等状态跨次保留)。与 shell_write / shell_read 配合使用。无 PTY,交互式程序可能表现为非交互模式。空闲 30 分钟自动回收。", + "inputSchema": { + "type": "object", + "properties": { + "shell": {"type": "string", "description": "shell 程序,如 sh/bash/zsh/fish,默认 sh(windows 为 cmd)"}, + "cwd": {"type": "string", "description": "会话工作目录"} + } + } + }), + json!({ + "name": "shell_write", + "description": "向会话写入输入(默认自动补换行)。", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "integer", "description": "shell_open 返回的会话 ID"}, + "input": {"type": "string", "description": "要写入的内容"}, + "newline": {"type": "boolean", "description": "是否自动补换行,默认 true"} + }, + "required": ["session_id", "input"] + } + }), + json!({ + "name": "shell_read", + "description": "读取会话自上次读取以来的新输出;若无输出则阻塞至 timeout_ms(0 = 等到会话退出)。truncated=true 表示还有未读输出;exited=true 表示会话已结束且输出已读完。", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "integer"}, + "timeout_ms": {"type": "integer", "description": "等待毫秒,默认 5000,0=等到退出"}, + "max_bytes": {"type": "integer", "description": "本次最多返回字节数,默认 1048576"} + }, + "required": ["session_id"] + } + }), + json!({ + "name": "shell_close", + "description": "关闭并杀掉会话进程树。", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "integer"} + }, + "required": ["session_id"] + } + }), + ]; + if files_enabled(cfg).is_ok() { + tools.push(json!({ + "name": "file_read", + "description": "读取服务器上的文件。返回两块内容:第一块是元数据 JSON(size / bytes / next_offset / eof / encoding),第二块是文件内容。encoding=auto 时 UTF-8 文本按文本返回,图片(png/jpg/gif/webp)按图片返回,其余按 base64 返回。大文件用 offset + length 分块读取;更大的文件(HTTP 模式)请用 file_link 生成下载链接。", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "文件路径(相对路径基于服务器工作目录)"}, + "offset": {"type": "integer", "description": "起始字节偏移,默认 0"}, + "length": {"type": "integer", "description": "最多读取字节数,默认 262144,上限 8388608"}, + "encoding": {"type": "string", "enum": ["auto", "text", "base64"], "description": "默认 auto"} + }, + "required": ["path"] + } + })); + tools.push(json!({ + "name": "file_write", + "description": "向服务器写入文件。mode=overwrite(默认,原子替换并保留原权限)/ create(文件已存在则失败)/ append(追加,可用于分块上传)。二进制内容用 encoding=base64。返回写入字节数与 SHA-256。大文件(HTTP 模式)请用 file_link 生成上传链接。", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "目标路径"}, + "content": {"type": "string", "description": "文件内容(text 原文或 base64)"}, + "encoding": {"type": "string", "enum": ["text", "base64"], "description": "默认 text"}, + "mode": {"type": "string", "enum": ["overwrite", "create", "append"], "description": "默认 overwrite"}, + "mkdirs": {"type": "boolean", "description": "自动创建父目录,默认 false"} + }, + "required": ["path", "content"] + } + })); + tools.push(json!({ + "name": "file_stat", + "description": "查询文件/目录信息(是否存在、类型、大小、修改时间、权限),可选计算 SHA-256 用于校验传输结果。", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "sha256": {"type": "boolean", "description": "是否计算文件 SHA-256,默认 false"} + }, + "required": ["path"] + } + })); + if matches!(cfg.mode, Mode::Http(_)) { + tools.push(json!({ + "name": "file_link", + "description": "生成带签名、限时有效的 HTTP 直传链接,用于传输大文件(数据不经过模型上下文)。method=GET 下载服务器文件,method=PUT 上传到服务器。返回 url 与可直接在客户端本机执行的 curl 命令(GET: curl -o 本地路径 url;PUT: curl -T 本地文件 url)。", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "服务器上的文件路径"}, + "method": {"type": "string", "enum": ["GET", "PUT"], "description": "GET 下载 / PUT 上传,默认 GET"}, + "expires_in": {"type": "integer", "description": "有效期秒数,默认 600,上限 86400"}, + "mode": {"type": "string", "enum": ["overwrite", "create", "append"], "description": "PUT 的写入方式,默认 overwrite"}, + "mkdirs": {"type": "boolean", "description": "PUT 时自动创建父目录,默认 false"} + }, + "required": ["path"] + } + })); + } + } + json!({ "tools": tools }) +} + // --------------------------------------------------------------------------- // MCP 分发 // --------------------------------------------------------------------------- @@ -1019,94 +2161,77 @@ fn initialize_result(params: &Value) -> Value { "protocolVersion": version, "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "bolt", "version": VERSION}, - "instructions": "你是连接到 bolt 命令执行服务器的 AI。你可以用 run_command 在服务器上执行 shell 命令(支持管道、重定向、超时与取消);需要保留状态(如 cd、环境变量)时,用 shell_open 开启持久会话,配合 shell_write/shell_read 交互。注意命令受服务器策略限制,输出可能被截断。" + "instructions": "你是连接到 bolt 命令执行服务器的 AI。你可以用 run_command 在服务器上执行 shell 命令(支持管道、重定向、超时与取消);需要保留状态(如 cd、环境变量)时,用 shell_open 开启持久会话,配合 shell_write/shell_read 交互。传输文件用 file_read/file_write(小文件,内容经过上下文)或 file_link(大文件,生成 HTTP 直传链接后在客户端本机用 curl 传输)。注意命令与文件路径受服务器策略限制,输出可能被截断。" }) } /// 执行工具调用,返回工具结果(Ok)或协议错误(Err((code, msg))) /// killable:外部可取消句柄(注册在 inflight 表中,供 notifications/cancelled 使用); -/// 为 None 时(测试或内部调用)自动创建本地句柄。 -fn exec_tool_call(app: &Shared, name: &str, args: &Value, progress: Option<&Value>, killable: Option<&Killable>) -> Result { +/// 为 None 时(测试或内部调用)使用本地句柄。 +fn exec_tool_call(app: &Shared, ctx: &Ctx, name: &str, args: &Value, progress: Option<&Value>, killable: Option<&Killable>) -> ArgResult { + let local_k = Killable::default(); + let k = killable.unwrap_or(&local_k); + let cfg = &app.cfg; match name { "run_command" => { - let req = parse_run_params(args, &app.cfg).map_err(|e| (-32602, e))?; - let token = req.command.split_whitespace().next().unwrap_or(""); - if let Err(e) = policy_check(&app.cfg, token) { + let req = parse_run_params(args, cfg).map_err(|e| (-32602, e))?; + if let Err(e) = policy_check(cfg, &req.command, req.shell) { return Ok(tool_error(&e)); } - let local_k; - let k = match killable { - Some(k) => k, - None => { - local_k = Killable::default(); - &local_k - } - }; - match exec(&req, k, progress.cloned(), app) { - Ok(o) => Ok(run_result(&o, req.cwd.as_deref())), - Err(e) => Ok(tool_error(&e)), - } + Ok(match exec(&req, k, progress.cloned(), ctx.sink.clone()) { + Ok(o) => run_result(&o, req.cwd.as_deref()), + Err(e) => tool_error(&e), + }) } "shell_open" => { - let shell = args.get("shell").and_then(Value::as_str); - let cwd = args.get("cwd").and_then(Value::as_str); - match shell_open(app, shell, cwd) { - Ok(v) => Ok(v), - Err(e) => Ok(tool_error(&e)), - } + let shell = opt_str(args, "shell")?; + let cwd = opt_str(args, "cwd")?; + Ok(wrap(shell_open(app, &ctx.scope, shell, cwd))) } "shell_write" => { - let id = args - .get("session_id") - .and_then(Value::as_u64) - .ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?; - let input = args - .get("input") - .and_then(Value::as_str) - .ok_or_else(|| (-32602, "'input' 必须是字符串".to_string()))?; - let newline = args.get("newline").and_then(Value::as_bool).unwrap_or(true); - let session = match app.sessions.lock().unwrap().get(&id) { - Some(s) => s.clone(), - None => return Ok(tool_error(&format!("会话不存在: {id}"))), + let id = req_u64(args, "session_id")?; + let input = req_str(args, "input")?; + let newline = opt_bool(args, "newline")?.unwrap_or(true); + let s = match get_session(app, &ctx.scope, id) { + Ok(s) => s, + Err(e) => return Ok(tool_error(&e)), }; - match shell_write(&session, input, newline) { - Ok(v) => Ok(v), - Err(e) => Ok(tool_error(&e)), - } + let _u = SessionUse::new(&s); + Ok(wrap(shell_write(cfg, &s, input, newline))) } "shell_read" => { - let id = args - .get("session_id") - .and_then(Value::as_u64) - .ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?; - let timeout_ms = args.get("timeout_ms").and_then(Value::as_u64).unwrap_or(5000); - let max_bytes = args - .get("max_bytes") - .and_then(Value::as_u64) - .map(|n| n as usize) - .unwrap_or(DEFAULT_MAX_OUTPUT); - let session = match app.sessions.lock().unwrap().get(&id) { - Some(s) => s.clone(), - None => return Ok(tool_error(&format!("会话不存在: {id}"))), + let id = req_u64(args, "session_id")?; + let timeout_ms = opt_u64(args, "timeout_ms")?.unwrap_or(5000); + let max_bytes = opt_u64(args, "max_bytes")?.map(|n| n as usize).unwrap_or(DEFAULT_MAX_OUTPUT); + let s = match get_session(app, &ctx.scope, id) { + Ok(s) => s, + Err(e) => return Ok(tool_error(&e)), }; - Ok(shell_read(&session, timeout_ms, max_bytes)) + let _u = SessionUse::new(&s); + Ok(tool_ok(shell_read(&s, timeout_ms, max_bytes, k))) } "shell_close" => { - let id = args - .get("session_id") - .and_then(Value::as_u64) - .ok_or_else(|| (-32602, "'session_id' 必须是整数".to_string()))?; - match shell_close(app, id) { - Ok(v) => Ok(v), - Err(e) => Ok(tool_error(&e)), - } + let id = req_u64(args, "session_id")?; + Ok(wrap(shell_close(app, &ctx.scope, id))) } + "file_read" => file_read(cfg, args), + "file_write" => file_write(cfg, args), + "file_stat" => file_stat(cfg, args), + "file_link" => file_link(app, ctx, args), _ => Err((-32602, format!("未知工具: {name}"))), } } +fn call_response(id: &Value, r: std::thread::Result>) -> Value { + match r { + Ok(Ok(v)) => json!({"jsonrpc": "2.0", "id": id, "result": v}), + Ok(Err((code, msg))) => rpc_error(id, code, &msg), + Err(_) => rpc_error(id, -32603, "internal error"), + } +} + // 处理 messages,返回需要发送的 JSON-RPC 响应(通知/异步工具调用返回 None) -fn process_message(v: &Value, app: &Shared, async_exec: bool) -> Option { +fn process_message(v: &Value, app: &Shared, ctx: &Ctx, async_exec: bool) -> Option { let obj = match v.as_object() { Some(o) => o, None => return Some(rpc_error(&Value::Null, -32600, "invalid request")), @@ -1124,13 +2249,14 @@ fn process_message(v: &Value, app: &Shared, async_exec: bool) -> Option { "notifications/initialized" => {} "notifications/cancelled" => { if let Some(rid) = params.get("requestId") { - let key = id_key(rid); + let key = inflight_key(&ctx.scope, rid); if let Some(k) = app.inflight.lock().unwrap().get(&key).cloned() { k.kill(true); } } } - "notifications/exit" => { + // 仅 stdio 的拉起方可以让服务器退出;HTTP 客户端不能关停整个服务 + "notifications/exit" if ctx.scope == STDIO_SCOPE => { app.exit_flag.store(true, Ordering::SeqCst); } _ => {} @@ -1142,47 +2268,39 @@ fn process_message(v: &Value, app: &Shared, async_exec: bool) -> Option { let resp = match method { "initialize" => json!({"jsonrpc": "2.0", "id": id, "result": initialize_result(¶ms)}), "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}), - "tools/list" => json!({"jsonrpc": "2.0", "id": id, "result": tools_list()}), + "tools/list" => json!({"jsonrpc": "2.0", "id": id, "result": app.tools}), "tools/call" => { - let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let name = params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); let progress = params.get("_meta").and_then(|m| m.get("progressToken")).cloned(); + let Some(slot) = CallSlot::acquire(app) else { + let msg = format!("服务器繁忙:同时进行的工具调用已达上限 {MAX_CONCURRENT_CALLS},请稍后重试"); + return Some(json!({"jsonrpc": "2.0", "id": id, "result": tool_error(&msg)})); + }; + let k = register_inflight(app, &ctx.scope, &id); if async_exec { // stdio 模式:worker 线程执行,完成后经 sink 发响应(不阻塞主循环) - let app2 = app.clone(); - let id2 = id.clone(); - let name2 = name.to_string(); - let args2 = args; - let tok2 = progress; - let k = register_inflight(app, &id); - thread::Builder::new() - .name("bolt-tool".into()) - .spawn(move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - exec_tool_call(&app2, &name2, &args2, tok2.as_ref(), Some(&k)) - })); - unregister_inflight(&app2, &id2, &k); - let resp = match r { - Ok(Ok(v)) => json!({"jsonrpc": "2.0", "id": id2, "result": v}), - Ok(Err((code, msg))) => rpc_error(&id2, code, &msg), - Err(_) => rpc_error(&id2, -32603, "internal error"), - }; - app2.sink.send(&resp.to_string()); - }) - .unwrap(); - return None; - } else { - let k = register_inflight(app, &id); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - exec_tool_call(app, name, &args, progress.as_ref(), Some(&k)) - })); - unregister_inflight(app, &id, &k); - match r { - Ok(Ok(v)) => json!({"jsonrpc": "2.0", "id": id, "result": v}), - Ok(Err((code, msg))) => rpc_error(&id, code, &msg), - Err(_) => rpc_error(&id, -32603, "internal error"), + let (app2, ctx2, id2, k2) = (app.clone(), ctx.clone(), id.clone(), k.clone()); + let spawned = thread::Builder::new().name("bolt-tool".into()).spawn(move || { + let _slot = slot; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + exec_tool_call(&app2, &ctx2, &name, &args, progress.as_ref(), Some(&k2)) + })); + unregister_inflight(&app2, &ctx2.scope, &id2, &k2); + ctx2.sink.send(&call_response(&id2, r).to_string()); + }); + if spawned.is_err() { + unregister_inflight(app, &ctx.scope, &id, &k); + return Some(rpc_error(&id, -32603, "无法创建工作线程")); } + return None; } + let _slot = slot; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + exec_tool_call(app, ctx, &name, &args, progress.as_ref(), Some(&k)) + })); + unregister_inflight(app, &ctx.scope, &id, &k); + call_response(&id, r) } "shutdown" => json!({"jsonrpc": "2.0", "id": id, "result": {}}), _ => rpc_error(&id, -32601, &format!("method not found: {method}")), @@ -1190,6 +2308,108 @@ fn process_message(v: &Value, app: &Shared, async_exec: bool) -> Option { Some(resp) } +// --------------------------------------------------------------------------- +// 客户端作用域(HTTP 的 Mcp-Session-Id) +// --------------------------------------------------------------------------- + +/// 新建 MCP 会话;超过上限时淘汰最久未活动的会话 +fn create_mcp_session(app: &Shared) -> String { + let id = random_hex(16); + let evicted = { + let mut m = app.mcp_sessions.lock().unwrap(); + let evicted = if m.len() >= MAX_MCP_SESSIONS { + let oldest = m.iter().min_by_key(|(_, t)| **t).map(|(k, _)| k.clone()); + oldest.inspect(|k| { + m.remove(k); + }) + } else { + None + }; + m.insert(id.clone(), Instant::now()); + evicted + }; + if let Some(old) = evicted { + cleanup_scope(app, &old); + } + id +} + +fn touch_mcp_session(app: &Shared, id: &str) -> bool { + match app.mcp_sessions.lock().unwrap().get_mut(id) { + Some(t) => { + *t = Instant::now(); + true + } + None => false, + } +} + +fn terminate_mcp_session(app: &Shared, id: &str) -> bool { + let existed = app.mcp_sessions.lock().unwrap().remove(id).is_some(); + if existed { + cleanup_scope(app, id); + } + existed +} + +/// 释放某个客户端作用域的全部资源:进行中的调用、shell 会话、GET 流 +fn cleanup_scope(app: &Shared, scope: &str) { + let prefix = format!("{scope}\n"); + let ks: Vec> = app + .inflight + .lock() + .unwrap() + .iter() + .filter(|(k, _)| k.starts_with(&prefix)) + .map(|(_, v)| v.clone()) + .collect(); + for k in ks { + k.kill(true); + } + let ss: Vec> = { + let mut m = app.sessions.lock().unwrap(); + let ids: Vec = m.iter().filter(|(_, s)| s.owner == scope).map(|(id, _)| *id).collect(); + ids.iter().filter_map(|id| m.remove(id)).collect() + }; + for s in ss { + close_session(&s); + } + app.hub.drop_scope(scope); +} + +/// 后台回收:空闲的 shell 会话与过期的 MCP 会话 +fn janitor_loop(app: Shared) { + loop { + thread::sleep(JANITOR_INTERVAL); + if app.exit_flag.load(Ordering::SeqCst) { + return; + } + let stale: Vec> = { + let mut m = app.sessions.lock().unwrap(); + let ids: Vec = m + .iter() + .filter(|(_, s)| s.active.load(Ordering::SeqCst) == 0 && s.last_used.lock().unwrap().elapsed() > SESSION_IDLE) + .map(|(id, _)| *id) + .collect(); + ids.iter().filter_map(|id| m.remove(id)).collect() + }; + for s in stale { + close_session(&s); + } + let expired: Vec = { + let mut m = app.mcp_sessions.lock().unwrap(); + let ids: Vec = m.iter().filter(|(_, t)| t.elapsed() > MCP_SESSION_IDLE).map(|(k, _)| k.clone()).collect(); + for k in &ids { + m.remove(k); + } + ids + }; + for s in expired { + cleanup_scope(&app, &s); + } + } +} + // --------------------------------------------------------------------------- // stdio 传输 // --------------------------------------------------------------------------- @@ -1227,11 +2447,16 @@ fn read_line_capped(r: &mut R, cap: usize, buf: &mut Vec) -> io: } fn run_stdio(app: Shared) -> io::Result<()> { + let ctx = Ctx { + scope: STDIO_SCOPE.to_string(), + sink: Sink::Stdio(app.stdout.clone()), + base_url: None, + }; let stdin = io::stdin(); let mut reader = BufReader::with_capacity(64 * 1024, stdin); let mut buf: Vec = Vec::with_capacity(512); loop { - // 顶部检查退出标志:exit 通知/信号处理后,即使 stdin 仍阻塞读取也能退出 + // 顶部检查退出标志:notifications/exit 处理后立即退出(信号由信号线程直接清理退出) if app.exit_flag.load(Ordering::SeqCst) { break; } @@ -1245,14 +2470,10 @@ fn run_stdio(app: Shared) -> io::Result<()> { break; } } - // 去掉尾部 \r\n - let line = match buf.iter().rposition(|&b| b == b'\n') { - Some(i) => &buf[..i], - None => &buf[..], - }; - let line = match line.iter().rposition(|&b| b == b'\r') { - Some(i) if i + 1 == line.len() => &line[..i], - _ => line, + // 去掉尾部 \r + let line = match buf.last() { + Some(b'\r') => &buf[..buf.len() - 1], + _ => &buf[..], }; if line.is_empty() { continue; @@ -1260,12 +2481,12 @@ fn run_stdio(app: Shared) -> io::Result<()> { let v = match serde_json::from_slice::(line) { Ok(v) => v, Err(_) => { - app.sink.send(PARSE_ERROR); + ctx.sink.send(PARSE_ERROR); continue; } }; - if let Some(resp) = process_message(&v, &app, true) { - app.sink.send(&resp.to_string()); + if let Some(resp) = process_message(&v, &app, &ctx, true) { + ctx.sink.send(&resp.to_string()); } } Ok(()) @@ -1278,6 +2499,7 @@ fn run_stdio(app: Shared) -> io::Result<()> { struct ReqHead { method: String, path: String, + query: String, conn_close: bool, headers: HashMap, } @@ -1293,11 +2515,7 @@ fn read_http_head(r: &mut R) -> io::Result>> { return Ok(if buf.is_empty() { None } else { Some(buf) }); } // 空行结束头部(\r\n\r\n 或 \n\n) - let len = buf.len(); - if len >= 2 && buf[len - 1] == b'\n' && buf[len - 2] == b'\n' { - return Ok(Some(buf)); - } - if len >= 4 && buf[len - 1] == b'\n' && buf[len - 2] == b'\r' && buf[len - 3] == b'\n' && buf[len - 4] == b'\r' { + if buf.ends_with(b"\n\n") || buf.ends_with(b"\r\n\r\n") { return Ok(Some(buf)); } } @@ -1309,9 +2527,9 @@ fn parse_http_head(buf: &[u8]) -> Result { let req_line = lines.next().ok_or(())?; let mut parts = req_line.split_whitespace(); let method = parts.next().ok_or(())?.to_string(); - let raw_path = parts.next().ok_or(())?.to_string(); - let path = raw_path.split('?').next().unwrap_or("").to_string(); - let mut headers = HashMap::new(); + let raw_path = parts.next().ok_or(())?; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let mut headers: HashMap = HashMap::new(); let mut conn_close = false; for line in lines { let line = line.trim_end_matches('\r'); @@ -1324,17 +2542,114 @@ fn parse_http_head(buf: &[u8]) -> Result { if key == "connection" && val.eq_ignore_ascii_case("close") { conn_close = true; } - headers.insert(key, val); + // 同名头合并(RFC 9110) + match headers.get_mut(&key) { + Some(old) => { + old.push_str(", "); + old.push_str(&val); + } + None => { + headers.insert(key, val); + } + } } } Ok(ReqHead { method, - path, + path: path.to_string(), + query: query.to_string(), conn_close, headers, }) } +enum BodyKind { + Len(u64), + Chunked, +} + +/// 请求体长度:Transfer-Encoding: chunked 优先;无长度信息视为空 body +fn body_kind(req: &ReqHead) -> Result { + if let Some(te) = req.headers.get("transfer-encoding") { + if te.to_ascii_lowercase().contains("chunked") { + return Ok(BodyKind::Chunked); + } + } + match req.headers.get("content-length") { + Some(v) => v.trim().parse().map(BodyKind::Len).map_err(|_| ()), + None => Ok(BodyKind::Len(0)), + } +} + +/// 请求体读取器:支持 Content-Length 与 chunked,超过 limit 报错 +struct BodyReader<'a, R: BufRead> { + r: &'a mut R, + chunked: bool, + remaining: u64, + done: bool, + total: u64, + limit: u64, +} + +impl<'a, R: BufRead> BodyReader<'a, R> { + fn new(r: &'a mut R, kind: BodyKind, limit: u64) -> Self { + match kind { + BodyKind::Len(n) => BodyReader { r, chunked: false, remaining: n, done: n == 0, total: 0, limit }, + BodyKind::Chunked => BodyReader { r, chunked: true, remaining: 0, done: false, total: 0, limit }, + } + } + + fn read_line(&mut self) -> io::Result { + let mut line = Vec::new(); + (&mut *self.r).take(4096).read_until(b'\n', &mut line)?; + if !line.ends_with(b"\n") { + return Err(io::Error::new(io::ErrorKind::InvalidData, "chunk 格式非法")); + } + Ok(String::from_utf8_lossy(&line).trim().to_string()) + } +} + +impl Read for BodyReader<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.done || buf.is_empty() { + return Ok(0); + } + if self.chunked && self.remaining == 0 { + let line = self.read_line()?; + let size_s = line.split(';').next().unwrap_or("").trim(); + let size = u64::from_str_radix(size_s, 16) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "chunk 长度非法"))?; + if size == 0 { + // 跳过 trailer 直到空行 + while !self.read_line()?.is_empty() {} + self.done = true; + return Ok(0); + } + self.remaining = size; + } + let want = (buf.len() as u64).min(self.remaining) as usize; + let n = self.r.read(&mut buf[..want])?; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "请求体提前结束")); + } + self.remaining -= n as u64; + self.total += n as u64; + if self.total > self.limit { + return Err(io::Error::other(TOO_LARGE)); + } + if self.remaining == 0 { + if self.chunked { + if !self.read_line()?.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "chunk 结尾缺少 CRLF")); + } + } else { + self.done = true; + } + } + Ok(n) + } +} + fn origin_allowed(cfg: &Config, origin: &str) -> bool { if cfg.allow_origins.iter().any(|o| origin == o || origin.starts_with(&format!("{o}:"))) { return true; @@ -1349,30 +2664,62 @@ fn origin_allowed(cfg: &Config, origin: &str) -> bool { host == "localhost" || host == "127.0.0.1" || host == "[::1]" } -fn respond_plain(w: &mut impl Write, status: &str, body: &[u8]) -> io::Result<()> { +/// 客户端视角的服务器地址(file_link 用),支持反向代理的 X-Forwarded-Proto / X-Forwarded-Host +fn request_base_url(req: &ReqHead) -> Option { + let first = |k: &str| req.headers.get(k).map(|v| v.split(',').next().unwrap_or("").trim().to_string()); + let host = first("x-forwarded-host").filter(|h| !h.is_empty()).or_else(|| first("host"))?; + let proto = first("x-forwarded-proto").filter(|p| p == "https" || p == "http").unwrap_or_else(|| "http".into()); + Some(format!("{proto}://{host}")) +} + +const CORS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Expose-Headers: Mcp-Session-Id\r\n"; + +fn status_line(code: u16) -> &'static str { + match code { + 200 => "200 OK", + 201 => "201 Created", + 202 => "202 Accepted", + 206 => "206 Partial Content", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 409 => "409 Conflict", + 413 => "413 Payload Too Large", + 416 => "416 Range Not Satisfiable", + 431 => "431 Request Header Fields Too Large", + 503 => "503 Service Unavailable", + _ => "500 Internal Server Error", + } +} + +fn respond(w: &mut impl Write, status: &str, ctype: &str, extra: &str, body: &[u8]) -> io::Result<()> { write!( w, - "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n", + "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n{CORS}{extra}\r\n", body.len() )?; w.write_all(body)?; w.flush() } -fn respond_json(w: &mut impl Write, status: &str, body: &str) -> io::Result<()> { - write!( - w, - "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n", - body.len() - )?; - w.write_all(body.as_bytes())?; - w.flush() +fn respond_plain(w: &mut impl Write, status: &str, body: &[u8]) -> io::Result<()> { + respond(w, status, "text/plain; charset=utf-8", "", body) +} + +fn respond_json(w: &mut impl Write, status: &str, extra: &str, body: &str) -> io::Result<()> { + respond(w, status, "application/json; charset=utf-8", extra, body.as_bytes()) +} + +fn respond_err(w: &mut impl Write, code: u16, msg: &str) -> io::Result<()> { + respond_json(w, status_line(code), "", &json!({"error": msg}).to_string()) } fn respond_options(w: &mut impl Write) -> io::Result<()> { write!( w, - "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization, Accept\r\nAccess-Control-Max-Age: 86400\r\nConnection: keep-alive\r\n\r\n" + "HTTP/1.1 204 No Content\r\n{CORS}Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization, Accept, Range, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID\r\nAccess-Control-Max-Age: 86400\r\nConnection: keep-alive\r\n\r\n" )?; w.flush() } @@ -1380,37 +2727,46 @@ fn respond_options(w: &mut impl Write) -> io::Result<()> { fn respond_401(w: &mut impl Write) -> io::Result<()> { write!( w, - "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer\r\nContent-Length: 0\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n" + "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer\r\nContent-Length: 0\r\nConnection: close\r\n{CORS}\r\n" )?; w.flush() } -/// POST 的单事件 SSE 响应(规范:响应发送后服务器 SHOULD 关闭流) -fn write_sse_event(w: &mut impl Write, data: &str) -> io::Result<()> { +fn write_sse_head(w: &mut impl Write, extra: &str, keep_alive: bool) -> io::Result<()> { + let conn = if keep_alive { "keep-alive" } else { "close" }; write!( w, - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\nevent: message\r\ndata: {data}\r\n\r\n" + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: {conn}\r\n{CORS}{extra}\r\n" )?; w.flush() } -/// GET:长连接 SSE 流,接收服务器通知,15s 心跳保活 -fn sse_stream(w: &mut impl Write, app: &Shared) { - let _ = write!( - w, - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n" - ); - let _ = w.flush(); +fn write_sse_data(w: &mut impl Write, data: &str) -> io::Result<()> { + write!(w, "event: message\r\ndata: {data}\r\n\r\n")?; + w.flush() +} + +/// 请求带 Expect: 100-continue 时先回 100 Continue;返回 false 表示写入失败 +fn send_continue(req: &ReqHead, w: &mut impl Write) -> bool { + let expects = req + .headers + .get("expect") + .map(|e| e.eq_ignore_ascii_case("100-continue")) + .unwrap_or(false); + !expects || w.write_all(b"HTTP/1.1 100 Continue\r\n\r\n").and_then(|_| w.flush()).is_ok() +} + +/// GET:长连接 SSE 流,接收本作用域的服务器通知,10s 心跳保活 +fn sse_stream(w: &mut impl Write, app: &Shared, scope: &str) { + if write_sse_head(w, "", true).is_err() { + return; + } let (tx, rx) = mpsc::channel::(); - let id = app.hub.add(tx); + let id = app.hub.add(scope, tx); loop { match rx.recv_timeout(SSE_HEARTBEAT) { Ok(msg) => { - let mut ev = String::with_capacity(msg.len() + 32); - ev.push_str("event: message\r\ndata: "); - ev.push_str(&msg); - ev.push_str("\r\n\r\n"); - if w.write_all(ev.as_bytes()).and_then(|_| w.flush()).is_err() { + if write_sse_data(w, &msg).is_err() { break; } } @@ -1418,6 +2774,9 @@ fn sse_stream(w: &mut impl Write, app: &Shared) { if w.write_all(b": ping\r\n\r\n").and_then(|_| w.flush()).is_err() { break; } + if scope != ANON_SCOPE { + touch_mcp_session(app, scope); // 有活跃流的会话不过期 + } } Err(mpsc::RecvTimeoutError::Disconnected) => break, } @@ -1425,6 +2784,307 @@ fn sse_stream(w: &mut impl Write, app: &Shared) { app.hub.remove(id); } +/// /mcp:返回是否可以继续复用连接 +fn handle_mcp(req: &ReqHead, reader: &mut R, w: &mut W, app: &Shared) -> bool { + let sid_hdr = req.headers.get("mcp-session-id").cloned(); + if let Some(s) = &sid_hdr { + if !touch_mcp_session(app, s) { + // 规范:未知/过期的会话 ID 返回 404,客户端应重新 initialize + let e = rpc_error(&Value::Null, -32001, "会话不存在或已过期,请重新 initialize"); + let _ = respond_json(w, "404 Not Found", "", &e.to_string()); + return false; + } + } + match req.method.as_str() { + "GET" => { + let scope = sid_hdr.unwrap_or_else(|| ANON_SCOPE.to_string()); + sse_stream(w, app, &scope); + false + } + "DELETE" => { + match sid_hdr { + Some(s) => { + terminate_mcp_session(app, &s); + let _ = respond_plain(w, "200 OK", b""); + } + None => { + let _ = respond_plain(w, "400 Bad Request", b"missing Mcp-Session-Id"); + } + } + false + } + "POST" => { + let kind = match body_kind(req) { + Ok(k) => k, + Err(_) => { + let _ = respond_plain(w, "400 Bad Request", b"bad content-length"); + return false; + } + }; + if let BodyKind::Len(n) = kind { + if n > MAX_HTTP_BODY as u64 { + let _ = respond_plain(w, "413 Payload Too Large", b"body too large"); + return false; + } + } + // curl 等客户端发送较大 body 前会等 100 Continue,不回应会白等约 1 秒 + if !send_continue(req, w) { + return false; + } + let mut body = Vec::new(); + if let Err(e) = BodyReader::new(reader, kind, MAX_HTTP_BODY as u64).read_to_end(&mut body) { + if is_too_large(&e) { + let _ = respond_plain(w, "413 Payload Too Large", b"body too large"); + } + return false; + } + let v: Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => { + let e = rpc_error(&Value::Null, -32700, "parse error"); + let _ = respond_json(w, "400 Bad Request", "", &e.to_string()); + return false; + } + }; + if !v.is_object() { + let e = rpc_error(&Value::Null, -32600, "invalid request"); + let _ = respond_json(w, "400 Bad Request", "", &e.to_string()); + return false; + } + let method = v.get("method").and_then(Value::as_str).unwrap_or(""); + let has_id = v.get("id").is_some(); + let is_call = method == "tools/call" && has_id; + let mut extra = String::new(); + let scope = match sid_hdr { + Some(s) => s, + None if method == "initialize" && has_id => { + let s = create_mcp_session(app); + extra = format!("Mcp-Session-Id: {s}\r\n"); + s + } + None => ANON_SCOPE.to_string(), + }; + let base_url = request_base_url(req); + let wants_sse = req + .headers + .get("accept") + .map(|a| a.contains("text/event-stream")) + .unwrap_or(false); + if wants_sse && is_call { + // 工具调用走 SSE:先推送本次调用的进度通知,最后推送结果,然后关闭流 + let (tx, rx) = mpsc::channel::(); + let ctx = Ctx { scope, sink: Sink::Chan(tx), base_url }; + let app2 = app.clone(); + let spawned = thread::Builder::new().name("bolt-call".into()).spawn(move || { + if let Some(r) = process_message(&v, &app2, &ctx, false) { + ctx.sink.send(&r.to_string()); + } + }); + if spawned.is_err() { + let _ = respond_plain(w, "503 Service Unavailable", b"cannot spawn worker"); + return false; + } + if write_sse_head(w, &extra, false).is_err() { + return false; + } + // 客户端断开不视为取消(规范),工具继续执行,结果丢弃 + for msg in rx { + if write_sse_data(w, &msg).is_err() { + break; + } + } + return false; + } + let ctx = Ctx { + scope: scope.clone(), + sink: Sink::Hub(app.hub.clone(), scope), + base_url, + }; + match process_message(&v, app, &ctx, false) { + Some(resp) => { + let s = resp.to_string(); + if wants_sse { + let _ = write_sse_head(w, &extra, false).and_then(|_| write_sse_data(w, &s)); + return false; // 规范:响应后关闭 SSE 流 + } + let _ = respond_json(w, "200 OK", &extra, &s); + } + None => { + // 通知 → 202 Accepted,无 body + let _ = respond(w, "202 Accepted", "text/plain; charset=utf-8", &extra, b""); + } + } + true + } + _ => { + let _ = respond_plain(w, "405 Method Not Allowed", b"method not allowed"); + false + } + } +} + +/// 解析单区间 Range: bytes=a-b / bytes=a- / bytes=-n;返回 [start, end) +fn parse_range(h: &str, size: u64) -> Option<(u64, u64)> { + let spec = h.trim().strip_prefix("bytes=")?; + if spec.contains(',') { + return None; + } + let (a, b) = spec.split_once('-')?; + let (a, b) = (a.trim(), b.trim()); + if a.is_empty() { + let n: u64 = b.parse().ok()?; + if n == 0 || size == 0 { + return None; + } + return Some((size - n.min(size), size)); + } + let s: u64 = a.parse().ok()?; + if s >= size { + return None; + } + let e = if b.is_empty() { size } else { b.parse::().ok()?.min(size - 1) + 1 }; + if e <= s { + None + } else { + Some((s, e)) + } +} + +/// /files:GET/HEAD 下载,PUT 上传。signed=true 表示凭 file_link 签名访问(已跳过 Bearer 校验) +fn handle_files(req: &ReqHead, reader: &mut R, w: &mut W, app: &Shared, signed: bool) -> bool { + let q = parse_query(&req.query); + let Some(path) = q.get("path").filter(|p| !p.is_empty()) else { + let _ = respond_err(w, 400, "缺少 path 参数"); + return false; + }; + let method = req.method.as_str(); + let mode_s = q.get("mode").map(String::as_str).unwrap_or("overwrite"); + let mkdirs = matches!(q.get("mkdirs").map(String::as_str), Some("1") | Some("true")); + if signed { + let exp: u64 = q.get("exp").and_then(|e| e.parse().ok()).unwrap_or(0); + if unix_now() > exp { + let _ = respond_err(w, 403, "链接已过期"); + return false; + } + let sig_method = if method == "HEAD" { "GET" } else { method }; + let (m, k) = if sig_method == "PUT" { (mode_s, mkdirs) } else { ("-", false) }; + let expected = link_sig(app, sig_method, path, m, k, exp); + let got = q.get("sig").map(String::as_str).unwrap_or(""); + if !ct_eq(expected.as_bytes(), got.as_bytes()) { + let _ = respond_err(w, 403, "签名无效"); + return false; + } + } + let cfg = &app.cfg; + match method { + "GET" | "HEAD" => { + let p = match resolve_path(cfg, path, false) { + Ok(p) => p, + Err((s, m)) => { + let _ = respond_err(w, s, &m); + return true; + } + }; + let md = match fs::metadata(&p) { + Ok(md) if md.is_file() => md, + Ok(_) => { + let _ = respond_err(w, 400, "不是普通文件"); + return true; + } + Err(e) => { + let (s, m) = io_ferr(e, "读取文件信息失败", &p); + let _ = respond_err(w, s, &m); + return true; + } + }; + let size = md.len(); + let (start, end, partial) = match req.headers.get("range") { + Some(r) => match parse_range(r, size) { + Some((a, b)) => (a, b, true), + None => { + let extra = format!("Content-Range: bytes */{size}\r\n"); + let _ = respond(w, "416 Range Not Satisfiable", "text/plain; charset=utf-8", &extra, b""); + return true; + } + }, + None => (0, size, false), + }; + let mut f = match File::open(&p).and_then(|mut f| f.seek(SeekFrom::Start(start)).map(|_| f)) { + Ok(f) => f, + Err(e) => { + let (s, m) = io_ferr(e, "打开文件失败", &p); + let _ = respond_err(w, s, &m); + return true; + } + }; + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let status = if partial { "206 Partial Content" } else { "200 OK" }; + let range_hdr = if partial { + format!("Content-Range: bytes {start}-{}/{size}\r\n", end - 1) + } else { + String::new() + }; + let head = write!( + w, + "HTTP/1.1 {status}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\nContent-Disposition: attachment; filename*=UTF-8''{}\r\n{range_hdr}Connection: keep-alive\r\n{CORS}\r\n", + end - start, + pct_encode(&name) + ); + if head.is_err() { + return false; + } + if method == "GET" && io::copy(&mut (&mut f).take(end - start), w).is_err() { + return false; + } + w.flush().is_ok() + } + "PUT" => { + let Some(mode) = WriteMode::parse(mode_s) else { + let _ = respond_err(w, 400, "mode 必须是 overwrite / create / append"); + return false; + }; + let kind = match body_kind(req) { + Ok(k) => k, + Err(_) => { + let _ = respond_err(w, 400, "Content-Length 非法"); + return false; + } + }; + if let BodyKind::Len(n) = kind { + if n > cfg.max_upload { + let _ = respond_err(w, 413, &format!("{TOO_LARGE}({} 字节)", cfg.max_upload)); + return false; + } + } + // 先校验路径再接收数据,避免白传 + if let Err((s, m)) = resolve_path(cfg, path, false) { + let _ = respond_err(w, s, &m); + return false; + } + if !send_continue(req, w) { + return false; + } + let mut body = BodyReader::new(reader, kind, cfg.max_upload); + match write_file(cfg, path, mode, mkdirs, &mut body, cfg.max_upload) { + Ok(info) => { + let code = if info["created"] == true { 201 } else { 200 }; + let complete = body.done; + let _ = respond_json(w, status_line(code), "", &info.to_string()); + complete + } + Err((s, m)) => { + let _ = respond_err(w, s, &m); + false + } + } + } + _ => { + let _ = respond_err(w, 405, "仅支持 GET / HEAD / PUT"); + false + } + } +} + fn conn_loop(stream: TcpStream, app: Shared) { let _ = stream.set_read_timeout(Some(Duration::from_secs(120))); let _ = stream.set_write_timeout(Some(Duration::from_secs(60))); @@ -1432,15 +3092,17 @@ fn conn_loop(stream: TcpStream, app: Shared) { Ok(s) => s, Err(_) => return, }; - let mut reader = BufReader::with_capacity(16 * 1024, reader_stream); - let mut writer = BufWriter::with_capacity(16 * 1024, stream); + let mut reader = BufReader::with_capacity(64 * 1024, reader_stream); + let mut writer = BufWriter::with_capacity(64 * 1024, stream); loop { let head = match read_http_head(&mut reader) { Ok(Some(h)) => h, Ok(None) => return, // 客户端关闭 - Err(_) => { - let _ = respond_plain(&mut writer, "431 Request Header Fields Too Large", b"header too large"); + Err(e) => { + if e.kind() == io::ErrorKind::InvalidData { + let _ = respond_plain(&mut writer, status_line(431), b"header too large"); + } return; } }; @@ -1455,97 +3117,74 @@ fn conn_loop(stream: TcpStream, app: Shared) { let _ = respond_options(&mut writer); continue; } + // 以下错误都在读取 body 之前返回,直接关闭连接,避免把未读的 body 当成下一个请求 // 防 DNS rebinding:校验 Origin(规范 MUST) if let Some(origin) = req.headers.get("origin") { if !origin_allowed(&app.cfg, origin) { let _ = respond_plain(&mut writer, "403 Forbidden", b"origin not allowed"); - continue; + return; } } - if let Some(tok) = &app.cfg.token { - let ok = req - .headers - .get("authorization") - .map(|a| a == &format!("Bearer {tok}")) - .unwrap_or(false); - if !ok { - let _ = respond_401(&mut writer); - continue; + let signed = req.path == "/files" && parse_query(&req.query).contains_key("sig"); + if !signed { + if let Some(expected) = &app.auth { + let ok = req + .headers + .get("authorization") + .map(|a| ct_eq(a.as_bytes(), expected)) + .unwrap_or(false); + if !ok { + let _ = respond_401(&mut writer); + return; + } } } - if req.path != "/mcp" { - let _ = respond_plain(&mut writer, "404 Not Found", b"not found"); + let keep = match req.path.as_str() { + "/mcp" => handle_mcp(&req, &mut reader, &mut writer, &app), + "/files" => handle_files(&req, &mut reader, &mut writer, &app, signed), + _ => { + let _ = respond_plain(&mut writer, "404 Not Found", b"not found"); + false + } + }; + if !keep || req.conn_close { return; } - match req.method.as_str() { - "GET" => { - sse_stream(&mut writer, &app); - return; - } - "POST" => { - let len: usize = req - .headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - if len > MAX_HTTP_BODY { - let _ = respond_plain(&mut writer, "413 Payload Too Large", b"body too large"); - return; - } - let mut body = vec![0u8; len]; - if reader.read_exact(&mut body).is_err() { - return; - } - let v: Value = match serde_json::from_slice(&body) { - Ok(v) => v, - Err(_) => { - let e = rpc_error(&Value::Null, -32700, "parse error"); - let _ = respond_json(&mut writer, "400 Bad Request", &e.to_string()); - return; - } - }; - if !v.is_object() { - let e = rpc_error(&Value::Null, -32600, "invalid request"); - let _ = respond_json(&mut writer, "400 Bad Request", &e.to_string()); - return; - } - let wants_sse = req - .headers - .get("accept") - .map(|a| a.contains("text/event-stream")) - .unwrap_or(false); - match process_message(&v, &app, false) { - Some(resp) => { - let s = resp.to_string(); - if wants_sse { - let _ = write_sse_event(&mut writer, &s); - return; // 规范:响应后关闭 SSE 流 - } - let _ = respond_json(&mut writer, "200 OK", &s); - } - None => { - // 通知 → 202 Accepted,无 body - let _ = respond_plain(&mut writer, "202 Accepted", b""); - } - } - if req.conn_close { - return; - } - } - _ => { - let _ = respond_plain(&mut writer, "405 Method Not Allowed", b"method not allowed"); - return; - } - } } } +/// HTTP 连接名额(RAII 归还) +struct ConnGuard(Shared); + +impl Drop for ConnGuard { + fn drop(&mut self) { + self.0.active_conns.fetch_sub(1, Ordering::SeqCst); + } +} + +fn spawn_conn(mut s: TcpStream, app: &Shared) { + let _ = s.set_nodelay(true); + if app.active_conns.fetch_add(1, Ordering::SeqCst) >= MAX_HTTP_CONNS { + app.active_conns.fetch_sub(1, Ordering::SeqCst); + let _ = s.set_write_timeout(Some(Duration::from_secs(1))); + let _ = s.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + return; + } + let guard = ConnGuard(app.clone()); + let app2 = app.clone(); + // spawn 失败时闭包(连同 guard)被丢弃,名额自动归还 + let _ = thread::Builder::new().name("bolt-http".into()).spawn(move || { + let _g = guard; + conn_loop(s, app2) + }); +} + #[cfg(unix)] fn run_http(addr: &str, app: Shared) -> io::Result<()> { use std::os::unix::io::AsRawFd; let listener = TcpListener::bind(addr)?; eprintln!("[bolt] {VERSION} 监听 http://{addr}/mcp (Streamable HTTP)"); - log_policy(&app); + log_policy(&app, addr); let fd = listener.as_raw_fd(); let mut pollfd = libc::pollfd { fd, @@ -1559,14 +3198,7 @@ fn run_http(addr: &str, app: Shared) -> io::Result<()> { let r = unsafe { libc::poll(&mut pollfd, 1, 200) }; if r > 0 { match listener.accept() { - Ok((s, _)) => { - let _ = s.set_nodelay(true); - let app2 = app.clone(); - thread::Builder::new() - .name("bolt-http".into()) - .spawn(move || conn_loop(s, app2)) - .unwrap(); - } + Ok((s, _)) => spawn_conn(s, &app), Err(e) => eprintln!("[bolt] accept 错误: {e}"), } } else if r < 0 { @@ -1583,32 +3215,49 @@ fn run_http(addr: &str, app: Shared) -> io::Result<()> { #[cfg(windows)] fn run_http(addr: &str, app: Shared) -> io::Result<()> { let listener = TcpListener::bind(addr)?; + // 非阻塞 accept + 短暂休眠,以便检查退出标志 + listener.set_nonblocking(true)?; eprintln!("[bolt] {VERSION} 监听 http://{addr}/mcp (Streamable HTTP)"); - log_policy(&app); + log_policy(&app, addr); loop { + if app.exit_flag.load(Ordering::SeqCst) { + break; + } match listener.accept() { Ok((s, _)) => { - let _ = s.set_nodelay(true); - let app2 = app.clone(); - thread::Builder::new() - .name("bolt-http".into()) - .spawn(move || conn_loop(s, app2)) - .unwrap(); + let _ = s.set_nonblocking(false); + spawn_conn(s, &app); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(50)), + Err(e) => { + eprintln!("[bolt] accept 错误: {e}"); + thread::sleep(Duration::from_millis(50)); } - Err(e) => eprintln!("[bolt] accept 错误: {e}"), } } + Ok(()) } -fn log_policy(app: &Shared) { - if app.cfg.token.is_some() { +fn log_policy(app: &Shared, addr: &str) { + let cfg = &app.cfg; + if cfg.token.is_some() { eprintln!("[bolt] 认证: Bearer Token 已启用"); + } else if !(addr.starts_with("127.") || addr.starts_with("localhost") || addr.starts_with("[::1]")) { + eprintln!("[bolt] 警告: 监听非本机地址但未设置 --token,任何人都可以执行命令!"); } - if !app.cfg.allow.is_empty() { - eprintln!("[bolt] 白名单: {}", app.cfg.allow.join(", ")); + if !cfg.allow.is_empty() { + eprintln!("[bolt] 白名单: {}", cfg.allow.join(", ")); } - if !app.cfg.deny.is_empty() { - eprintln!("[bolt] 黑名单: {}", app.cfg.deny.join(", ")); + if !cfg.deny.is_empty() { + eprintln!("[bolt] 黑名单: {}", cfg.deny.join(", ")); + } + match files_enabled(cfg) { + Err(_) => eprintln!("[bolt] 文件功能: 已禁用(配置了白名单但未设置 --file-root)"), + Ok(()) if cfg.file_roots.is_empty() => eprintln!("[bolt] 文件功能: 不限目录"), + Ok(()) => { + let roots: Vec = cfg.file_roots.iter().map(|p| display_path(p)).collect(); + eprintln!("[bolt] 文件功能: 限于 {}", roots.join(", ")); + } } } @@ -1616,8 +3265,9 @@ fn log_policy(app: &Shared) { // 信号与清理 // --------------------------------------------------------------------------- +/// SIGINT/SIGTERM:清理全部子进程后直接退出(stdio 模式下主线程可能阻塞在读 stdin) #[cfg(unix)] -fn install_signal_flag(flag: Arc) { +fn install_signal_handler(app: Shared) { unsafe { let mut set: libc::sigset_t = std::mem::zeroed(); libc::sigemptyset(&mut set); @@ -1627,14 +3277,35 @@ fn install_signal_flag(flag: Arc) { thread::spawn(move || { let mut sig: libc::c_int = 0; libc::sigwait(&set, &mut sig); - flag.store(true, Ordering::SeqCst); + app.exit_flag.store(true, Ordering::SeqCst); eprintln!("[bolt] 收到信号 {sig},正在清理并退出…"); + shutdown_cleanup(&app); + std::process::exit(0); }); } } -#[cfg(not(unix))] -fn install_signal_flag(_flag: Arc) {} +#[cfg(windows)] +static CTRL_APP: OnceLock = OnceLock::new(); + +#[cfg(windows)] +unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 { + if let Some(app) = CTRL_APP.get() { + app.exit_flag.store(true, Ordering::SeqCst); + eprintln!("[bolt] 收到控制台中断,正在清理并退出…"); + shutdown_cleanup(app); + } + std::process::exit(0) +} + +/// Ctrl+C / Ctrl+Break / 关闭控制台:清理全部子进程后退出 +#[cfg(windows)] +fn install_signal_handler(app: Shared) { + let _ = CTRL_APP.set(app); + unsafe { + win::SetConsoleCtrlHandler(Some(ctrl_handler), 1); + } +} /// 退出前杀掉所有进行中的命令与会话,避免孤儿进程 fn shutdown_cleanup(app: &Shared) { @@ -1644,7 +3315,7 @@ fn shutdown_cleanup(app: &Shared) { } let sessions: Vec> = app.sessions.lock().unwrap().drain().map(|(_, s)| s).collect(); for s in sessions { - kill_tree(s.pid); + close_session(&s); } } @@ -1658,7 +3329,14 @@ fn main() { return; }; let app = Arc::new(App::new(cfg)); - install_signal_flag(app.exit_flag.clone()); + install_signal_handler(app.clone()); + { + let app2 = app.clone(); + thread::Builder::new() + .name("bolt-janitor".into()) + .spawn(move || janitor_loop(app2)) + .expect("无法启动回收线程"); + } let res = match &app.cfg.mode { Mode::Stdio => run_stdio(app.clone()), Mode::Http(addr) => run_http(addr, app.clone()), @@ -1677,6 +3355,40 @@ fn main() { mod tests { use super::*; + fn ctx(scope: &str) -> Ctx { + Ctx { + scope: scope.into(), + sink: Sink::Null, + base_url: Some("http://h".into()), + } + } + + fn app_with(cfg: Config) -> Shared { + Arc::new(App::new(cfg)) + } + + /// 测试用临时目录(规范化路径),结束时删除 + struct TmpDir(PathBuf); + impl TmpDir { + fn new() -> TmpDir { + let p = env::temp_dir().join(format!("bolt-test-{}", random_hex(6))); + fs::create_dir_all(&p).unwrap(); + TmpDir(fs::canonicalize(&p).unwrap()) + } + fn s(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn sc(r: &Value) -> &Value { + &r["structuredContent"] + } + #[test] fn test_parse_size() { assert_eq!(parse_size("1024"), Some(1024)); @@ -1689,26 +3401,108 @@ mod tests { #[test] fn test_policy() { let cfg = Config { - mode: Mode::Stdio, - token: None, allow: vec!["git".into(), "docker".into()], deny: vec!["rm".into()], - timeout_ms: 1000, - cwd: None, - max_output: 1024, - allow_origins: vec![], + ..Default::default() }; - assert!(policy_check(&cfg, "git").is_ok()); - assert!(policy_check(&cfg, "git status").is_ok()); - assert!(policy_check(&cfg, "docker ps").is_ok()); - assert!(policy_check(&cfg, "curl").is_err()); + for ok in ["git", "git status", "docker ps", "/usr/bin/git log", "GIT_DIR=x git log"] { + assert!(policy_check(&cfg, ok, true).is_ok(), "{ok}"); + } + for bad in [ + "curl x", + "gitk", + "git status; rm -rf /", + "git status && curl x", + "git log | sh", + "git $(curl x)", + "git `id`", + "git log > /etc/passwd", + "git log\nrm -rf /", + ] { + assert!(policy_check(&cfg, bad, true).is_err(), "{bad}"); + } + assert!(policy_check(&cfg, "git", false).is_ok()); + assert!(policy_check(&cfg, "/usr/bin/git", false).is_ok()); + assert!(policy_check(&cfg, "rm", false).is_err()); + let cfg2 = Config { - allow: vec![], deny: vec!["rm".into()], - ..cfg + ..Default::default() }; - assert!(policy_check(&cfg2, "rm -rf /").is_err()); - assert!(policy_check(&cfg2, "ls").is_ok()); + for bad in ["rm -rf /", "ls; rm -rf /", "ls && /bin/rm x", "FOO=1 rm x", "echo $(rm x)", "\"rm\" x", "r\\m x"] { + assert!(policy_check(&cfg2, bad, true).is_err(), "{bad}"); + } + for ok in ["ls", "echo rm", "git rm x", "rmdir x", ""] { + assert!(policy_check(&cfg2, ok, true).is_ok(), "{ok}"); + } + } + + #[test] + fn test_utf8_complete_len() { + let s = "你好".as_bytes(); + assert_eq!(utf8_complete_len(&s[..6]), 6); + assert_eq!(utf8_complete_len(&s[..5]), 3); + assert_eq!(utf8_complete_len(&s[..4]), 3); + assert_eq!(utf8_complete_len(&s[..1]), 0); + assert_eq!(utf8_complete_len(b"ab"), 2); + assert_eq!(utf8_complete_len(b""), 0); + assert_eq!(utf8_complete_len(&[0xFF]), 1); + assert_eq!(buf_text(&s[..4], true), "你"); + } + + #[test] + fn test_crypto_and_encoding() { + assert_eq!(hex(&sha256(b"")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + // 百万个 'a',分块大小不一,覆盖缓冲拼接 + let mut h = Sha256::new(); + let data = vec![b'a'; 1_000_000]; + let mut off = 0; + for (i, step) in [1usize, 63, 64, 65, 1000, 4096].iter().cycle().enumerate() { + if off >= data.len() || i > 100_000 { + break; + } + let end = (off + step).min(data.len()); + h.update(&data[off..end]); + off = end; + } + assert_eq!(hex(&h.finish()), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); + // RFC 4231 用例 2 + assert_eq!( + hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + for (raw, enc) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foobar", "Zm9vYmFy")] { + assert_eq!(base64_encode(raw.as_bytes()), enc); + assert_eq!(base64_decode(enc).unwrap(), raw.as_bytes()); + } + assert_eq!(base64_decode("Zm9v\nYmFy").unwrap(), b"foobar"); + assert_eq!(base64_decode("Zg").unwrap(), b"f"); + let bin: Vec = (0..=255u8).collect(); + for n in 0..bin.len() { + assert_eq!(base64_decode(&base64_encode(&bin[..n])).unwrap(), &bin[..n]); + } + assert!(base64_decode("Zm9v!").is_err()); + assert!(base64_decode("Z").is_err()); + assert!(base64_decode("Zg==Zg").is_err()); + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); + assert_eq!(random_hex(8).len(), 16); + } + + #[test] + fn test_percent_and_query() { + let s = "/tmp/a b+c'd&e=f/你"; + let e = pct_encode(s); + assert!(!e.contains(' ') && !e.contains('\'') && !e.contains('&') && !e.contains('+')); + assert_eq!(pct_decode(&e).unwrap(), s); + let q = parse_query(&format!("path={e}&exp=10&flag")); + assert_eq!(q["path"], s); + assert_eq!(q["exp"], "10"); + assert_eq!(q["flag"], ""); + assert!(pct_decode("%zz").is_none()); + assert!(pct_decode("%4").is_none()); } #[test] @@ -1732,26 +3526,60 @@ mod tests { #[test] fn test_http_head_parse() { - let head = b"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\nConnection: close\r\nAccept: application/json, text/event-stream\r\n\r\n"; + let head = b"POST /files?path=%2Ftmp%2Fx&exp=1 HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\nConnection: close\r\nAccept: application/json\r\naccept: text/event-stream\r\n\r\n"; let req = parse_http_head(head).unwrap(); assert_eq!(req.method, "POST"); - assert_eq!(req.path, "/mcp"); + assert_eq!(req.path, "/files"); + assert_eq!(parse_query(&req.query)["path"], "/tmp/x"); assert!(req.conn_close); assert_eq!(req.headers.get("content-length").unwrap(), "10"); - assert!(req.headers.get("accept").unwrap().contains("text/event-stream")); + assert_eq!(req.headers.get("accept").unwrap(), "application/json, text/event-stream"); + assert_eq!(request_base_url(&req).unwrap(), "http://x"); + } + + #[test] + fn test_body_reader() { + let raw = b"4\r\nWiki\r\n5;ext=1\r\npedia\r\n0\r\nX-Trailer: 1\r\n\r\nNEXT"; + let mut r = BufReader::new(&raw[..]); + let mut out = Vec::new(); + BodyReader::new(&mut r, BodyKind::Chunked, 100).read_to_end(&mut out).unwrap(); + assert_eq!(out, b"Wikipedia"); + let mut rest = String::new(); + r.read_to_string(&mut rest).unwrap(); + assert_eq!(rest, "NEXT"); + // 超过上限 + let mut r = BufReader::new(&raw[..]); + let e = BodyReader::new(&mut r, BodyKind::Chunked, 5).read_to_end(&mut Vec::new()).unwrap_err(); + assert!(is_too_large(&e)); + // 定长 + let mut r = BufReader::new(&b"abcdefNEXT"[..]); + let mut out = Vec::new(); + BodyReader::new(&mut r, BodyKind::Len(6), 100).read_to_end(&mut out).unwrap(); + assert_eq!(out, b"abcdef"); + // 提前结束 + let mut r = BufReader::new(&b"abc"[..]); + assert!(BodyReader::new(&mut r, BodyKind::Len(6), 100).read_to_end(&mut Vec::new()).is_err()); + } + + #[test] + fn test_parse_range() { + assert_eq!(parse_range("bytes=0-9", 100), Some((0, 10))); + assert_eq!(parse_range("bytes=90-", 100), Some((90, 100))); + assert_eq!(parse_range("bytes=90-200", 100), Some((90, 100))); + assert_eq!(parse_range("bytes=-10", 100), Some((90, 100))); + assert_eq!(parse_range("bytes=-1000", 100), Some((0, 100))); + assert_eq!(parse_range("bytes=100-", 100), None); + assert_eq!(parse_range("bytes=5-2", 100), None); + assert_eq!(parse_range("bytes=0-1,5-6", 100), None); + assert_eq!(parse_range("items=0-1", 100), None); + assert_eq!(parse_range("bytes=0-0", 0), None); } #[test] fn test_origin() { let cfg = Config { - mode: Mode::Stdio, - token: None, - allow: vec![], - deny: vec![], - timeout_ms: 1000, - cwd: None, - max_output: 1024, allow_origins: vec!["https://example.com".into()], + ..Default::default() }; assert!(origin_allowed(&cfg, "http://localhost:3000")); assert!(origin_allowed(&cfg, "https://127.0.0.1")); @@ -1761,19 +3589,11 @@ mod tests { #[test] fn test_dispatch_basic() { - let app = Arc::new(App::new(Config { - mode: Mode::Stdio, - token: None, - allow: vec![], - deny: vec![], - timeout_ms: 1000, - cwd: None, - max_output: 1024, - allow_origins: vec![], - })); + let app = app_with(Config::default()); + let c = ctx("t"); // ping let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap(); - let r = process_message(&v, &app, false).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); assert_eq!(r["result"], json!({})); assert_eq!(r["id"], json!(1)); // initialize @@ -1781,126 +3601,391 @@ mod tests { r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#, ) .unwrap(); - let r = process_message(&v, &app, false).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); assert_eq!(r["result"]["protocolVersion"], "2024-11-05"); assert_eq!(r["result"]["serverInfo"]["name"], "bolt"); + // tools/list:stdio 模式有文件工具但没有 file_link + let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); + let names: Vec<&str> = r["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["run_command", "shell_open", "shell_write", "shell_read", "shell_close", "file_read", "file_write", "file_stat"]); // 未知方法 - let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":3,"method":"nope"}"#).unwrap(); - let r = process_message(&v, &app, false).unwrap(); + let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","id":4,"method":"nope"}"#).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); assert_eq!(r["error"]["code"], -32601); // 未知工具 let v: Value = serde_json::from_str( - r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nope","arguments":{}}}"#, + r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nope","arguments":{}}}"#, ) .unwrap(); - let r = process_message(&v, &app, false).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); assert_eq!(r["error"]["code"], -32602); // 参数错误 let v: Value = serde_json::from_str( - r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{}}}"#, + r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{}}}"#, ) .unwrap(); - let r = process_message(&v, &app, false).unwrap(); + let r = process_message(&v, &app, &c, false).unwrap(); assert_eq!(r["error"]["code"], -32602); + // HTTP 客户端发 notifications/exit 不能关停服务 + let v: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","method":"notifications/exit"}"#).unwrap(); + assert!(process_message(&v, &app, &c, false).is_none()); + assert!(!app.exit_flag.load(Ordering::SeqCst)); // 解析失败走 PARSE_ERROR assert!(PARSE_ERROR.contains("-32700")); + // HTTP 模式才有 file_link;白名单且无 --file-root 时文件工具被禁用 + let http = app_with(Config { + mode: Mode::Http("127.0.0.1:0".into()), + ..Default::default() + }); + assert!(http.tools.to_string().contains("file_link")); + let locked = app_with(Config { + allow: vec!["git".into()], + ..Default::default() + }); + assert!(!locked.tools.to_string().contains("file_read")); + let r = exec_tool_call(&locked, &c, "file_read", &json!({"path": "/etc/hosts"}), None, None).unwrap(); + assert_eq!(r["isError"], true); } + #[test] + fn test_session_buffer() { + let out = Arc::new(SessionOut::new()); + let s = Session { + pid: 0, + owner: "t".into(), + stdin: Mutex::new(None), + out: out.clone(), + last_used: Mutex::new(Instant::now()), + active: AtomicUsize::new(0), + }; + let k = Killable::default(); + push_output(&out, b"hello ", 1024); + assert_eq!(shell_read(&s, 10, 1 << 20, &k)["output"], "hello "); + // 半个汉字不返回,补齐后一起返回 + let ni = "你好世界".as_bytes(); + push_output(&out, &ni[..4], 1024); + assert_eq!(shell_read(&s, 50, 1 << 20, &k)["output"], "你"); + push_output(&out, &ni[4..], 1024); + assert_eq!(shell_read(&s, 50, 1 << 20, &k)["output"], "好世界"); + // 未读输出被裁剪:报告丢弃量,且只报告一次 + let out2 = Arc::new(SessionOut::new()); + let s2 = Session { out: out2.clone(), ..s }; + push_output(&out2, b"AAAAAAAAAA", 16); + push_output(&out2, b"BBBBBBBBBB", 16); + let r = shell_read(&s2, 10, 1 << 20, &k); + assert_eq!(r["dropped_bytes"], 5); + assert!(r["output"].as_str().unwrap().ends_with("AAAAABBBBBBBBBB"), "{r}"); + let r = shell_read(&s2, 10, 1 << 20, &k); + assert_eq!(r["output"], ""); + assert_eq!(r["dropped_bytes"], 0); + // 已读部分被裁剪:读取位置不受影响 + push_output(&out2, b"CCCCCCCCCC", 16); + let r = shell_read(&s2, 10, 1 << 20, &k); + assert_eq!(r["output"], "CCCCCCCCCC"); + assert_eq!(r["dropped_bytes"], 0); + // 退出 + out2.buf.lock().unwrap().exit = Some(ExitInfo { code: None, signal: Some(9) }); + out2.cv.notify_all(); + let r = shell_read(&s2, 0, 1 << 20, &k); + assert_eq!(r["exited"], true); + assert_eq!(r["signal"], 9); + // 取消:timeout=0 的阻塞读也能返回 + let k2 = Killable::default(); + k2.kill(true); + let r = shell_read(&Session { out: Arc::new(SessionOut::new()), ..s2 }, 0, 1 << 20, &k2); + assert_eq!(r["cancelled"], true); + } + + #[test] + fn test_files() { + let dir = TmpDir::new(); + let app = app_with(Config { + file_roots: vec![dir.0.clone()], + ..Default::default() + }); + let c = ctx("t"); + let call = |name: &str, args: Value| exec_tool_call(&app, &c, name, &args, None, None).unwrap(); + + // 文本写入 + 读取 + 哈希 + let r = call("file_write", json!({"path": dir.s("a.txt"), "content": "hello\n"})); + assert_eq!(sc(&r)["bytes_written"], 6, "{r}"); + assert_eq!(sc(&r)["created"], true); + assert_eq!(sc(&r)["sha256"], hex(&sha256(b"hello\n"))); + let r = call("file_read", json!({"path": dir.s("a.txt")})); + assert_eq!(r["content"][1]["text"], "hello\n"); + let meta: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(meta["eof"], true); + assert_eq!(meta["encoding"], "text"); + let r = call("file_stat", json!({"path": dir.s("a.txt"), "sha256": true})); + assert_eq!(sc(&r)["sha256"], hex(&sha256(b"hello\n"))); + assert_eq!(sc(&r)["type"], "file"); + // 追加 / create 冲突 / 覆盖 + call("file_write", json!({"path": dir.s("a.txt"), "content": "world", "mode": "append"})); + let r = call("file_read", json!({"path": dir.s("a.txt")})); + assert_eq!(r["content"][1]["text"], "hello\nworld"); + let r = call("file_write", json!({"path": dir.s("a.txt"), "content": "x", "mode": "create"})); + assert_eq!(r["isError"], true); + let r = call("file_write", json!({"path": dir.s("a.txt"), "content": "new"})); + assert_eq!(sc(&r)["created"], false); + assert_eq!(fs::read_to_string(dir.0.join("a.txt")).unwrap(), "new"); + // 二进制:base64 写入,auto 读取返回 base64 + let r = call("file_write", json!({"path": dir.s("b.bin"), "content": base64_encode(&[0, 255, 1]), "encoding": "base64"})); + assert_eq!(sc(&r)["bytes_written"], 3); + let r = call("file_read", json!({"path": dir.s("b.bin")})); + assert_eq!(r["content"][1]["text"], base64_encode(&[0, 255, 1])); + // 分块读取不切断汉字 + call("file_write", json!({"path": dir.s("c.txt"), "content": "你好"})); + let r = call("file_read", json!({"path": dir.s("c.txt"), "length": 4})); + assert_eq!(r["content"][1]["text"], "你"); + let meta: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(meta["next_offset"], 3); + let r = call("file_read", json!({"path": dir.s("c.txt"), "offset": 3})); + assert_eq!(r["content"][1]["text"], "好"); + // 图片按 image 块返回 + call("file_write", json!({"path": dir.s("p.png"), "content": base64_encode(b"\x89PNG"), "encoding": "base64"})); + let r = call("file_read", json!({"path": dir.s("p.png")})); + assert_eq!(r["content"][1]["type"], "image"); + assert_eq!(r["content"][1]["mimeType"], "image/png"); + // mkdirs + let r = call("file_write", json!({"path": dir.s("x/y/z.txt"), "content": "1"})); + assert_eq!(r["isError"], true); + let r = call("file_write", json!({"path": dir.s("x/y/z.txt"), "content": "1", "mkdirs": true})); + assert_eq!(sc(&r)["bytes_written"], 1, "{r}"); + // 目录限制:.. 逃逸、根目录外 + let outside = dir.0.parent().unwrap().join(format!("bolt-outside-{}", random_hex(4))); + let r = call("file_write", json!({"path": outside.to_string_lossy(), "content": "x"})); + assert_eq!(r["isError"], true); + assert!(!outside.exists()); + let r = call("file_write", json!({"path": dir.s("../evil.txt"), "content": "x"})); + assert_eq!(r["isError"], true); + let r = call("file_write", json!({"path": dir.s("nope/../../evil.txt"), "content": "x", "mkdirs": true})); + assert_eq!(r["isError"], true); + let r = call("file_stat", json!({"path": dir.s("missing")})); + assert_eq!(sc(&r)["exists"], false); + let r = call("file_read", json!({"path": dir.s("missing")})); + assert_eq!(r["isError"], true); + // 符号链接指向根目录外 + #[cfg(unix)] + { + std::os::unix::fs::symlink("/tmp", dir.0.join("link")).unwrap(); + let r = call("file_write", json!({"path": dir.s("link/evil.txt"), "content": "x"})); + assert_eq!(r["isError"], true, "{r}"); + let r = call("file_read", json!({"path": dir.s("link")})); + assert_eq!(r["isError"], true); + } + } + + #[test] + fn test_files_http() { + let dir = TmpDir::new(); + let app = app_with(Config { + mode: Mode::Http("127.0.0.1:0".into()), + file_roots: vec![dir.0.clone()], + ..Default::default() + }); + let head = |method: &str, query: &str, extra: &str| { + let raw = format!("{method} /files?{query} HTTP/1.1\r\nHost: h\r\n{extra}\r\n"); + parse_http_head(raw.as_bytes()).unwrap() + }; + let path_q = format!("path={}", pct_encode(&dir.s("up.bin"))); + // PUT(chunked + 100-continue) + let req = head("PUT", &path_q, "Transfer-Encoding: chunked\r\nExpect: 100-continue\r\n"); + let mut body = BufReader::new(&b"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"[..]); + let mut out = Vec::new(); + assert!(handle_files(&req, &mut body, &mut out, &app, false)); + let resp = String::from_utf8_lossy(&out); + assert!(resp.starts_with("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 201 Created"), "{resp}"); + assert_eq!(fs::read(dir.0.join("up.bin")).unwrap(), b"hello world"); + // GET 全量与 Range + let mut out = Vec::new(); + assert!(handle_files(&head("GET", &path_q, ""), &mut BufReader::new(&b""[..]), &mut out, &app, false)); + assert!(String::from_utf8_lossy(&out).ends_with("\r\n\r\nhello world")); + let mut out = Vec::new(); + handle_files(&head("GET", &path_q, "Range: bytes=6-\r\n"), &mut BufReader::new(&b""[..]), &mut out, &app, false); + let resp = String::from_utf8_lossy(&out); + assert!(resp.starts_with("HTTP/1.1 206"), "{resp}"); + assert!(resp.contains("Content-Range: bytes 6-10/11")); + assert!(resp.ends_with("\r\n\r\nworld")); + // 根目录外 → 403 + let mut out = Vec::new(); + handle_files(&head("GET", "path=%2Fetc%2Fhosts", ""), &mut BufReader::new(&b""[..]), &mut out, &app, false); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 403")); + // 签名链接:file_link 生成 → 可用;篡改路径或方法 → 403 + let c = ctx("t"); + let r = exec_tool_call(&app, &c, "file_link", &json!({"path": dir.s("up.bin")}), None, None).unwrap(); + let url = sc(&r)["url"].as_str().unwrap().to_string(); + assert!(url.starts_with("http://h/files?"), "{url}"); + let q = url.split_once('?').unwrap().1.to_string(); + let mut out = Vec::new(); + assert!(handle_files(&head("GET", &q, ""), &mut BufReader::new(&b""[..]), &mut out, &app, true)); + assert!(String::from_utf8_lossy(&out).ends_with("hello world")); + let mut out = Vec::new(); + handle_files(&head("PUT", &q, "Content-Length: 1\r\n"), &mut BufReader::new(&b"x"[..]), &mut out, &app, true); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 403")); + let other = q.replace("up.bin", "other.bin"); + let mut out = Vec::new(); + handle_files(&head("GET", &other, ""), &mut BufReader::new(&b""[..]), &mut out, &app, true); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 403")); + // PUT 签名链接上传(append 模式受签名保护) + let r = exec_tool_call(&app, &c, "file_link", &json!({"path": dir.s("new/put.txt"), "method": "PUT", "mkdirs": true}), None, None).unwrap(); + let q = sc(&r)["url"].as_str().unwrap().split_once('?').unwrap().1.to_string(); + let mut out = Vec::new(); + assert!(handle_files(&head("PUT", &q, "Content-Length: 3\r\n"), &mut BufReader::new(&b"abc"[..]), &mut out, &app, true)); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 201"), "{}", String::from_utf8_lossy(&out)); + assert_eq!(fs::read(dir.0.join("new/put.txt")).unwrap(), b"abc"); + let tampered = q.replace("mode=overwrite", "mode=append"); + let mut out = Vec::new(); + handle_files(&head("PUT", &tampered, "Content-Length: 1\r\n"), &mut BufReader::new(&b"x"[..]), &mut out, &app, true); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 403")); + // 上传上限 + let small = app_with(Config { + file_roots: vec![dir.0.clone()], + max_upload: 4, + ..Default::default() + }); + let mut out = Vec::new(); + handle_files(&head("PUT", &path_q, "Content-Length: 10\r\n"), &mut BufReader::new(&b"0123456789"[..]), &mut out, &small, false); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 413")); + let mut out = Vec::new(); + handle_files(&head("PUT", &path_q, "Transfer-Encoding: chunked\r\n"), &mut BufReader::new(&b"a\r\n0123456789\r\n0\r\n\r\n"[..]), &mut out, &small, false); + assert!(String::from_utf8_lossy(&out).starts_with("HTTP/1.1 413")); + assert_eq!(fs::read(dir.0.join("up.bin")).unwrap(), b"hello world"); // 原子覆盖:失败不破坏原文件 + } + + #[test] + fn test_mcp_expect_continue() { + let app = app_with(Config::default()); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + let raw = format!("POST /mcp HTTP/1.1\r\nHost: h\r\nContent-Length: {}\r\nExpect: 100-continue\r\n\r\n", body.len()); + let req = parse_http_head(raw.as_bytes()).unwrap(); + let mut out = Vec::new(); + assert!(handle_mcp(&req, &mut BufReader::new(body.as_bytes()), &mut out, &app)); + let resp = String::from_utf8_lossy(&out); + assert!(resp.starts_with("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK"), "{resp}"); + } + + #[test] + fn test_mcp_session_scope() { + let app = app_with(Config::default()); + let a = create_mcp_session(&app); + let b = create_mcp_session(&app); + assert_ne!(a, b); + assert!(touch_mcp_session(&app, &a)); + assert!(!touch_mcp_session(&app, "nope")); + // 相同请求 id 在不同作用域互不影响 + let ka = register_inflight(&app, &a, &json!(1)); + let kb = register_inflight(&app, &b, &json!(1)); + let cancel: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#).unwrap(); + process_message(&cancel, &app, &ctx(&b), false); + assert!(!ka.is_killed()); + assert!(kb.is_killed()); + // 终止会话:进行中的调用被取消 + assert!(terminate_mcp_session(&app, &a)); + assert!(ka.is_killed()); + assert!(!touch_mcp_session(&app, &a)); + } + + #[cfg(unix)] #[test] fn test_run_command_basic() { - let app = Arc::new(App::new(Config { - mode: Mode::Stdio, - token: None, - allow: vec![], - deny: vec![], + let app = app_with(Config { timeout_ms: 5000, - cwd: None, - max_output: 1024 * 1024, - allow_origins: vec![], - })); - let args = json!({"command": "echo hello && printf err >&2 && exit 3"}); - let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap(); + ..Default::default() + }); + let c = ctx("t"); + let run = |a: &Shared, args: Value| exec_tool_call(a, &c, "run_command", &args, None, None).unwrap(); + let r = run(&app, json!({"command": "echo hello && printf err >&2 && exit 3"})); assert_eq!(r["isError"], Value::Null); - let sc = &r["structuredContent"]; - assert_eq!(sc["exit_code"], 3); - assert_eq!(sc["stdout"], "hello\n"); - assert_eq!(sc["stderr"], "err"); - assert!(!sc["timed_out"].as_bool().unwrap()); + assert_eq!(sc(&r)["exit_code"], 3); + assert_eq!(sc(&r)["stdout"], "hello\n"); + assert_eq!(sc(&r)["stderr"], "err"); + assert!(!sc(&r)["timed_out"].as_bool().unwrap()); // 超时 - let args = json!({"command": "sleep 10", "timeout_ms": 300}); - let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap(); - assert_eq!(r["structuredContent"]["timed_out"], true); + let r = run(&app, json!({"command": "sleep 10", "timeout_ms": 300})); + assert_eq!(sc(&r)["timed_out"], true); // shell=false 直接执行 - let args = json!({"command": "echo", "args": ["direct"], "shell": false}); - let r = exec_tool_call(&app, "run_command", &args, None, None).unwrap(); - assert_eq!(r["structuredContent"]["stdout"], "direct\n"); + let r = run(&app, json!({"command": "echo", "args": ["direct"], "shell": false})); + assert_eq!(sc(&r)["stdout"], "direct\n"); + // 后台进程占用管道:不再阻塞到后台进程结束 + let t = Instant::now(); + let r = run(&app, json!({"command": "(sleep 3; echo late) & echo now"})); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + assert_eq!(sc(&r)["stdout"], "now\n"); + assert_eq!(sc(&r)["detached"], true); + // 截断不切断多字节字符 + let r = run(&app, json!({"command": "printf '%01024d' 0 | tr 0 x; printf '你好'", "max_output_bytes": 1025})); + assert_eq!(sc(&r)["truncated_stdout"], true); + assert_eq!(sc(&r)["stdout"].as_str().unwrap().len(), 1024); // 白名单拦截 - let app2 = Arc::new(App::new(Config { + let app2 = app_with(Config { allow: vec!["echo".into()], - ..Config { - mode: Mode::Stdio, - token: None, - allow: vec![], - deny: vec![], - timeout_ms: 5000, - cwd: None, - max_output: 1024, - allow_origins: vec![], - } - })); - let r = exec_tool_call(&app2, "run_command", &json!({"command": "ls"}), None, None).unwrap(); - assert_eq!(r["isError"], true); + ..Default::default() + }); + assert_eq!(run(&app2, json!({"command": "ls"}))["isError"], true); + assert_eq!(run(&app2, json!({"command": "echo hi; ls"}))["isError"], true); + assert_eq!(sc(&run(&app2, json!({"command": "echo hi"})))["stdout"], "hi\n"); // 经 shell 执行不存在的命令:sh 启动成功,内部报错,exit 127 - let r = exec_tool_call(&app, "run_command", &json!({"command": "definitely_not_a_cmd_xyz"}), None, None).unwrap(); - assert_eq!(r["structuredContent"]["exit_code"], 127); - assert!(!r["structuredContent"]["stderr"].as_str().unwrap().is_empty()); + let r = run(&app, json!({"command": "definitely_not_a_cmd_xyz"})); + assert_eq!(sc(&r)["exit_code"], 127); + assert!(!sc(&r)["stderr"].as_str().unwrap().is_empty()); // shell=false 直接执行不存在的程序:spawn 失败 → isError - let r = exec_tool_call(&app, "run_command", &json!({"command": "definitely_not_a_cmd_xyz", "shell": false}), None, None).unwrap(); + let r = run(&app, json!({"command": "definitely_not_a_cmd_xyz", "shell": false})); assert_eq!(r["isError"], true); } + #[cfg(unix)] #[test] fn test_shell_session() { - let app = Arc::new(App::new(Config { - mode: Mode::Stdio, - token: None, - allow: vec![], - deny: vec![], - timeout_ms: 5000, - cwd: None, - max_output: 1024, - allow_origins: vec![], - })); - let r = shell_open(&app, Some("sh"), None).unwrap(); - let sid = r["session_id"].as_u64().unwrap(); - assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo FIRST", true).is_ok()); - let r0 = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 2000, 1 << 20); - assert!(r0["output"].as_str().unwrap().contains("FIRST"), "output: {}", r0); + let app = app_with(Config::default()); + let c = ctx("t"); + let call = |name: &str, args: Value| exec_tool_call(&app, &c, name, &args, None, None).unwrap(); + let r = call("shell_open", json!({"shell": "sh"})); + assert!(r["content"].is_array(), "{r}"); + let sid = sc(&r)["session_id"].as_u64().unwrap(); + // 其他作用域看不到这个会话 + let other = exec_tool_call(&app, &ctx("other"), "shell_read", &json!({"session_id": sid}), None, None).unwrap(); + assert_eq!(other["isError"], true); + assert_eq!(sc(&call("shell_write", json!({"session_id": sid, "input": "echo FIRST"})))["ok"], true); + let r0 = call("shell_read", json!({"session_id": sid, "timeout_ms": 2000})); + assert!(sc(&r0)["output"].as_str().unwrap().contains("FIRST"), "output: {r0}"); // 状态保留:cd 后 pwd(非交互 sh 无回显,分两步写、一次读) - assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "cd /tmp", true).is_ok()); - assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo PROBE && pwd", true).is_ok()); + call("shell_write", json!({"session_id": sid, "input": "cd /tmp"})); + call("shell_write", json!({"session_id": sid, "input": "echo PROBE && pwd"})); // 输出分块到达,循环读取直到拿到全部(工具语义:返回当前可用的新输出) let mut probe_out = String::new(); for _ in 0..10 { - let r = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 500, 1 << 20); - probe_out.push_str(r["output"].as_str().unwrap()); + let r = call("shell_read", json!({"session_id": sid, "timeout_ms": 500})); + probe_out.push_str(sc(&r)["output"].as_str().unwrap()); if probe_out.contains("PROBE") && probe_out.contains("/tmp") { break; } } assert!(probe_out.contains("PROBE"), "output: {probe_out}"); assert!(probe_out.contains("/tmp"), "output: {probe_out}"); - assert!(shell_write(app.sessions.lock().unwrap().get(&sid).unwrap(), "echo STATE_OK && exit", true).is_ok()); + call("shell_write", json!({"session_id": sid, "input": "echo STATE_OK && exit 4"})); let mut out = String::new(); + let mut last = Value::Null; for _ in 0..20 { - let r = shell_read(app.sessions.lock().unwrap().get(&sid).unwrap(), 1000, 1 << 20); - out.push_str(r["output"].as_str().unwrap()); - if r["exited"].as_bool().unwrap() { + last = call("shell_read", json!({"session_id": sid, "timeout_ms": 1000})); + out.push_str(sc(&last)["output"].as_str().unwrap()); + if sc(&last)["exited"].as_bool().unwrap() { break; } } assert!(out.contains("STATE_OK"), "out: {out}"); - let r = shell_close(&app, sid).unwrap(); - assert_eq!(r["closed"], true); + assert_eq!(sc(&last)["exit_code"], 4); + let r = call("shell_close", json!({"session_id": sid})); + assert_eq!(sc(&r)["closed"], true); + // 被信号杀死的会话也能报告退出 + let sid = sc(&call("shell_open", json!({})))["session_id"].as_u64().unwrap(); + call("shell_write", json!({"session_id": sid, "input": "kill -9 $$"})); + let r = call("shell_read", json!({"session_id": sid, "timeout_ms": 0})); + assert_eq!(sc(&r)["exited"], true, "{r}"); + assert_eq!(sc(&r)["signal"], 9); + // 终止作用域会清理其会话 + let sid = sc(&call("shell_open", json!({})))["session_id"].as_u64().unwrap(); + cleanup_scope(&app, "t"); + assert!(get_session(&app, "t", sid).is_err()); } }