Merge pull request #843 from nyakang/feat/web-deployment

feat(web): add browser runtime and Docker deployment beta
This commit is contained in:
Kang authored and GitHub committed 2026-10-07 11:54:04 +08:00
commit f49aa85f7c
402 files changed
+41604 -13957

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
.git
**/node_modules
**/target
**/dist
**/*.log
.env
.env.*
*.key
nyaterm-web-data
web-test-data
.codex
.claude
.cursor
.trae
temp
docs-site
src-tauri/binaries
src-tauri/resources
src-tauri/vendor/portable-pty
src-tauri/vendor/picky
src-tauri/vendor/sspi
artifacts
playwright-report
+92
View File
@@ -0,0 +1,92 @@
name: Web merge checks
on:
pull_request:
push:
branches: [main, feat/web-deployment]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: web-checks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
with:
version: 11.10.0
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm i18n:check && pnpm i18n:keys
- run: pnpm test --run --maxWorkers=4
- run: pnpm build:web
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev libfreetype-dev
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
src-tauri/crates/nyaterm-core -> target
src-tauri/crates/nyaterm-web -> target
- run: cargo test --manifest-path src-tauri/crates/nyaterm-core/Cargo.toml --locked
- run: cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked
desktop:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: pnpm/action-setup@v5
with:
version: 11.10.0
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Linux Tauri dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libudev-dev libfontconfig1-dev libfreetype-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
src-tauri -> target
src-tauri/crates/nyaterm-mcp -> target
- run: pnpm install --frozen-lockfile
- run: pnpm build:mcp-sidecar
- run: pnpm prepare:conpty
if: runner.os == 'Windows'
- run: cargo check --manifest-path src-tauri/Cargo.toml --locked
deployment:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
with:
version: 11.10.0
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- uses: docker/setup-buildx-action@v3
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:web:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: web-deployment-failure
path: artifacts/web-e2e/
retention-days: 7
+11
View File
@@ -39,3 +39,14 @@ temp/
.claude
.codex
.trae
# Web runtime secrets and data
.env
.env.*
nyaterm-web-data/
web-test-data/
# Isolated deployment test traces and container logs
artifacts/web-e2e/
artifacts/web-local-e2e/
playwright-report/
+5
View File
@@ -322,6 +322,10 @@ AUR package: [`nyaterm-bin`](https://aur.archlinux.org/packages/nyaterm-bin)
> `nyaterm-bin` is a community-maintained binary AUR package. If the AUR package has not yet caught up with the latest release, download the official package from [Releases](https://github.com/nyakang/nyaterm/releases).
## Web / Docker Deployment
The Web Beta serves the browser interface and API from a single container for one trusted administrator. See [deploy/web/](./deploy/web/README.md) for build and Compose commands, and the [deployment guide](./docs/web-deployment.md) for configuration and supported capabilities.
## Prerequisites for Development
- Node.js 18+
@@ -351,6 +355,7 @@ pnpm tauri dev
│ ├── src/core/ # SSH, SFTP, PTY, Telnet, Serial, AI, backup logic
│ ├── src/config/ # Persistent config models
│ └── crates/otp/ # Local OTP implementation
├── deploy/web/ # Web Dockerfile, Compose, and deployment entry point
├── docs-site/ # Docusaurus documentation site
├── public/ # Static assets
└── scripts/ # Checks, version sync, and demo helper scripts
+5
View File
@@ -329,6 +329,10 @@ AUR 软件包:[`nyaterm-bin`](https://aur.archlinux.org/packages/nyaterm-bin)
> `nyaterm-bin` 为社区维护的 AUR 二进制包。如果 AUR 版本暂未同步到最新发布版,可以从 [Releases](https://github.com/nyakang/nyaterm/releases) 下载官方安装包。
## Web / Docker 部署
Web Beta 通过单个容器提供浏览器界面与 API,面向一个可信管理员。构建与 Compose 命令见 [deploy/web/](./deploy/web/README.md),配置和能力范围见 [部署指南](./docs/web-deployment.md)。
## 开发环境要求
- Node.js 18+
@@ -358,6 +362,7 @@ pnpm tauri dev
│ ├── src/core/ # SSH、SFTP、PTY、Telnet、串口、AI、备份逻辑
│ ├── src/config/ # 持久化配置模型
│ └── crates/otp/ # 本地 OTP 实现
├── deploy/web/ # Web Dockerfile、Compose 与部署入口
├── docs-site/ # Docusaurus 文档站点
├── public/ # 静态资源
└── scripts/ # 检查、版本同步与演示辅助脚本
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
# Build from the repository root: docker build -f deploy/web/Dockerfile -t nyaterm-web .
FROM node:24-bookworm-slim AS frontend
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.10.0 --activate
COPY . .
RUN pnpm install --frozen-lockfile
ARG NYATERM_WEB_BASE_PATH=/
ENV NYATERM_WEB_BASE_PATH=$NYATERM_WEB_BASE_PATH
RUN pnpm build:web
FROM rust:1.97.1-bookworm AS backend
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libfontconfig1-dev libfreetype-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY src-tauri/crates/nyaterm-core src-tauri/crates/nyaterm-core
COPY src-tauri/crates/nyaterm-web src-tauri/crates/nyaterm-web
COPY src-tauri/crates/nyaterm-gpu src-tauri/crates/nyaterm-gpu
COPY src-tauri/crates/otp src-tauri/crates/otp
COPY src-tauri/vendor/russh src-tauri/vendor/russh
COPY src-tauri/vendor/russh-sftp src-tauri/vendor/russh-sftp
COPY src-tauri/vendor/vnc-rs src-tauri/vendor/vnc-rs
RUN cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 libfontconfig1 libfreetype6 && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --create-home nyaterm && mkdir -p /data && chown nyaterm:nyaterm /data
WORKDIR /app
COPY --from=frontend /app/dist /app/dist
COPY --from=backend /app/src-tauri/crates/nyaterm-web/target/release/nyaterm-web /usr/local/bin/nyaterm-web
USER 10001:10001
ENV NYATERM_WEB_BIND=0.0.0.0:8080 NYATERM_WEB_DATA_DIR=/data NYATERM_WEB_DIST=/app/dist
EXPOSE 8080
VOLUME ["/data"]
STOPSIGNAL SIGTERM
ENTRYPOINT ["nyaterm-web"]
+54
View File
@@ -0,0 +1,54 @@
# Web / Docker 部署入口
此目录提供 Web Beta 的镜像构建与 Compose 配置。完整的环境变量、秘密生成、HTTPS / 子路径代理、备份和能力范围见 [部署指南](../../docs/web-deployment.md)。
## 文件与执行目录
- `Dockerfile`:Node、Rust、Debian 三阶段构建,生成前端资源与 `nyaterm-web` 服务。
- `docker-compose.yml`:服务运行配置与持久数据卷。
- 仓库根目录 `.dockerignore`:排除依赖、构建产物、秘密与本地数据。
以下命令均在仓库根目录执行。Dockerfile 的位置与构建上下文不同:上下文必须是仓库根目录,才能复制前端和 Rust 源码。
## Compose 启动
安装 Docker Engine / Docker Desktop 和 Compose V2。提前在当前 shell 或仓库根目录的受限 `.env` 中设置:
- `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。
- `NYATERM_WEB_ENCRYPTION_KEY`:标准 Base64 编码的 32 字节随机密钥,需持久保存。
- `NYATERM_WEB_PUBLIC_URL`:浏览器实际访问地址,默认 `http://localhost:8080/`;非回环地址默认要求 HTTPS。
```sh
docker compose -f deploy/web/docker-compose.yml up --build -d
docker compose -f deploy/web/docker-compose.yml logs --tail 200 nyaterm
docker compose -f deploy/web/docker-compose.yml down
```
使用外部环境文件时,在 Compose 命令中添加 `--env-file /absolute/private/nyaterm.env`。生产环境推荐 `secrets` 挂载与 `_FILE` 变量,操作示例见部署指南。
默认项目名为 `nyaterm`,持久数据卷为 `nyaterm_nyaterm-data`。从旧版根目录 Compose 迁移时,如果原项目名不是 `nyaterm`,继续使用 `-p <原项目名>` 或 `COMPOSE_PROJECT_NAME`,并沿用原登录密码和加密密钥。普通 `down` 保留数据卷;`down -v` 会删除数据卷。
## 手工构建镜像
```sh
docker build -f deploy/web/Dockerfile -t nyaterm-web .
```
子路径部署示例:
```sh
docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web .
```
运行时的 `NYATERM_WEB_PUBLIC_URL` 路径须与构建路径一致。单独运行容器、挂载秘密和配置反向代理见部署指南。
## 部署验收
在已安装项目依赖及 Playwright Chromium 的 Docker 环境执行:
```sh
pnpm exec playwright install chromium
pnpm test:web:e2e
```
验收脚本分别构建根路径与 `/nyaterm/` 镜像,通过临时 OpenSSH 容器验证浏览器功能,失败产物写入 `artifacts/web-e2e/`。
+29
View File
@@ -0,0 +1,29 @@
name: nyaterm
services:
nyaterm:
build:
context: ../..
dockerfile: deploy/web/Dockerfile
args:
NYATERM_WEB_BASE_PATH: ${NYATERM_WEB_BASE_PATH:-/}
ports:
- "8080:8080"
environment:
NYATERM_WEB_ALLOW_INSECURE_HTTP: ${NYATERM_WEB_ALLOW_INSECURE_HTTP:-false}
NYATERM_WEB_PUBLIC_URL: ${NYATERM_WEB_PUBLIC_URL:-http://localhost:8080/}
NYATERM_WEB_PASSWORD: ${NYATERM_WEB_PASSWORD:?Set a random login password of at least 32 characters}
NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key}
volumes:
- nyaterm-data:/data
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
nyaterm-data:
-95
View File
@@ -1,95 +0,0 @@
# GPU 插件验证记录
验证日期:2026-10-03。宿主:Windows x86_64;目标远端:Linux/NVIDIA。
本记录验证代码、包和真实原生进程;没有连接用户的 SSH 会话,也没有真实 NVIDIA 主机结果。
## 交付
- 源码:`plugins/examples/gpu-monitor/`,插件 ID `nyaterm.gpu`,版本 `1.0.1`。
- 包:`temp/plugins/gpu-monitor.nyap`,Windows x86_64,484,743 字节,8 个 ZIP 条目。
- SHA-256:`e92a8888b3658e5fb72a0a576b09657db37f5dfac66cd4b133f637ff7d522bd3`。
- 用法与构建:[GPU 插件 README](../plugins/examples/gpu-monitor/README.md)。
- API:[插件开发说明](plugin-development.md)。
```sh
pnpm plugin:example:gpu
pnpm plugin:pack plugins/examples/gpu-monitor temp/plugins/gpu-monitor.nyap
node plugins/examples/gpu-monitor/verify-ui.mjs
```
构建输出包含相对路径 CSS、SDK、单个经典 IIFE 脚本和 release 后台。
打包器调用实际安装器校验路径、引用、配额及校验和后发布 `.nyap`;另行核对全部 checksums。
当前包只声明 Windows x86_64;构建脚本支持在 Linux/macOS、x86_64/aarch64 宿主重建相应产物。
不能据此宣称跨平台桌面验证完成。
## 首版自动验证
| 检查 | 结果与范围 |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Runtime `cargo test --features sdk-fixture` | 28 项通过;另有 1 项最终包测试需显式启用(本次已执行通过) |
| 最终包 Sidecar 测试 | 1 项通过;实际安装、授权、启动 release 可执行文件,两次 reverse Probe RPC 往返及 GPU 解析 |
| 共享 GPU crate | 5 项解析测试通过;涵盖多设备、缺失可选值、进程索引和带逗号名称 |
| Rust SDK | 2 项测试通过;JSONL/framed 的进程互操作另由 Runtime 覆盖 |
| Runtime/SDK/GPU Clippy | `--all-targets -- -D warnings` 通过;Runtime 启用 `sdk-fixture` |
| Tauri | `cargo check --all-targets`、`cargo clippy --all-targets` 通过;宿主 Clippy 有项目级警告,未宣称零警告 |
| 前端 TypeScript | 宿主 `tsc --noEmit` 与插件独立 TypeScript 检查通过 |
| 宿主/插件构建 | `pnpm build` 和 `pnpm plugin:example:gpu` 通过;宿主仍有既有大 chunk/Browserslist 提示 |
| 前端 lint | `pnpm lint` 通过;`CommandSuggestions.tsx` 有既有依赖数组警告 |
| 相关 Vitest | 初始 7 文件 16 项通过;补充 Bridge 和续期失败检查后,相关 5 文件 16 项通过 |
| 全量 Vitest 首轮 | 155 文件中 154 通过,971 项通过;AI 设置有 1 项 5 秒超时。该文件独立重跑 9 项全部通过 |
| 全量 Vitest 最终重跑 | `vitest run --maxWorkers=4`:155 文件、974 项全部通过 |
| 宿主 Rust 插件测试 | 编译成功;测试可执行文件启动失败,Windows 返回 `0xc0000139 / STATUS_ENTRYPOINT_NOT_FOUND`,未执行断言 |
| 构建产物 UI 冒烟 | jsdom 执行实际 SDK 和 IIFE:设备/驱动/CUDA/温度/功率/风扇/显存/进程、排序/搜索、刷新、四语言、主题、保留数据的错误和暂停状态通过 |
Runtime 新增测试覆盖旧包兼容与新贡献校验、固定脚本全文审核、非法路径/超时/输出配额、更新和回滚移除授权、
GPU Monitor 冲突、共享需求、无重叠/补发、最后需求释放、取消和迟到结果、停止/手动恢复、不可用停止及三次失败清空。
前端测试覆盖初始快照竞态、revision 去重、订阅/iframe 归属、配额、取消、作用域续期、会话/激活切换、锁屏释放需求、
插件错误不切源、停用恢复内置、当前会话资产回填及 NPU 入口保持原行为。
最终包测试执行真实原生程序,使用安装包内脚本和 `execute_declared`。
执行器模拟远端输出,未建立网络 SSH 连接。私有 exec 通道代码经过 Tauri check/Clippy;
`SessionCommand::Write` 不在 Probe 执行路径中。尚未实际验证 russh 网络取消或终端无输入干扰。
jsdom 只验证构建产物逻辑和 DOM,不等同于 WebView2 的样式、opaque-origin 自定义协议或原生桌面验收。
## 1.0.1 换行修复验证
用户桌面运行 `1.0.0` 报告 `GPU probe did not exit successfully`。
检查原始 `.nyap` 发现 `gpu.sh` 的 34 个换行全部为 CRLF。
在 WSL Ubuntu 的真实 `/bin/sh -s` 中,原字节第二行报 `export: LC_ALL\r: bad variable name`,退出码 2;
同样脚本仅转换为 LF 后成功,缺少 NVIDIA 工具时输出 `GPU_AVAILABLE\t0` 并退出 0。
首版 Sidecar 测试模拟远端输出,未执行脚本,因而没有发现这一打包缺陷。
`1.0.1` 将脚本源码和构建输出固定为 LF,构建脚本显式规范换行;
后台错误区分非零退出码与没有退出状态,不记录原始 stdout/stderr。
提高版本号使已安装的不可变 `1.0.0` 能通过正常更新流程安装修复。
本次通过的检查:
- `pnpm plugin:example:gpu`:独立 TypeScript、经典脚本 UI、release 后台构建。
- `pnpm plugin:pack ...`:实际安装器的包校验;新版包所有 checksums 和 LF 字节另行核对。
- `python plugins/examples/gpu-monitor/verify-probe.py --wsl Ubuntu`:执行最终包内的真实脚本,
验证 CRLF 失败、LF 无工具分支和隔离 `nvidia-smi` 样本的设备/CUDA/进程输出。
- GPU Sidecar 测试 3 项通过:成功采集复用、退出码/缺失状态诊断、源码换行回归。
- 最终 `1.0.1` 安装包 Sidecar 测试 1 项通过:实际安装/授权并启动 release 程序,两次固定 Probe 往返。
- Runtime `clippy --all-targets --features sdk-fixture -- -D warnings` 通过。
- 构建产物 GPU UI 冒烟通过。
此修复只需更新插件包并重新授权,不需要修改或重新编译宿主。
Linux Shell 验证使用本机 WSL 和固定 NVIDIA 样本,仍未连接真实 NVIDIA SSH 服务器。
## 用户 SSH 会话上的桌面验收(未执行)
使用包含本次接口实现的宿主,由用户自行建立 Linux/NVIDIA SSH 会话:
1. 安装并审核固定脚本,授权 `native`/`remote.probe` 后启用;确认普通终端输入不被采集打断。
2. 同时打开原 GPU 工作区和独立插件面板,设置 3 秒间隔,确认持续更新且共享采集。
3. 顶部状态选择 GPU,关闭两处面板,确认顶部状态仍更新;关闭全部 GPU 需求后确认停止。
4. 测试手动刷新、搜索/排序、四种语言和明暗主题;核对实时指标与服务器 `nvidia-smi`。
5. 在任务空闲时点击“停止后台”,确认定时器不重启后台;刷新后恢复。
6. 切换会话、断开、锁屏、关闭窗口和撤销授权,确认停止请求、旧数据不回填新会话。
7. 超过 25 分钟确认主窗口续期不重载工作区数据;iframe 重建后取得当前快照。
8. 模拟采集错误,确认保留旧数据并标记错误,三次后清空;不静默回退。
9. 停用后确认恢复内置采集;更新/回滚要求重新审核;第二个 GPU Monitor 冲突被拒绝。
真实 NVIDIA 数据、持续网络采集、网络取消、原生安装 UI 和上述桌面行为均未验收。
本次没有修改用户凭据、建立用户会话或安装到用户的真实插件目录。
-30
View File
@@ -1,30 +0,0 @@
# Plugin development and diagnostics
This stage completes four deliverables on top of the local plugin host:
Implemented and verified. See [the development guide](plugin-development.md),
[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md) and
[validation report](plugin-system-validation.md#developer-tooling-and-diagnostics-validation).
- `pnpm plugin:create <id> <directory> --template ui|rust` creates a new, buildable
project without overwriting existing content. Each template includes a manifest,
working entrypoint, SDK integration, build/package instructions and README.
- A standalone Rust native SDK handles identity/API handshake, JSONL and framed
transport, concurrent requests, scoped reverse host calls, structured errors,
timeouts, cooperative cancellation and logging. The existing native counter
and generated Rust projects use the SDK.
- The Plugins panel displays idle/starting/running/stopped/error states for native
backends and distinguishes UI-only and disabled plugins. Users can stop a backend
and see its next invocation activate a fresh process.
- Users can inspect, refresh, copy and clear bounded in-memory logs. Startup,
protocol, crash and invocation errors are retained even during the handshake.
Old processes cannot overwrite the state of a newer activation. Logs are
treated as untrusted text, redact host-known secrets/scope tokens, and are not
persisted or added to backup/cloud sync.
Verification must exercise generated projects through build/package validation,
the SDK through the real host runtime in both transports (including reverse RPC,
errors, cancellation and crash handling), status/log transitions and retention,
and the frontend diagnostic controls. Type checking, relevant lint and frontend
build remain final gates. Marketplace, connection provider APIs and browser data
stream subscriptions remain separate stages.
-168
View File
@@ -1,168 +0,0 @@
# 插件开发与排错
插件安装、授权、运行状态和日志都在 **插件面板** 中操作。
## 创建项目
在 NyaTerm 仓库根目录执行:
```sh
# 纯界面插件:查看选中终端的会话信息
pnpm plugin:create example.session temp/plugins/my-session --template ui
pnpm plugin:pack temp/plugins/my-session temp/plugins/my-session.nyap
# Rust 插件:界面调用后台,也提供原生命令
pnpm plugin:create example.hello temp/plugins/my-hello --template rust
node temp/plugins/my-hello/build.mjs
pnpm plugin:pack temp/plugins/my-hello temp/plugins/my-hello.nyap
```
创建器拒绝覆盖已有目录。项目包含 manifest、可用入口、SDK 和 README;
Rust 模板还包含编译脚本,编译后自动填入当前平台的可执行文件路径。
需要 Node 和 Rust 1.94 或更新版本。Rust SDK 使用本地路径依赖,移动项目时
应一起提供 SDK 或调整 Cargo.toml 中的路径。
将生成的 `.nyap` 安装到插件面板,检查权限并启用。UI 模板需先选中已连接终端;
Rust 模板可以打开界面点击运行,也可以直接执行插件卡片上的原生命令。
## 页面字体
宿主会将应用的界面字体栈通过 `NyaTerm.context.theme` 中的 `--font-sans`、
`--font-display` 同步到插件页面;修改设置时,已打开的页面也会收到更新,无需重载。
`--font-mono` 提供宿主的默认等宽字体栈。SDK 默认使用界面字体,表单控件继承字体;
插件自定义 CSS 应使用 `font-family: var(--font-sans, system-ui, sans-serif)`,
代码区域可使用 `var(--font-mono, monospace)`,避免写死 `system-ui`。
iframe 可以使用系统安装的字体,但不会继承主界面的 `@font-face`。
若使用未安装到系统的内置字体或其他 Web 字体,需要在插件包内提供字体文件并声明
`@font-face`。已有插件若写死字体,需要修改 CSS 并重新打包安装。
## 编写 Rust 后台
入口实现 `Plugin`,由 `Server::from_env()` 启动服务。SDK 负责握手、传输、
并发、作用域、超时和取消,无需手写 JSON-RPC:
```rust
#[async_trait]
impl Plugin for MyPlugin {
async fn call(&self, context: Context, method: &str, input: Value) -> Result<Value> {
match method {
"ui/session" => {
let session = context.host().session().await?;
context.log("info", "Session inspected").await?;
Ok(session)
}
_ => Err(RpcError::method_not_found(method)),
}
}
}
```
以上会话读取需在 manifest 中声明并获授 `session.read`;原生执行需 `native`。
UI 调用 `NyaTerm.backend("ui/session")`,contributions.commands 中的原生命令
则使用 manifest 声明的方法。不要向 stdout 打印日志,它专用于协议;使用
`Context::log` 或 stderr。不要记录口令、令牌、终端输入输出等敏感内容。
默认采用 JSONL。将 `backend.transport` 改为 `stdio-framed` 后,SDK 根据宿主
环境自动切换协议,支持二进制帧;界面流式订阅尚未提供。完整 API 和取消约定
见 [Rust SDK](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md)。
## 固定远端 Probe 和共享 Monitor
Manifest v1 可选声明 `contributions.probes` 和 `contributions.monitors`,旧包不必增加字段。
以 GPU 为例:
```json
{
"contributions": {
"panels": [{ "id": "overview", "title": "GPU", "entry": "ui/index.html" }],
"probes": [
{
"id": "gpu-overview",
"title": "GPU probe",
"entry": "assets/probes/gpu.sh",
"timeoutMs": 15000
}
],
"monitors": [
{
"id": "gpu",
"title": "GPU",
"schema": "gpu.v1",
"method": "monitor/collect",
"panel": "overview"
}
]
},
"permissions": ["native", "remote.probe"]
}
```
每类最多 16 项,ID 不可与其他贡献重复。脚本必须位于包内 `assets/probes/*.sh`,为非空 UTF-8、无 NUL、最多 64 KiB;
超时范围 1–30 秒。Monitor 必须引用已声明面板,声明 `monitor/*` 方法、原生后台和 Probe;当前只支持 `gpu.v1`。
自动授权绑定审核版本;启用对话框展示脚本全文,更新/切换版本移除 `remote.probe` 授权。
宿主不保证第三方脚本只读,不允许两个有效插件同时提供同一种 Monitor schema。
后台每次 `monitor/collect` 调用 `context.host().remote_probe("gpu-overview").await?`,解析结果并返回 GPU JSON。
对应 RPC `host/remote/probe` **只接受** `{ "probeId": "gpu-overview" }`,返回 `{ stdout, stderr, exitStatus }`。
不能提供命令、参数、stdin 或其他会话 ID;宿主从当前包解析脚本,在所属 SSH 连接的新 exec 通道执行 `sh -s`。
stdout/stderr 合计限 1 MiB,超时或取消关闭该通道,不向交互终端输入命令、不关闭 SSH 连接。
执行前后检查版本、授权、窗口、会话及锁屏;网络设备和禁止远程探测的会话不可执行。
UI 只订阅结果,不直接启动后台采集循环:
```js
await NyaTerm.ready;
const monitor = await NyaTerm.monitoring.subscribe("gpu", (snapshot) => {
// revision, sessionId, overview, error, refreshing, paused
render(snapshot);
});
await monitor.refresh();
await monitor.unsubscribe();
```
宿主以插件/版本/窗口/会话/Monitor 为键共享任务,订阅返回初始快照,再推送递增 revision。
Bridge 仅向对应 iframe 发送所属订阅;未开放通用原生事件透传或 UI 调用 `monitor/*`。
宿主消费者使用 typed `pluginApi.subscribeMonitor/refreshMonitor/unsubscribeMonitor`;
`subscribePluginMonitor` 负责先监听再订阅、初始快照竞态、revision 去重及释放。
每个 iframe 最多 16 个订阅;宿主最多 256 订阅和 128 共享任务。
SDK Context 提供 `language`、主题变量和 `monitorIntervalSeconds`。使用 `onContextChange` 同步语言/主题,
仅间隔变化时重新订阅。主窗口订阅 25 分钟续期;iframe 作用域按现有机制重建。
闲置任务不持有请求租约;后台每次只处理一次采集,不保存已经结束请求的 Context。
`gpu.v1` 结构与 `RemoteGpuOverview` 相同,宿主拒绝未知字段并限制设备/进程数量和文本长度。
资产回填只能来自当前会话的 GPU 数据,没有任意连接修改接口。
完整示例与构建命令见 [GPU 插件](../plugins/examples/gpu-monitor/README.md)。
## 查看状态和日志
原生后台按需启动并复用进程。卡片显示未启动、启动中、运行中、已停止或运行异常;
禁用插件和仅界面插件有独立标识。RPC 方法返回错误时,只记录调用失败,后台仍可
继续运行;进程退出、握手或协议失败则显示运行异常。
点击 **查看日志** 可刷新、复制和清空。日志包括时间、版本、级别、来源和消息。
日志弹窗每秒刷新,卡片状态每 2.5 秒刷新;关闭插件管理面板后停止轮询。
每个插件最多保留 100 条、64 KiB 消息与来源/版本文本,单条消息最多 2 KiB。
stderr 每次启动最多采集约 64 KiB,超过后继续排空而不保留。
日志只在内存中保留,退出应用即清除,不进入备份或云同步。
宿主会遮蔽常见凭据和作用域令牌,但插件作者仍需自行避免写入敏感内容。
**停止后台** 保留启用状态和权限。普通调用可启动新进程,进程内计数等状态会重置;
监控任务会同时暂停,定时器不会自行重新启动后台,手动刷新或重新启用可恢复。
插件有进行中的请求时会提示忙碌;先结束或关闭该操作,再停止后台。
禁用、更新、切换版本、锁屏和退出应用也会停止后台。旧进程的延迟消息不会覆盖
新启动进程的状态。更新插件必须提升版本号,同版本不同内容不能重新安装。
## 本地验证
```sh
node --test scripts/create-plugin.node-test.mjs
cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture
pnpm exec vitest run src/components/plugins/PluginDiagnostics.test.tsx --maxWorkers=4
```
`native-counter` 示例已使用 SDK,并升到 1.0.1:执行两次可看到计数递增和日志;
停止后台后再执行,计数从 1 开始。新版安装包为 `temp/plugins/native-counter.nyap`。
模板编译和打包已在 Windows 验证;其他平台需构建对应二进制并填写 executables。
-22
View File
@@ -1,22 +0,0 @@
# NyaTerm plugin system implementation
The requested plugin system builds on the preceding architecture review. The implementation must provide a usable extension host, local package installation, lifecycle management, permissions, isolated UI, native runtime, documentation and an end-to-end example.
Implemented as the local tool/panel extension host v1. See the [plugin guide](plugins.md)
and [validation report](plugin-system-validation.md) for the supported APIs, evidence
and remaining desktop release QA limits.
## Completion requirements
- A versioned, validated manifest declares panels, commands, menu locations, permissions and optional platform-specific native backends.
- `.nyap` packages can be inspected before installation, safely extracted with strict size/path/checksum validation, installed as immutable versions, enabled/disabled, updated, rolled back and uninstalled.
- Permissions are granted explicitly, enforced in Rust, and checked together with live session scope and application lock state. Native execution is presented as trusted code with OS user permissions.
- Plugin panels integrate with the existing activity bar and panel layouts; plugin commands are discoverable and available in declared context menus.
- Third-party HTML/JS runs in a sandboxed iframe with a restricted CSP and a bridge bound to its own iframe, plugin identity and backend-issued scope token. It cannot invoke arbitrary Tauri commands.
- The host API supports scoped session metadata, recent terminal output, approved command execution, scoped file reads, namespaced settings and declared network origins. Revocation, session closure and window teardown invalidate access.
- Optional sidecars use lazy persistent processes, a versioned initialization handshake, bounded JSON-RPC/binary frames, timeouts, crash cleanup and stop-on-disable/update behavior.
- Updates and removal do not race active requests; plugin state and permissions survive restarts. Plugin layouts survive temporary unavailability.
- A JavaScript SDK, packager, working example plugin and developer documentation cover the installation and authoring workflow.
- Meaningful backend and frontend tests, type checking, relevant linting and a build verify the implementation. A runtime smoke check verifies installation and UI/bridge integration where the environment permits.
New connection protocols and replacing built-in SSH/SFTP/RDP/VNC are subsequent provider API work described by the review, rather than requirements of this tool/panel extension host. A remote marketplace is also a separate service. Local packages and the extension runtime must be complete independently.
-85
View File
@@ -1,85 +0,0 @@
# Plugin v1 validation
Developer tooling and diagnostics were subsequently completed; see
[the development guide](plugin-development.md) and the updated verification below.
Validated on Windows on 2026-10-03. Scope follows
[the implementation plan](plugin-system-plan.md).
| Requirement | Implementation and evidence |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Versioned manifest and contributions | Independent runtime validates API/engine versions, paths, permissions, panels and command/menu declarations. Frontend contribution tests verify enabled-version discovery and namespaced IDs. |
| Local package lifecycle | Package/registry tests cover traversal, case collisions, complete checksums, modified content, inspection digest, immutable versions, persisted grants, permission revocation, update, rollback and publisher continuity. |
| Permissions and scope | Rust gateway allowlists capabilities and combines grants with plugin version, window ownership, connected session and lock checks. Approved command/file operations revalidate scope before execution. Permission review is bound to the active version to reject stale dialogs after an update. |
| Isolated UI | Bridge tests cover unrelated/spoofed frames, opaque origin, fixed scope token, arbitrary native command attempts, duplicate IDs, oversized messages and disposal. IPC tests cover top-frame initialization and Tauri binary/channel/fallback serialization. |
| Workspace integration | Unified Plugins management panel, activity panels, floating/multiple panel layouts and terminal/connection menu contributions integrate with existing workspace components. Existing workspace and context-menu tests pass. |
| Native runtime | Real child-process tests exercise handshake identity, persistent reuse, reverse RPC, timeout recovery and crash cleanup. Framed transport tests verify binary round trips and size limits. Activation cancellation and application exit/lock cleanup are implemented. |
| Developer workflow | UI and native examples both build/package. The extracted native package initializes and returns counts 1 then 2 from the same executable process. UI SDK round-trip tests and browser smoke verify the authoring path. |
| Documentation | [Plugin guide](plugins.md) specifies installation, trust, package lifecycle, storage, manifest, UI API, native protocol, limitations and maintenance. |
Checks completed:
- `pnpm exec vitest run --maxWorkers=4`: **152 files / 964 tests passed**.
Default high parallelism exceeded the existing AI settings test's five-second
timeout; limiting workers passed the entire suite. Temporary reference projects
under `temp/` are now excluded from test discovery.
- `pnpm build`: frontend type checking, MCP sidecar build and production Vite build
passed. Existing Browserslist age and large-bundle notices remain.
- `pnpm lint`: passed; one existing dependency warning in `CommandSuggestions.tsx`
remains. Plugin frontend files have no lint diagnostics.
- Runtime Cargo tests: **9 unit tests and 2 real-process tests passed**.
- Runtime `cargo clippy --all-targets -- -D warnings`: passed.
- Tauri `cargo check --lib` and `cargo clippy --lib`: passed. Existing project-wide
Clippy warnings remain; plugin-module diagnostics were checked separately.
- Tauri plugin tests: **2 passed**, for atomic lifecycle admission and rendered IPC
script templates. The Windows library-test binary failed in the loader before
running any tests; a temporary copy with a Common Controls v6 activation manifest
ran both successfully. Production application files were not altered for this
workaround.
- Real Chrome smoke using the actual bridge, SDK and Session Toolbox files:
panel loading, selected-session request, theme propagation, opaque message origin,
fixed-scope routing, parent DOM isolation, blocked direct network and absent
child-frame IPC transport all passed. Native host calls were stubbed in this
browser check.
This validation does not include a manually operated packaged Tauri desktop build
or macOS/Linux WebView testing. The browser smoke uses HTTP asset serving with the
equivalent sandbox/CSP and mocked native commands; it does not exercise Tauri's
custom resource protocol or real terminal execution dialogs. Verify those in
desktop release QA. Native plugins remain trusted OS processes; the current
format has no cryptographic publisher signatures or marketplace.
## Developer tooling and diagnostics validation
Completed on Windows, 2026-10-03:
- Project creation: UI and Rust starters generated successfully. Three Node tests
cover external paths (including spaces), valid SDK references, invalid arguments
and refusal to overwrite existing source.
- Generated UI/Rust packages pass the real package validator. The Rust starter
builds in release mode. Extracted native-counter **1.0.1** and the generated
Rust package both complete their SDK handshake, return counts **1, 2** from a
persistent process, and exit cleanly on stdin EOF.
- Rust SDK: two wire tests cover bounded frames, binary round trips, clean EOF and
truncated input. Runtime: **10 unit tests + 5 process integration tests** pass
with `--features sdk-fixture`. SDK process tests cover JSONL/framed transport,
concurrent and reverse RPC, binary data, structured logs, request cancellation,
cancellation of timed-out reverse host calls, method errors, panic recovery,
crashes, and stderr collected during a failing initialization handshake.
- Diagnostic storage tests cover retention limits, UTF-8 truncation, credential
redaction, stop/restart generations, clearing and removal. The panel distinguishes
UI-only/disabled plugins and backend states; three frontend tests cover stopping,
refresh/copy/clear, untrusted log text and stale responses after locking.
- Frontend full suite: **153 files / 967 tests pass**. `pnpm build` and `pnpm lint`
pass; the existing CommandSuggestions hook warning remains.
- SDK/runtime Clippy with `--all-targets -- -D warnings` passes. Tauri library
Clippy/check passes; there are no plugin-module diagnostics, while existing
project-wide warnings remain.
- `pnpm i18n:check` still reports existing ordering issues in the AI text sections
of all four locales. Plugin sections were formatted independently to preserve
unrelated content. All four locales contain the same diagnostic keys.
The user reported successful desktop testing of the earlier native-counter
package. The new diagnostics controls have automated frontend coverage; the
updated 1.0.1 package is ready for desktop smoke testing. This does not claim
macOS/Linux validation or a full manual desktop lifecycle test.
-360
View File
@@ -1,360 +0,0 @@
# NyaTerm plugins v1
NyaTerm supports locally installed tool and panel extensions. A plugin can add
sandboxed HTML panels, terminal/connection context-menu commands, and an optional
persistent native backend. Built-in connection protocols remain managed by
NyaTerm. Connection/filesystem providers and an online marketplace are future APIs.
## Install and use
1. Open the **Plugins** activity panel, choose **Install local package**, and select a `.nyap` file.
2. Review the plugin identity, publisher label and declared permissions. Installation
validates the package and leaves it disabled.
3. Choose **Enable**, select the permissions to grant, and save. A native backend
requires explicit `native` approval.
4. Open its panel or command from the Plugins activity item. Panels also appear in
the activity bar. Declared menu commands appear in terminal/connection menus.
The terminal selected when a panel opens defines its session scope. Selecting
another terminal recreates that scope. A terminal-menu command is scoped to that
terminal; a saved-connection-menu command has no live terminal scope. It cannot
silently access an unrelated active terminal. Opening a panel from a context menu
keeps that explicit session selection until another open intent replaces it.
Command execution and file reading each require a separate approval dialog, even
after granting their manifest permissions. Closing the panel or command dialog,
closing its session/window, locking NyaTerm, or changing plugin activation revokes
the associated scope and cancels host requests. Cancellation cannot undo a command
that has already run on the terminal.
Install a newer package with the same ID to update. Versions are immutable: the
same version cannot be replaced with different contents. Updates and switching
the version selector leave the selected version disabled for review. The old
versions remain available for rollback. Updates, grant changes and uninstall fail
while a plugin request is active; finish or close it, then retry. Disable stops the
native backend. Native processes are also stopped on lock and application exit.
## Trust and storage
UI panels run in an iframe with `sandbox="allow-scripts"`, an opaque origin and
a restrictive response CSP. Scripts, styles, images and fonts are local package
assets; direct network access, child frames, form submissions and popups are
blocked. The bridge accepts messages only from its own frame and binds requests
to a Rust-issued plugin/version/window/session scope. Plugins do not receive an
arbitrary Tauri command gateway. On Windows, where Wry injects initialization
scripts into subframes, the native IPC transport is explicitly initialized only
in the top-level document.
**Native plugins are trusted executables running with your OS user permissions.**
Host permission checks do not sandbox their filesystem, network or subprocess
access. Only enable native packages from a source you trust. SHA-256 checksums
verify consistency and the reviewed package digest prevents a file swap between
inspection and installation; these are not publisher authentication. Publisher
names are self-declared. A changed publisher label cannot overwrite an existing
ID without uninstalling it first, but that continuity check is not a signature.
Local packages may remain unsigned. The official Plugin Store uses repository
Ed25519 signing: `signature.json` signs the exact bytes of `checksums.json`, which
covers every content file (including `manifest.json`) but excludes `checksums.json`
and `signature.json` themselves. Publisher verification and package signatures are
separate concepts; a signature authenticates the Store's reviewed artifact.
The Marketplace downloads and verifies packages in Rust, checks the catalog's final
SHA-256 and size, and cross-checks ID, version, publisher, permissions and signing
key. Public keys are pinned in the client; catalog metadata cannot add trusted keys.
The review dialog shows the verified package before installation, and installation
leaves the plugin disabled until its runtime permissions are separately granted.
Installed versions persist provenance; switching between local and Store sources
requires uninstalling first. Old registry records default to local provenance.
The Store lives in [`nyakang/nyaterm-plugins`](https://github.com/nyakang/nyaterm-plugins).
Its records and signing workflow are independent of author plugin source. First
rollout requires initializing the official key and configuring the protected
`plugin-signing` environment; an empty client trust store rejects every Store install.
The standalone [`@nyaterm/plugin-cli`](../plugins/cli/README.md) package bundles the
authoring binary, SDK and templates, with a workflow to assemble six platforms:
```sh
npm install -g @nyaterm/plugin-cli
nyaterm-plugin create example.hello ./hello
nyaterm-plugin pack ./hello ./hello-1.0.0.nyap
nyaterm-plugin inspect ./hello-1.0.0.nyap
```
Publication to npm is a separate release step. Distributed Rust templates use a
pinned public Git SDK dependency; source-checkout templates retain local paths.
Plugin files live in the active runtime's `plugins/` directory (normally
`~/.nyaterm/plugins/`):
```text
plugins/
registry.json
vendor.plugin/versions/1.0.0/...
data/vendor.plugin/settings.json
```
`storage` holds plaintext, nonsecret JSON preferences, separately for each ID.
Do not store passwords, tokens, terminal commands or terminal output there.
Uninstall removes plugin code and registration; preferences are retained for a
later reinstall. Plugin packages, grants and preferences are excluded from the
existing portable snapshot/backup/cloud-sync payloads.
## GPU monitor
The [GPU monitor example](../plugins/examples/gpu-monitor/README.md) validates
fixed remote probes, shared collection and GPU status contributions. Build it with
`pnpm plugin:example:gpu`, then package it with
`pnpm plugin:pack plugins/examples/gpu-monitor temp/plugins/gpu-monitor.nyap`.
Enable it after reviewing its fixed scripts and approving `native` and `remote.probe`.
Automatic probe grants are removed on updates and version switches.
An authorized `gpu.v1` provider replaces built-in GPU collection and the existing
GPU workspace; disabling it restores the built-in source. Collection failures remain
visible on the plugin source. See the [validation record](gpu-plugin-validation.md).
## Build the UI example
For project generation, Rust SDK APIs, status and logs, see the
[development guide](plugin-development.md) and
[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md).
From the NyaTerm repository (Node/pnpm and Rust required):
```sh
pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap
```
Install the generated file, enable it with the permissions you want to try, select
a live terminal and open **Session Toolbox**. Read output, request a command or
request a UTF-8 file read. The latter two actions show the native host approval UI.
Terminal execution uses the existing execution profile and shell capture engine;
sessions that disable execution retain that restriction.
The packager includes `ui/nyaterm-sdk.js` automatically. It is reserved and cannot
be overwritten with different bytes. Only `manifest.json`, `ui/`, `assets/` and
`bin/` are collected, so source code/build artifacts outside these directories do
not enter the archive. UI entry assets must be at most 4 MiB each.
## Manifest
```json
{
"contributions": {
"panels": [
{ "id": "overview", "title": "Overview", "entry": "ui/index.html" }
],
"commands": [
{
"id": "open-overview",
"title": "Open Overview",
"panel": "overview",
"menus": ["terminal", "connection"]
}
]
},
"description": "Tools for a selected terminal",
"engine": ">=1.2.12, <2.0.0",
"id": "example.tools",
"manifestVersion": 1,
"name": "Example Tools",
"permissions": ["session.read", "terminal.read", "storage"],
"publisher": "Example",
"version": "1.0.0"
}
```
Unknown fields are rejected. IDs use lowercase ASCII letters, digits, `.`, `_`
and `-`, start with a letter/digit and are at most 128 bytes; `..` is prohibited.
Plugin IDs also contain a dot to separate their namespace.
Versions use canonical SemVer; `engine` uses Rust semver requirements, including
comma-separated comparators. Panel entries are `ui/*.html`. A command references
exactly one panel or a declared `command/*` native method. Panel and command IDs
are unique across the contribution set. Optional menus are `terminal` and
`connection`. Panels use persisted `plugin:<pluginId>:<panelId>` activity IDs and
retain their layout when temporarily disabled or unavailable.
Limits: 16 panels, 64 commands, 24 permissions including at most 8 exact HTTPS
origins. Package limits: 128 MiB compressed, 256 MiB expanded, 64 MiB per file and
1024 archive entries. Paths must be portable relative paths; traversal, symlinks,
special files, Windows aliases and case collisions are rejected. `checksums.json`
maps every other archive filename, including the manifest, to its SHA-256 hash.
Use the packager to produce this file rather than maintaining hashes by hand.
## UI SDK and host API
Load classic scripts from local files. Inline styles are allowed; use a local
bundled JS file rather than a CDN or dynamic imports requiring cross-origin access.
```html
<script src="nyaterm-sdk.js"></script>
<script src="app.js"></script>
```
```js
await NyaTerm.ready;
const session = await NyaTerm.session();
const { output } = await NyaTerm.terminal.read(100);
document.getElementById("output").textContent = output;
```
Use `textContent` when rendering terminal/file/network data. Typings are in
`plugins/sdk/nyaterm.d.ts`. SDK operations return promises and reject on denied
permission, invalid scope, cancellation or runtime errors.
| SDK method | Host method / permission | Behavior |
| ---------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `session()` | `host/session` / `session.read` | Selected `{id,name,type,connected}` or `null`; no credentials |
| `terminal.read(lines = 100)` | `host/terminal/read` / `terminal.read` | `{output}`; 1–500 recent lines |
| `terminal.execute(command, timeoutMs = 30000)` | `host/terminal/execute` / `terminal.execute` | Individual approval, audit and existing execution engine; 1–120 s execution timeout |
| `filesystem.read(path)` | `host/filesystem/read` / `filesystem.read` | Individual approval; at most 256 KiB UTF-8 text |
| `storage.get(key)` | `host/storage/get` / `storage` | JSON value or `null` |
| `storage.set(key, value)` | `host/storage/set` / `storage` | `null` deletes; otherwise saves nonsecret JSON |
| `network.request(url, options)` | `host/network/request` / `network:https://origin` | GET/POST, declared exact origin, no redirects, 30 s timeout |
| `backend(method, input)` | `ui/*` or declared `command/*` / `native` | Request to persistent backend |
Execute results contain `output`, `exitCode`, `durationMs`, `timedOut` and
`sourceTruncated`. File reads expose `content` (SSH may return extra file metadata).
Local file paths are confined to the selected session's canonical working
directory. Remote reads require SSH/SFTP; serial and Telnet do not offer file reads.
Storage allows 128 portable-ID keys and 1 MiB serialized JSON per plugin. Network
options are `{method, body, contentType}`; request bodies are capped at 256 KiB and
responses at 1 MiB. Results are `{status, contentType, body}` with text bodies.
URLs cannot contain credentials. HTTPS origins include their nondefault port and
must match exactly; no wildcards, paths or implicit subdomain grants.
`NyaTerm.context` contains plugin identity/version and CSS theme variables;
`NyaTerm.onContextChange(listener)` returns an unsubscribe function. Theme variables
are applied to the iframe document automatically, including `--font-sans` and
`--font-display` (the configured UI font stack) and `--font-mono`. Font changes
are pushed to open panels without reloading them. The SDK defaults to the UI font
and makes form controls inherit it; plugin styles should use
`font-family: var(--font-sans, system-ui, sans-serif)` instead of a fixed family.
Use `var(--font-mono, monospace)` for code. System-installed fonts are available
inside the iframe, but the host's `@font-face` declarations are not inherited;
package any required web fonts with the plugin and declare them in its CSS.
Existing packages with a fixed font family need to update their CSS and be rebuilt.
This context contains no scope
token. Tokens are managed by the parent bridge. Scopes expire after 30 minutes;
open panels renew at 25 minutes by recreating their document. Preserve needed
nonsecret preferences in plugin storage.
UI requests are limited to 32 outstanding calls and 1 MiB request parameters.
Host calls have a 180 s envelope including approval; SDK calls time out at 185 s.
An approval dialog times out at 120 s. A native backend request times out at 170 s,
including any reverse host calls, so long commands and approvals share that budget.
## Native backend protocol
Build and package a working persistent-process example:
```sh
pnpm plugin:example:native
pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap
```
Install and explicitly grant `native`. Invoke **Increment native counter** from
the Plugins panel twice: its count increases in the same process. Disable/re-enable
and invoke again: activation starts a fresh process. The build script generates
a manifest for the current OS/architecture. Production plugins should bundle the
executables needed for each supported platform:
```json
{
"executables": {
"windows-x86_64": "bin/backend.exe",
"linux-x86_64": "bin/backend"
},
"transport": "stdio-jsonl"
}
```
Put that object under `backend`, declare the `native` permission, and declare a
command with `"method": "command/count"`. Accepted platforms are Windows, Linux
and macOS on `x86_64` or `aarch64`. Activation is lazy, per plugin; calls reuse the
process and can run concurrently. stdout is exclusively protocol traffic; write
diagnostics to stderr. Environment inheritance is restricted to PATH, OS/temp and
locale values, plus `NYATERM_PLUGIN_ID`, `NYATERM_PLUGIN_VERSION`, `NYATERM_APP_VERSION`.
The working directory is the immutable installed version directory.
The host first sends a JSON-RPC 2.0 request:
```json
{
"id": 1,
"jsonrpc": "2.0",
"method": "plugin/initialize",
"params": {
"pluginId": "example.tools",
"pluginVersion": "1.0.0",
"appVersion": "1.2.12",
"apiVersion": 1,
"protocolVersion": 1,
"permissions": ["native"]
}
}
```
Reply within 10 seconds with matching identity/version/protocol:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"pluginId": "example.tools",
"pluginVersion": "1.0.0",
"protocolVersion": 1
}
}
```
Normal host requests have numeric IDs and params `{scopeToken, input}`. Respond
with the same numeric ID and `result` or `error:{code,message}`. Backends may make
reverse host requests using a **string ID**, one of the host methods in the table,
and params `{scopeToken, input}`. They must propagate the token associated with the
specific call, not keep a global "current session". Host enforcement checks the
token's plugin, live session/window, version, grants and lock state. Other plugins'
tokens are rejected. Backends should handle `$/cancelRequest` notifications for
timed-out numeric host requests. Closing a scope stops waiting for its response;
native code must arrange its own cooperative work cancellation.
`stdio-jsonl` is one UTF-8 JSON object per newline. `stdio-framed` uses:
```text
kind: u8 | payload length: u32 little-endian | payload
kind 1: UTF-8 JSON-RPC
kind 2: channel length: u16 little-endian | UTF-8 channel | raw bytes
```
Frames are capped at 8 MiB; JSON at 2 MiB; channel names at 128 bytes. The runtime
supports notification/binary subscriptions for future provider integration, but
v1 browser APIs expose request/response calls only. There is no public UI binary
stream or backend event subscription. Do not use those frames to implement a UI
API yet. The runtime bounds queues and pending requests and terminates the child
on protocol failures, failed handshake, disable/update/uninstall, lock or exit.
A crashed backend is activated again on the next call.
## Maintaining the host
The independent runtime lives in `src-tauri/crates/nyaterm-plugin-runtime`; Tauri
adapters and permission checks live in `src-tauri/src/core/plugins`. Plugin UI
bridge code and the SDK are separately tested.
`ipc_transport.js` and `process_ipc_message.js` preserve Tauri 2.11.5 transport
semantics, with a top-frame guard before initializing the invoke key. When
upgrading Tauri, compare these files with its upstream scripts and rerun the IPC
isolation/serialization tests. Do not remove the guard or add `allow-same-origin`
to plugin iframes to work around compatibility issues.
On Windows, the full Tauri library test executable may need a Common Controls v6
activation manifest to load its native UI dependencies. An entrypoint-not-found
error before any tests run is a loader failure; check the executable manifest
rather than interpreting it as a failed plugin assertion.
```sh
pnpm exec vitest run
pnpm build
pnpm lint
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml
cargo test --manifest-path src-tauri/Cargo.toml --lib core::plugins
```
+248
View File
@@ -0,0 +1,248 @@
# Web / Docker 部署
Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 Beta 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。
## 本地运行
需要 Node 24、pnpm 11.10 和 Rust 1.97.1(与 Dockerfile 一致)。
```powershell
pnpm install --frozen-lockfile
pnpm build:web
pnpm web:build:server
```
生成两个独立随机秘密并写入工作区外的受限目录。以下命令不打印秘密;登录密码可通过密码管理器读取保存。目录和文件的访问权限应限制为服务运行用户。
```powershell
$secretDir = Join-Path $env:LOCALAPPDATA 'NyaTermWebSecrets'
New-Item -ItemType Directory -Force -Path $secretDir | Out-Null
$loginBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
$keyBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
[System.IO.File]::WriteAllText((Join-Path $secretDir 'login'), [Convert]::ToBase64String($loginBytes))
[System.IO.File]::WriteAllText((Join-Path $secretDir 'encryption'), [Convert]::ToBase64String($keyBytes))
$env:NYATERM_WEB_PASSWORD_FILE = Join-Path $secretDir 'login'
$env:NYATERM_WEB_ENCRYPTION_KEY_FILE = Join-Path $secretDir 'encryption'
$env:NYATERM_WEB_PUBLIC_URL = 'http://localhost:8080/'
$env:NYATERM_WEB_BIND = '127.0.0.1:8080'
$env:NYATERM_WEB_DATA_DIR = Join-Path $env:LOCALAPPDATA 'NyaTermWebData'
pnpm web:serve
```
访问 `http://localhost:8080/`,使用生成的登录密码。PowerShell 示例要求 PowerShell 7 / .NET 的 `GetBytes(int)` API。Linux 可用 `umask 077` 后分别执行 `openssl rand -base64 32 > login` 和 `openssl rand -base64 32 > encryption`,然后配置相应 `_FILE` 变量。
## 环境变量
| 变量 | 含义 |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 |
| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 |
| `NYATERM_WEB_PUBLIC_URL` | 浏览器实际访问地址,含端口和 base path;默认 `http://localhost:8080/`。非回环地址默认必须为 HTTPS。 |
| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 |
| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 |
| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 |
| `NYATERM_WEB_BASE_PATH` | **前端构建时**使用的路径,默认 `/`,必须与 PUBLIC_URL 的路径一致并以 `/` 结尾。 |
`NYATERM_WEB_ALLOW_INSECURE_HTTP=true` 可显式允许局域网 HTTP 地址;同时将 `NYATERM_WEB_PUBLIC_URL` 设置为浏览器实际使用的 IP/域名和端口,并将监听地址设为 `0.0.0.0:8080`。前端请求 ID 使用 `crypto.getRandomValues` 兼容 HTTP,但剪贴板等浏览器能力仍可能要求 HTTPS。Host/Origin 校验继续生效,访问地址必须与 PUBLIC_URL 一致。
服务启动会验证数据密钥;错误密钥会阻止启动。文件方式会去掉末尾换行。登录密码可独立替换,服务器重启使所有旧登录失效;加密密钥目前没有在线轮换 API。
## Docker
部署文件集中在 [deploy/web/](../deploy/web/README.md)。以下 Docker 和 Compose 命令均在仓库根目录执行;构建上下文仍是仓库根目录,使用根目录的 `.dockerignore`。
```sh
docker build -f deploy/web/Dockerfile -t nyaterm-web .
docker volume create nyaterm-data
docker run -d --name nyaterm-web --init \
-p 127.0.0.1:8080:8080 \
-e NYATERM_WEB_PUBLIC_URL=http://localhost:8080/ \
-e NYATERM_WEB_PASSWORD_FILE=/run/secrets/login \
-e NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption \
--mount type=bind,src=/absolute/private/secrets,dst=/run/secrets,readonly \
--mount type=volume,src=nyaterm-data,dst=/data \
--read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges:true \
nyaterm-web
```
容器使用 UID / GID `10001:10001`。绑定目录时,确保该用户可以读取秘密文件和写入数据目录;新 named volume 会继承镜像中 `/data` 的所有权。默认端口映射仅暴露本机。如需远程访问,使用下面的 HTTPS 反向代理配置。
仓库还提供 `deploy/web/docker-compose.yml`,它通过外部环境变量注入密码和密钥:
```sh
docker compose -f deploy/web/docker-compose.yml up --build -d
```
提前在当前 shell 或仓库根目录的受限 `.env` 中设置两个必填秘密。使用外部环境文件时,在 Compose 命令中显式添加 `--env-file /absolute/private/nyaterm.env`。`.env*` 已排除在 Git 和 Docker build context 外。生产环境优先将 Compose 改为 `secrets` 挂载和 `_FILE` 变量,避免把秘密写入镜像。Dockerfile 使用 Node、Rust、Debian 三阶段构建,最终镜像只含 dist、服务程序和运行依赖。
Compose 默认项目名显式设为 `nyaterm`,与原先在名为 `nyaterm` 的仓库根目录运行的默认项目名一致,数据卷仍为 `nyaterm_nyaterm-data`。如果旧部署使用其他仓库目录名、`-p` 或 `COMPOSE_PROJECT_NAME`,迁移后须通过 `-p <原项目名>` 或 `COMPOSE_PROJECT_NAME` 沿用原值,确保继续使用原数据卷和镜像;原登录密码和加密密钥也须保持一致。
仓库 Compose 的 `8080:8080` 会发布到所有主机接口。仅本机访问或使用本机反向代理时,将它改为 `127.0.0.1:8080:8080`。
使用文件秘密时,可把下面的独立配置保存为工作区外的 `/absolute/private/compose.secrets.yml`。先按上面的命令构建 `nyaterm-web` 镜像,创建两个独立秘密文件,并确保 UID 10001 可以读取它们。此配置替代仓库 Compose,不与它叠加,避免原配置中必填的明文环境变量插值。
```yaml
name: nyaterm
services:
nyaterm:
image: nyaterm-web
ports:
- "127.0.0.1:8080:8080"
environment:
NYATERM_WEB_PUBLIC_URL: https://terminal.example.com/
NYATERM_WEB_PASSWORD_FILE: /run/secrets/login
NYATERM_WEB_ENCRYPTION_KEY_FILE: /run/secrets/encryption
secrets:
- login
- encryption
volumes:
- nyaterm-data:/data
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
secrets:
login:
file: /absolute/private/login
encryption:
file: /absolute/private/encryption
volumes:
nyaterm-data:
```
```sh
docker compose -f /absolute/private/compose.secrets.yml up -d
```
将 PUBLIC_URL 换为实际 HTTPS 地址,并配置下面的反向代理。Compose 文件秘密以只读挂载提供;宿主机文件权限仍决定容器能否读取,不能依赖 Compose 的 `uid` / `gid` 为绑定文件改属主。
## HTTPS 和子路径
例如部署到 `https://terminal.example.com/nyaterm/`:
```sh
docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web .
```
运行时设置 `NYATERM_WEB_PUBLIC_URL=https://terminal.example.com/nyaterm/`。前端和后端必须使用相同的 origin 和路径;后端会校验原始 Host / Origin,不使用转发头放宽校验。代理应保留完整路径,不能剥掉 `/nyaterm`。
使用仓库 Compose 时,构建前同时设置 `NYATERM_WEB_BASE_PATH=/nyaterm/` 和上述 PUBLIC_URL,再执行 `up --build -d`。HTTPS 地址使登录 Cookie 带上 Secure;TLS 可在代理终止,后端仍使用内部 HTTP。
Nginx 示例(TLS 证书配置按已有站点设置):
```nginx
# http 块
map $http_upgrade $nyaterm_connection_upgrade {
default upgrade;
'' close;
}
# 已配置 TLS 的 server 块
location = /nyaterm { return 308 /nyaterm/; }
location /nyaterm/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Origin $http_origin;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $nyaterm_connection_upgrade;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 650s;
client_max_body_size 0;
}
```
关闭响应缓冲以支持 SSE / 下载,关闭请求缓冲以支持流式上传。可根据部署需求调整上传大小和代理超时。Web 不提供跨源 API 或 Vite 开发代理;验证运行模式应使用构建后的 dist。
## 数据与能力范围
停服后备份整个数据目录,并单独备份加密密钥与登录密码。密钥丢失将无法解密凭据。不要让多个进程同时写同一个 redb volume,也不要把桌面用户数据目录直接作为服务器 volume。保存的私人密钥、密码和 AI provider 密钥使用原有 AES-GCM 存储。AI 历史和连接元数据属于实例共享数据,未作为整个数据库加密。
支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask、设置持久化以及资源/GPU/NPU/进程监控。监控使用独立 SSH exec 通道,Docker 管理仍不支持。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。
Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。
刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。
更新后请重新构建镜像并重建容器。普通 `docker compose down` 保留数据卷;`down -v` 会删除数据卷。恢复停服备份时,使用原加密密钥、恢复整个数据目录及 UID/GID 10001 的读写权限,再启动单个实例。浏览器中的密码备份用于配置迁移,不能代替整个数据目录的备份。
## Web Beta 能力矩阵与验收
| 能力 | Web Beta | 桌面兼容 |
| ------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| SSH/Telnet | 登录 owner 隔离、输入输出、刷新附着、有限重试恢复 | 原生协议与生命周期保持 |
| VNC | 现有 Web 认证、输入、剪贴板及恢复 | 保持 |
| 单文件上传/下载 | 流式原始上传;浏览器 Blob 下载并提示失败 | 保持原生传输 |
| 同名上传 | ask/skip/rename/overwrite;POSIX 原子覆盖,拒绝目录覆盖 | 共用 duplicate_strategy 设置 |
| 文件编辑 | 默认内部编辑器,工作区或 iframe;二进制/不支持编码下载 | 保留外部编辑器、watcher 和本地路径 |
| 传输设置 | 仅冲突策略与内部编辑显示方式 | 隐藏字段完整保留,无迁移 |
| 目录传输、调优、暂停/重试 | 未实现,隐藏入口;含目录选择禁用下载 | 保持 |
| 资源/GPU/NPU/进程监控 | 独立 SSH exec 与白名单进程信号 | 保持 |
| RDP、录制、云同步、AI Agent/MCP | 当前不支持 | 保持 |
上传继续使用 `POST api/sessions/{id}/upload?path=...` 和原始文件正文;返回 `{ "bytes": 123, "status": "completed", "path": "/final/path" }`。跳过返回 bytes=0、status=skipped;改名返回 UUID 后缀的最终 path。覆盖依赖远端 `posix-rename@openssh.com`;不支持时明确失败并保留原文件,不回退到先删后写。
SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。WebSocket 断开、发送失败或超时只释放当前附着;服务端保留远端会话约 30 秒,期间可重新附着同一个 session ID。输出队列有界,断开期间队列填满会对远端施加背压;不保证重放已经交给旧 WebSocket 的字节。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、登录过期、远端结束或租约到期会结束会话。注销从 File 菜单进入。
完整合并检查由 [Web merge checks](../.github/workflows/web-merge-checks.yml) 执行,包含前端 lint、国际化、Vitest、Web 构建、Core / Web Rust 测试、三个平台的桌面 cargo check,以及 Docker / Chromium 验收。Docker/OpenSSH 验收需要 Docker CLI:
```sh
pnpm exec playwright install chromium
pnpm test:web:e2e
```
脚本为根路径和 `/nyaterm/` 构建独立镜像,启动临时 OpenSSH 容器,检查登录、持续输出中的刷新与原会话重附着、文件编辑/传输和注销。失败产物保存在 `artifacts/web-e2e/`。部署后还需通过实际 HTTPS 代理确认登录、终端、SSE 与上传下载。
## 密码备份与文件导入
File 菜单的导出在浏览器中设置并确认本次备份密码,下载密码保护的 `.nya` 文件;导入选择文件、输入该文件的原备份密码,并确认覆盖实例配置。取消选择不会报错,执行期间不能重复提交。备份密码与 Web 登录密码、部署加密密钥相互独立,仅在本次请求中使用。
`.nya` 延续桌面已有格式和旧版解码能力。Web 导出将凭据包装到独立的备份密钥中;Web 导入解开源密钥,把凭据重新加密到目标实例。可以在使用不同部署密钥的 Web 实例之间迁移,不需要复制或替换目标 `NYATERM_WEB_ENCRYPTION_KEY`。桌面导出的备份密码是导出时的桌面主密码;当前桌面导入入口也使用桌面主密码解密文件,因此导入 Web 备份前需将桌面主密码设为该备份密码。
导入先校验密码、完整性、数据关系和凭据,再用单个 redb 事务恢复;失败保留原配置。备份中的 Serial、本地 Shell 等桌面配置会保留,Web 能力检查控制使用。导入成功后刷新连接、设置、快捷命令、历史和笔记。运行中的会话仍使用已建立的连接。
桌面备份中的旧连接内联明文密码会在导入时转换为目标实例的加密凭据,空字符串按未保存密码处理。符合 AES-GCM 密文格式的字段仍必须通过解密校验,不会在解密失败后当作明文保存;私钥、托管账号、OTP 等其他凭据继续严格校验。
连接导入支持 Xshell、MobaXterm、SecureCRT、WindTerm、Electerm、NyaTerm JSON 的单文件导出。服务端只解析上传内容,不读取上传内容指定的服务器路径。WindTerm 如果依赖外部配置或私钥,需先在桌面完整导入再迁移 `.nya`;FinalShell 目录、Termius 本机数据库和自动扫描 `~/.ssh/config` 仅桌面支持。Xshell、MobaXterm、SecureCRT 的导出通常不包含密码,导入后会提示补充凭据。主题、快捷命令、关键词规则和私钥的可见文件入口使用浏览器选择与下载。
| API | 正文与结果 | 上限 |
| ------------------------------ | --------------------------------------------------------- | ---------------------------- |
| `POST api/backups/export` | JSON `{ "password": "…" }`;返回 `.nya` 二进制 | 输出 50 MiB |
| `POST api/backups/import` | multipart `file`、`password`;成功返回 `status=completed` | 文件 50 MiB,解压 50 MiB |
| `POST api/imports/connections` | multipart `file`、`source`;返回 `imported`、`warnings` | 文件 10 MiB,归档展开 50 MiB |
上述接口要求当前登录、正确的 Origin 和 CSRF;`source` 只接受 `xshell`、`mobaxterm`、`securecrt`、`windterm`、`electerm`、`nyaterm_json`,不接受服务器文件路径。
## 部署日志与诊断
服务默认输出 JSON 日志到 stdout,可用 `docker logs --tail 200 nyaterm-web` 或 `docker compose -f deploy/web/docker-compose.yml logs --tail 200 nyaterm` 查看。同时写入数据目录的 `logs/` 子目录。日志按 UTC 日期或 10 MiB 轮转,总量不超过 100 MiB;诊断设置中的级别和保留天数保存后立即生效,保留天数为 1–30 天。文件写入失败时继续 stdout 日志并保持业务运行,正常停机刷新日志。
仓库 Compose 未单独配置 Docker 日志轮转,沿用 Docker daemon 的日志驱动及限制。可在服务中添加 `logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }`,或在 `docker run` 中添加 `--log-opt max-size=10m --log-opt max-file=3`。Docker stdout 日志与应用数据目录日志分别轮转。
每个 API 响应包含 `X-Nyaterm-Request-Id`;JSON 错误也带 `request_id`。前端 invoke 的请求 ID 传入服务端,日志记录路由模板、操作、状态码、耗时和相关会话 ID。异步连接任务延续请求关联。底层错误先记录类别、错误码、固定原因及原始错误指纹,再返回通用错误。终端帧和命令正文不会逐条记录。
浏览器未登录时仅保留有界内存队列,登录后批量上传现有 logger、未捕获异常和 Promise rejection。每批最多 50 条、256 KiB,服务端每个登录每分钟最多接受 120 批;断网上报最多重试两次(1 秒、2 秒退避),丢弃数量合并到后续批次,不递归记录上报失败。`POST api/logs/frontend` 和 `GET api/diagnostics/export` 要求登录;POST 同时校验 Origin 与 CSRF。
在 Web 设置的诊断区下载 ZIP 包,包含最多 20 MiB 的近期脱敏日志,以及版本、系统、架构和能力清单。任意错误文本和不可信文本以指纹记录;密码、Cookie、CSRF、私钥、命令正文、终端内容不会写入诊断包,数据库和备份也不会收录。Web 隐藏打开本地日志目录入口。本次不提供页面日志查看器。
## 本机浏览器验收
安装 Chromium 后,以本机编译的 Web 服务和真实 dist 执行登录、主题/窄屏截图、备份往返、诊断下载及会话恢复:
```sh
pnpm exec playwright install chromium
pnpm build:web
cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked
node scripts/web-local-e2e.mjs
```
子路径验收先用 `NYATERM_WEB_BASE_PATH=/nyaterm/` 重新构建前端,再设置 `NYATERM_LOCAL_E2E_BASE_PATH=/nyaterm/` 执行同一脚本。测试使用临时数据目录和随机秘密,退出后清理服务进程。截图保存在 `artifacts/web-local-e2e/`。
该本机脚本只覆盖不依赖 OpenSSH 容器的浏览器功能;完整 SSH 刷新恢复和容器部署验收使用 `pnpm test:web:e2e`。当前提交的验证结果以对应的 CI run 为准。
+303
View File
@@ -0,0 +1,303 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { gotoLogin } from "./loginNavigation.mjs";
const en = JSON.parse(
readFileSync(new URL("../src/i18n/locales/en.json", import.meta.url), "utf8"),
);
const t = (key) => key.split(".").reduce((value, part) => value[part], en);
test("single administrator: login, SSH, refresh, editor, conflicts, download, child settings, logout", async ({
page,
}, testInfo) => {
if (
!process.env.NYATERM_E2E_PASSWORD ||
!process.env.NYATERM_E2E_SSH_PASSWORD
)
throw new Error(
"Run pnpm test:web:e2e to provision disposable Docker credentials.",
);
const base = process.env.NYATERM_E2E_URL;
const errors = [],
output = [];
page.on("websocket", (socket) => {
if (socket.url().endsWith("/terminal"))
socket.on("framereceived", ({ payload }) => {
if (Buffer.isBuffer(payload)) output.push(payload);
});
});
const terminalOutput = () => Buffer.concat(output).toString("utf8");
try {
await gotoLogin(page, base);
page.on("pageerror", (error) => errors.push(String(error)));
await page
.getByLabel(t("web.password"))
.fill(process.env.NYATERM_E2E_PASSWORD);
await page
.getByRole("button", { name: t("web.signIn"), exact: true })
.click();
await expect(page.getByLabel(t("web.password"))).toHaveCount(0);
const auth = await (
await page.request.get(new URL("api/auth/session", base).href)
).json();
const headers = {
"X-Nyaterm-Csrf": auth.csrf,
"X-Nyaterm-Request": "1",
Origin: new URL(base).origin,
};
const command = async (name, args = {}) => {
const response = await page.request.post(
new URL(`api/commands/${name}`, base).href,
{ headers, data: args },
);
expect(response.ok(), `${name}: ${await response.text()}`).toBe(true);
return response.json();
};
const settings = await command("get_app_settings");
settings.general.startup_restore = true;
settings.ui.language = "en";
settings.ui.file_explorer_view_mode = "list";
settings.ui.active_left_panel = "fileExplorer";
settings.ui.active_right_panel = "savedConnections";
settings.ui.left_open_panels = ["fileExplorer"];
settings.ui.right_open_panels = ["savedConnections"];
settings.transfer.editor_type = "external"; // Web must override this without persisting a change.
settings.transfer.internal_editor_display = "workspace";
settings.transfer.duplicate_strategy = "ask";
await command("save_app_settings", { settings });
await command("save_connection", {
connection: {
id: "",
name: "E2E OpenSSH",
type: "ssh",
host: "ssh-fixture",
port: 22,
username: "test",
auth: {
mode: "password",
password: process.env.NYATERM_E2E_SSH_PASSWORD,
},
},
});
await page.reload();
await page.getByText("E2E OpenSSH", { exact: true }).first().dblclick();
await expect(
page.getByText(t("settings.hostKeyVerifyTitle"), { exact: true }),
).toBeVisible();
await page
.getByRole("button", {
name: t("settings.hostKeyVerifyAccept"),
exact: true,
})
.click();
await expect
.poll(
async () =>
(await command("get_sessions")).filter((session) => session.connected)
.length,
)
.toBe(1);
const sessionId = (await command("get_sessions"))[0].id;
expect(sessionId).toBeTruthy();
async function typeCommand(marker) {
const terminal = page.locator(".xterm-helper-textarea").first();
await terminal.focus();
await terminal.pressSequentially(`printf 'WEB_%s\\n' '${marker}'`, {
delay: 5,
});
await terminal.press("Enter");
await expect.poll(terminalOutput).toContain(`WEB_${marker}`);
}
await typeCommand("READY");
await expect
.poll(async () => (await command("get_app_settings")).ui.open_tabs.length)
.toBeGreaterThan(0);
// Leave a remote producer running across reload; quiet terminals do not
// exercise the server's WebSocket send/disconnect race.
const terminal = page.locator(".xterm-helper-textarea").first();
await terminal.focus();
await terminal.pressSequentially(
"while :; do printf 'WEB_%s\\n' 'STREAMING'; sleep 0.01; done & NYATERM_E2E_OUTPUT_PID=$!",
);
await terminal.press("Enter");
await expect.poll(terminalOutput).toContain("WEB_STREAMING");
await page.reload();
await expect(page.locator(".xterm-helper-textarea").first()).toBeAttached();
output.length = 0;
await expect.poll(terminalOutput).toContain("WEB_STREAMING");
expect(
(await command("get_sessions")).map((session) => session.id),
).toEqual([sessionId]);
await terminal.focus();
await terminal.pressSequentially(
'kill "$NYATERM_E2E_OUTPUT_PID"; wait "$NYATERM_E2E_OUTPUT_PID" 2>/dev/null',
);
await terminal.press("Enter");
await typeCommand("REFRESHED");
expect((await command("get_sessions"))[0].id).toBe(sessionId);
const readme = page.getByText("readme.txt", { exact: true }).first();
await readme.dblclick();
const editor = page.locator(".cm-content").first();
await expect(editor).toContainText("fixture text");
await editor.fill("edited through Chromium\n");
await page
.getByRole("button", { name: t("fileEditor.save"), exact: true })
.click();
await expect
.poll(
async () =>
(
await command("open_remote_file_text", {
sessionId,
path: "/home/test/readme.txt",
})
).file.content,
)
.toBe("edited through Chromium\n");
expect((await command("get_app_settings")).transfer.editor_type).toBe(
"external",
);
const binaryDownload = page.waitForEvent("download");
await page.getByText("binary.bin", { exact: true }).first().dblclick();
const binary = await binaryDownload;
expect(readFileSync(await binary.path())).toEqual(
Buffer.from([0, 1, 2, 3]),
);
await expect(
page.getByText(t("fileExplorer.webUnsupportedDownload")),
).toBeVisible();
async function upload(content) {
await page
.getByRole("button", { name: t("fileExplorer.upload"), exact: true })
.click();
const chooser = page.waitForEvent("filechooser");
await page
.getByRole("menuitem", { name: t("fileExplorer.upload"), exact: true })
.click();
await (
await chooser
).setFiles({
name: "readme.txt",
mimeType: "text/plain",
buffer: Buffer.from(content),
});
}
for (const action of ["duplicateSkip", "duplicateOverwrite"]) {
const response = page.waitForResponse((response) =>
response.url().includes(`/sessions/${sessionId}/upload?`),
);
await upload("browser upload\n");
await page
.getByRole("button", { name: t(`fileTransfer.${action}`), exact: true })
.click();
const result = await (await response).json();
expect(result.status).toBe(
action === "duplicateSkip" ? "skipped" : "completed",
);
expect(result.path).toBe("/home/test/readme.txt");
const saved = await command("open_remote_file_text", {
sessionId,
path: result.path,
});
expect(saved.file.content).toBe(
action === "duplicateSkip"
? "edited through Chromium\n"
: "browser upload\n",
);
}
const current = await command("get_app_settings");
current.transfer.duplicate_strategy = "rename";
await command("save_app_settings", { settings: current });
const renamedResponse = page.waitForResponse((response) =>
response.url().includes(`/sessions/${sessionId}/upload?`),
);
await upload("renamed browser upload\n");
const renamed = await (await renamedResponse).json();
expect(renamed.path).toMatch(/^\/home\/test\/readme\.txt\.[0-9a-f-]{36}$/);
expect(
(
await command("open_remote_file_text", {
sessionId,
path: renamed.path,
})
).file.content,
).toBe("renamed browser upload\n");
const downloaded = page.waitForEvent("download");
const readmeRow = page.getByRole("listitem").filter({
has: page.getByText("readme.txt", { exact: true }),
});
await expect(readmeRow).toHaveCount(1);
// Click the visible filename, not the center of an overflowing grid row.
await readmeRow.getByText("readme.txt", { exact: true }).click();
await page
.getByRole("button", {
name: t("fileExplorer.downloadSelected"),
exact: true,
})
.click();
const textDownload = await downloaded;
expect(textDownload.suggestedFilename()).toBe("readme.txt");
expect(readFileSync(await textDownload.path()).toString()).toBe(
"browser upload\n",
);
await page
.getByRole("button", { name: t("settings.title"), exact: true })
.click();
const child = page.frameLocator('iframe[src*="window=settings"]');
await child
.getByRole("button", { name: t("settings.transfer"), exact: true })
.click();
await expect(child.getByRole("combobox")).toHaveCount(2);
await expect(
child.getByText(t("settings.downloadPath"), { exact: true }),
).toHaveCount(0);
await child.getByRole("combobox").nth(1).click();
await child
.getByRole("option", {
name: t("settings.internalEditorDisplayWindow"),
exact: true,
})
.click();
await child
.getByRole("button", { name: t("common.confirm"), exact: true })
.click();
await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0);
await page
.getByText(renamed.path.split("/").pop(), { exact: true })
.first()
.dblclick();
const fileChild = page.frameLocator('iframe[src*="window=file-editor"]');
await expect(fileChild.locator(".cm-content")).toContainText(
"renamed browser upload",
);
await fileChild
.getByRole("button", { name: t("common.close"), exact: true })
.last()
.click();
await expect(page.locator('iframe[src*="window=file-editor"]')).toHaveCount(
0,
);
await page.getByText(t("menu.file"), { exact: true }).first().click();
await page.getByText(t("web.signOut"), { exact: true }).click();
const quit = page.getByRole("button", {
name: t("dialog.confirmCloseAction"),
exact: true,
});
if (await quit.isVisible()) await quit.click();
await expect(page.getByLabel(t("web.password"))).toBeVisible();
expect(
(await page.request.get(new URL("api/auth/session", base).href)).status(),
).toBe(401);
expect(errors).toEqual([]);
} finally {
await testInfo.attach("browser-errors", {
body: errors.join("\n"),
contentType: "text/plain",
});
}
});
+40
View File
@@ -0,0 +1,40 @@
// Docker network changes can invalidate Chromium's first module requests.
// Retry only that transport failure, before any login or application actions.
export async function gotoLogin(page, url) {
for (let attempt = 0; attempt < 3; attempt++) {
let networkChanged = false;
const pageErrors = [];
const onRequestFailed = (request) => {
if (request.failure()?.errorText === "net::ERR_NETWORK_CHANGED")
networkChanged = true;
};
const onPageError = (error) => pageErrors.push(error);
page.on("requestfailed", onRequestFailed);
page.on("pageerror", onPageError);
try {
await page.goto(url);
await page.locator("#web-password").waitFor({
state: "visible",
timeout: 10_000,
});
if (pageErrors.length)
throw new AggregateError(pageErrors, "Errors during login navigation");
return;
} catch (error) {
networkChanged ||= String(error).includes("net::ERR_NETWORK_CHANGED");
const unexpectedError = pageErrors.some(
(entry) =>
!String(entry).includes(
"Failed to fetch dynamically imported module",
),
);
if (!networkChanged || unexpectedError || attempt === 2) throw error;
console.warn(
`Retrying login navigation after ERR_NETWORK_CHANGED (${attempt + 1}/2)`,
);
} finally {
page.off("requestfailed", onRequestFailed);
page.off("pageerror", onPageError);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: process.env.NYATERM_E2E_FEATURES_ONLY
? "web-features.spec.mjs"
: ["deployment.spec.mjs", "web-features.spec.mjs"],
timeout: 90_000,
expect: { timeout: 15_000 },
workers: 1,
retries: 0,
outputDir:
process.env.NYATERM_E2E_ARTIFACTS ?? "../artifacts/web-e2e/results",
reporter: [["list"]],
use: {
baseURL: process.env.NYATERM_E2E_URL ?? "http://localhost:18080/",
browserName: "chromium",
viewport: { width: 1440, height: 1000 },
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
});
+10
View File
@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \
&& rm -rf /var/lib/apt/lists/* && useradd --create-home --shell /bin/bash test \
&& mkdir -p /run/sshd && rm -f /etc/ssh/ssh_host_* \
&& printf 'PasswordAuthentication yes\nPermitRootLogin no\nUsePAM no\nSubsystem sftp internal-sftp\n' > /etc/ssh/sshd_config
COPY start.sh /start.sh
RUN sed -i 's/\r$//' /start.sh && chmod +x /start.sh
VOLUME ["/home/test"]
EXPOSE 22
ENTRYPOINT ["/start.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
printf 'test:%s\n' "$(cat /run/secrets/ssh-password)" | chpasswd
ssh-keygen -A
printf 'fixture text\n' > /home/test/readme.txt
printf '\000\001\002\003' > /home/test/binary.bin
mkdir -p /home/test/folder
chown -R test:test /home/test
exec /usr/sbin/sshd -D -e
+348
View File
@@ -0,0 +1,348 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { gotoLogin } from "./loginNavigation.mjs";
const locale = (language) =>
JSON.parse(
readFileSync(
new URL(`../src/i18n/locales/${language}.json`, import.meta.url),
"utf8",
),
);
const en = locale("en");
const t = (key, dictionary = en) =>
key.split(".").reduce((value, part) => value[part], dictionary);
test("login layouts in four languages, light/dark themes and narrow screens", async ({
browser,
}, testInfo) => {
for (const language of ["en", "zh-CN", "zh-TW", "ko"]) {
for (const theme of ["github-light", "github-dark"]) {
const context = await browser.newContext({
viewport: { width: 360, height: 760 },
locale: language,
});
await context.addInitScript(
({ language, theme }) => {
localStorage.setItem("nyaterm-web-language", language);
localStorage.setItem("df-theme-id", theme);
},
{ language, theme },
);
const page = await context.newPage();
await gotoLogin(page, process.env.NYATERM_E2E_URL);
const password = page.getByLabel(t("web.password", locale(language)), {
exact: true,
});
await expect(password).toBeEnabled();
await expect(password).toBeFocused();
await expect(
page.getByRole("heading", { name: t("web.welcome", locale(language)) }),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await expect(
page.locator('img[src$="icons/app/nyaterm.svg"]'),
).toBeVisible();
const image = await page.screenshot({
path: testInfo.outputPath(`login-${language}-${theme}-360px.png`),
});
await testInfo.attach(`login-${language}-${theme}-360px`, {
body: image,
contentType: "image/png",
});
await context.close();
}
}
});
test("password errors, keyboard login, SSE retry, portable backup and diagnostic downloads", async ({
page,
}) => {
if (!process.env.NYATERM_E2E_PASSWORD)
throw new Error("Run scripts/web-local-e2e.mjs or pnpm test:web:e2e");
const base = process.env.NYATERM_E2E_URL;
const errors = [];
await gotoLogin(page, base);
page.on("pageerror", (error) => errors.push(String(error)));
const password = page.getByLabel(t("web.password"), { exact: true });
await expect(password).toBeEnabled();
await password.fill("incorrect-password");
await password.press("Enter");
await expect(page.getByRole("alert")).toHaveText(t("web.invalidPassword"));
await page.getByRole("button", { name: t("web.showPassword") }).click();
await expect(password).toHaveAttribute("type", "text");
await page.getByRole("button", { name: t("web.hidePassword") }).click();
// Faults are injected only at the browser transport; login and restore use the real server.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 429,
contentType: "application/json",
body: JSON.stringify({ error: "Limited" }),
}),
);
await password.press("Enter");
await expect(page.getByRole("alert")).toHaveText(t("web.signInRateLimited"));
await page.unroute("**/api/auth/login");
await page.route("**/api/auth/login", (route) =>
route.abort("internetdisconnected"),
);
await password.press("Enter");
await expect(page.getByRole("alert")).toHaveText(t("web.serverUnavailable"));
await page.unroute("**/api/auth/login");
await page.route("**/api/events", (route) =>
route.abort("internetdisconnected"),
);
let loginCount = 0;
page.on("request", (request) => {
if (request.url().endsWith("/api/auth/login")) loginCount++;
});
await password.fill(process.env.NYATERM_E2E_PASSWORD);
await password.press("Enter");
await expect(page.getByRole("alert")).toHaveText(
t("web.eventConnectionFailed"),
);
await page.unroute("**/api/events");
await page.getByRole("button", { name: t("web.retryConnection") }).click();
await expect(password).toHaveCount(0);
expect(loginCount).toBe(1);
await page.reload();
await expect(
page.getByText(t("menu.file"), { exact: true }).first(),
).toBeVisible();
expect(loginCount).toBe(1);
const auth = await (
await page.request.get(new URL("api/auth/session", base).href)
).json();
const headers = {
Origin: new URL(base).origin,
"X-Nyaterm-Request": "1",
"X-Nyaterm-Csrf": auth.csrf,
};
const command = async (name, args = {}) => {
const id = randomUUID();
const response = await page.request.post(
new URL(`api/commands/${name}`, base).href,
{ headers: { ...headers, "X-Nyaterm-Request-Id": id }, data: args },
);
expect(response.headers()["x-nyaterm-request-id"]).toBe(id);
expect(response.ok(), `${name}: ${await response.text()}`).toBe(true);
return response.json();
};
await command("save_password", {
entry: {
id: "web-e2e-password",
name: "Web backup fixture",
password: "portable-secret",
},
});
const menu = async (key) => {
await page.getByText(t("menu.file"), { exact: true }).first().click();
await page.getByText(t(key), { exact: true }).click();
};
await menu("settings.exportConfig");
await page
.getByLabel(t("web.backupPassword"), { exact: true })
.fill("web-e2e-backup-password");
await page
.getByLabel(t("web.backupPasswordConfirm"), { exact: true })
.fill("different");
await expect(
page.getByRole("button", { name: t("web.backupExportTitle"), exact: true }),
).toBeDisabled();
await page
.getByLabel(t("web.backupPasswordConfirm"), { exact: true })
.fill("web-e2e-backup-password");
const download = page.waitForEvent("download");
await page
.getByRole("button", { name: t("web.backupExportTitle"), exact: true })
.click();
const backup = await download;
expect(backup.suggestedFilename()).toMatch(/\.nya$/);
const backupBytes = readFileSync(await backup.path());
expect(backupBytes.includes(Buffer.from("portable-secret"))).toBe(false);
await command("delete_password", { id: "web-e2e-password" });
await menu("settings.importConfig");
await page.getByRole("button", { name: /NyaTerm \(\.nya\)/ }).click();
const chooser = page.waitForEvent("filechooser");
await page
.getByRole("button", {
name: t("savedConnections.restoreBackupConfirmAction"),
exact: true,
})
.click();
await (
await chooser
).setFiles({
name: "fixture.nya",
mimeType: "application/octet-stream",
buffer: backupBytes,
});
await page
.getByLabel(t("web.backupPassword"), { exact: true })
.fill("wrong-password");
await page
.getByLabel(t("web.backupOverwriteConfirm"), { exact: true })
.check();
await page
.getByRole("button", { name: t("web.backupImportTitle"), exact: true })
.click();
await expect(page.getByRole("alert")).toContainText(
t("web.backupImportFailed"),
);
await page
.getByLabel(t("web.backupPassword"), { exact: true })
.fill("web-e2e-backup-password");
await page
.getByRole("button", { name: t("web.backupImportTitle"), exact: true })
.click();
await expect(
page.getByLabel(t("web.backupPassword"), { exact: true }),
).toHaveCount(0);
expect(
await command("get_saved_password_value", { id: "web-e2e-password" }),
).toBe("portable-secret");
await command("save_quick_commands", {
config: {
commands: [
{
id: "web-quick-fixture",
label: "Web quick fixture",
command: "printf fixture",
},
],
categories: [],
},
});
const settings = await command("get_app_settings");
settings.ui.active_right_panel = "quickCommands";
settings.ui.right_open_panels = ["quickCommands"];
await command("save_app_settings", { settings });
await page.reload();
await expect(
page.getByText("Web quick fixture", { exact: true }),
).toBeVisible();
const quickDownload = page.waitForEvent("download");
await page
.getByRole("button", { name: t("quickCommands.export"), exact: true })
.click();
const quickBytes = readFileSync(await (await quickDownload).path());
expect(
JSON.parse(quickBytes.toString()).commands.some(
(entry) => entry.id === "web-quick-fixture",
),
).toBe(true);
await command("save_quick_commands", {
config: { commands: [], categories: [] },
});
await page
.getByRole("button", { name: t("quickCommands.import"), exact: true })
.click();
const quickChooser = page.waitForEvent("filechooser");
await page
.getByRole("button", {
name: new RegExp(t("quickCommands.importNyaTermJson")),
})
.click();
await (
await quickChooser
).setFiles({
name: "quick.json",
mimeType: "application/json",
buffer: quickBytes,
});
await expect
.poll(async () =>
(await command("get_quick_commands")).commands.some(
(entry) => entry.id === "web-quick-fixture",
),
)
.toBe(true);
await page
.getByRole("button", { name: t("settings.title"), exact: true })
.click();
const child = page.frameLocator('iframe[src*="window=settings"]');
await child
.getByRole("button", { name: t("settings.general"), exact: true })
.first()
.click();
await expect(
child.getByRole("button", { name: t("settings.openLogs"), exact: true }),
).toHaveCount(0);
const diagnosticDownload = page.waitForEvent("download");
await child
.getByRole("button", { name: t("settings.exportDiagnostics"), exact: true })
.click();
expect(
readFileSync(await (await diagnosticDownload).path())
.subarray(0, 2)
.toString(),
).toBe("PK");
await child
.getByRole("button", { name: t("settings.appearance"), exact: true })
.first()
.click();
await child
.getByRole("button", { name: t("settings.themeDesignerOpen"), exact: true })
.click();
await child
.getByRole("button", {
name: t("settings.themeDesignerCopyTheme"),
exact: true,
})
.click();
const themeDownload = page.waitForEvent("download");
await child
.getByRole("button", {
name: t("settings.themeDesignerExport"),
exact: true,
})
.click();
const themeData = JSON.parse(
readFileSync(await (await themeDownload).path()).toString(),
);
themeData.name = "Web theme fixture";
const themeChooser = page.waitForEvent("filechooser");
await child
.getByRole("button", {
name: t("settings.themeDesignerImport"),
exact: true,
})
.click();
await (
await themeChooser
).setFiles({
name: "theme.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(themeData)),
});
await expect
.poll(async () =>
(await command("get_app_settings")).appearance.custom_themes.some(
(theme) => theme.name === "Web theme fixture",
),
)
.toBe(true);
await child
.getByRole("dialog")
.getByRole("button", { name: t("common.close"), exact: true })
.click();
await child
.getByRole("button", { name: t("common.confirm"), exact: true })
.click();
await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0);
await page.reload();
await expect(
page.getByText(t("menu.file"), { exact: true }).first(),
).toBeVisible();
expect(errors).toEqual([]);
});
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" sizes="any" href="%BASE_URL%icons/app/nyaterm.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NyaTerm</title>
</head>
+9 -1
View File
@@ -41,7 +41,13 @@
"fmt:rust:check": "cargo fmt --manifest-path src-tauri/Cargo.toml -- --check",
"i18n:check": "prettier --check src/i18n/locales/*.json",
"i18n:fix": "prettier --write src/i18n/locales/*.json",
"postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs && node scripts/patch-xterm-wide-char-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs"
"postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs && node scripts/patch-xterm-wide-char-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs",
"build:web": "tsc && vite build",
"web:build:server": "cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked",
"web:serve": "cargo run --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked",
"test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked",
"i18n:keys": "node scripts/check-i18n-keys.mjs",
"test:web:e2e": "node scripts/web-e2e.mjs"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
@@ -91,6 +97,7 @@
"cmdk": "^1.1.1",
"i18next": "^25.8.11",
"input-otp": "^1.4.2",
"jsqr": "^1.4.0",
"lucide-react": "^0.575.0",
"motion": "^12.34.3",
"next-themes": "^0.4.6",
@@ -111,6 +118,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/react": "^16.3.2",
+36
View File
@@ -149,6 +149,9 @@ importers:
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
jsqr:
specifier: ^1.4.0
version: 1.4.0
lucide-react:
specifier: ^0.575.0
version: 0.575.0(react@19.2.4)
@@ -204,6 +207,9 @@ importers:
'@biomejs/biome':
specifier: ^2.4.2
version: 2.4.2
'@playwright/test':
specifier: ^1.63.0
version: 1.63.0
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0))
@@ -3125,6 +3131,11 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -6590,6 +6601,9 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsqr@1.4.0:
resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -7560,6 +7574,16 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
playwright@1.63.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
postcss-attribute-case-insensitive@7.0.1:
resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==}
engines: {node: '>=18'}
@@ -13409,6 +13433,10 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
'@playwright/test@1.63.0':
dependencies:
playwright: 1.63.0
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -17057,6 +17085,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsqr@1.4.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -18218,6 +18248,12 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
playwright-core@1.63.0: {}
playwright@1.63.0:
dependencies:
playwright-core: 1.63.0
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
dependencies:
postcss: 8.5.6
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
function keys(value, prefix = "") {
return Object.entries(value)
.flatMap(([key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof child === "object" && child !== null
? keys(child, path)
: [path];
})
.map((key) => key.replace(/_(zero|one|two|few|many|other|plural)$/, ""))
.filter((key, index, all) => all.indexOf(key) === index)
.sort();
}
const locales = ["en", "zh-CN", "zh-TW", "ko"];
const reference = keys(
JSON.parse(readFileSync(`src/i18n/locales/en.json`, "utf8")),
);
for (const locale of locales.slice(1)) {
const current = keys(
JSON.parse(readFileSync(`src/i18n/locales/${locale}.json`, "utf8")),
);
const missing = reference.filter((key) => !current.includes(key));
const extra = current.filter((key) => !reference.includes(key));
if (missing.length || extra.length)
throw new Error(
`${locale}: missing ${missing.join(", ")}; extra ${extra.join(", ")}`,
);
}
console.log(
`All four locales have the same ${reference.length} translation keys.`,
);
+194
View File
@@ -0,0 +1,194 @@
import { spawnSync } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { resolve, join, relative, basename } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const id = `nyaterm-e2e-${randomUUID().slice(0, 8)}`;
const secrets = mkdtempSync(join(tmpdir(), `${id}-`));
const artifacts = resolve("artifacts/web-e2e");
mkdirSync(artifacts, { recursive: true });
const password = randomBytes(32).toString("base64");
const sshPassword = randomBytes(32).toString("base64");
for (const [name, value] of Object.entries({
login: password,
encryption: randomBytes(32).toString("base64"),
"ssh-password": sshPassword,
})) {
writeFileSync(join(secrets, name), value, { mode: 0o644 });
}
chmodSync(secrets, 0o755); // Container UID 10001 must read only these disposable credentials.
const docker = (args, capture = false, optional = false) => {
const result = spawnSync("docker", args, {
encoding: "utf8",
stdio: capture ? "pipe" : "inherit",
});
if (!optional && (result.error || result.status !== 0))
throw new Error(`Docker ${args[0]} failed`);
return (
(result.stdout ?? "") + (args[0] === "logs" ? (result.stderr ?? "") : "")
);
};
const mount = (name, target) => [
"--mount",
`type=bind,src=${join(secrets, name)},dst=/run/secrets/${target},readonly`,
];
const failures = [];
const containers = [],
volumes = [],
images = [];
let networkCreated = false;
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = server.address().port;
await new Promise((resolve) => server.close(resolve));
return port;
}
try {
docker(["version"], true);
docker(["network", "create", id]);
networkCreated = true;
docker(["build", "-t", `${id}-ssh`, "e2e/ssh"]);
images.push(`${id}-ssh`);
for (const [name, basePath] of [
["root", "/"],
["subpath", "/nyaterm/"],
]) {
const output = join(artifacts, name);
mkdirSync(output, { recursive: true });
const image = `${id}-${name}`;
docker([
"build",
"-f",
"deploy/web/Dockerfile",
"--build-arg",
`NYATERM_WEB_BASE_PATH=${basePath}`,
"-t",
image,
".",
]);
images.push(image);
const ssh = `${id}-${name}-ssh`,
web = `${id}-${name}-web`;
const sshVolume = `${ssh}-data`,
webVolume = `${web}-data`;
for (const volume of [sshVolume, webVolume]) {
docker(["volume", "create", volume]);
volumes.push(volume);
}
containers.push(ssh, web);
docker([
"run",
"-d",
"--name",
ssh,
"--network",
id,
"--network-alias",
"ssh-fixture",
...mount("ssh-password", "ssh-password"),
"--mount",
`type=volume,src=${sshVolume},dst=/home/test`,
`${id}-ssh`,
]);
const port = await freePort();
const url = `http://localhost:${port}${basePath}`;
docker([
"run",
"-d",
"--init",
"--name",
web,
"--network",
id,
"-p",
`127.0.0.1:${port}:8080`,
"-e",
`NYATERM_WEB_PUBLIC_URL=${url}`,
"-e",
"NYATERM_WEB_PASSWORD_FILE=/run/secrets/login",
"-e",
"NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption",
...mount("login", "login"),
...mount("encryption", "encryption"),
"--mount",
`type=volume,src=${webVolume},dst=/data`,
"--read-only",
"--tmpfs",
"/tmp",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges:true",
image,
]);
try {
const deadline = Date.now() + 60_000;
for (;;) {
try {
if ((await fetch(url, { signal: AbortSignal.timeout(2000) })).ok)
break;
} catch {}
if (Date.now() >= deadline) throw new Error("Web readiness timed out");
await delay(500);
}
const result = spawnSync(
process.execPath,
[
"node_modules/@playwright/test/cli.js",
"test",
"--config",
"e2e/playwright.config.mjs",
],
{
stdio: "inherit",
env: {
...process.env,
NYATERM_E2E_URL: url,
NYATERM_E2E_PASSWORD: password,
NYATERM_E2E_SSH_PASSWORD: sshPassword,
NYATERM_E2E_ARTIFACTS: join(output, "results"),
},
},
);
if (result.error || result.status !== 0)
throw new Error(`Deployment acceptance failed for ${basePath}`);
} catch (error) {
failures.push(error);
} finally {
for (const container of [ssh, web])
writeFileSync(
join(output, `${container.endsWith("ssh") ? "ssh" : "web"}.log`),
docker(["logs", container], true, true),
);
docker(["rm", "-f", web, ssh], true, true);
}
}
if (failures.length)
throw new AggregateError(failures, "Deployment acceptance failed");
} finally {
for (const container of containers)
docker(["rm", "-f", container], true, true);
for (const volume of volumes) docker(["volume", "rm", volume], true, true);
if (networkCreated) docker(["network", "rm", id], true, true);
for (const image of images) docker(["image", "rm", image], true, true);
const secretRelative = relative(resolve(tmpdir()), resolve(secrets));
if (
secretRelative.startsWith("..") ||
!basename(secrets).startsWith(`${id}-`)
)
throw new Error("Refusing to remove an unexpected secrets directory");
rmSync(secrets, { recursive: true, force: true });
}
+93
View File
@@ -0,0 +1,93 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createServer } from "node:net";
import { setTimeout as delay } from "node:timers/promises";
const listener = createServer();
await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve));
const port = listener.address().port;
await new Promise((resolve) => listener.close(resolve));
const data = mkdtempSync(join(tmpdir(), "nyaterm-local-e2e-"));
const artifacts = resolve(
"artifacts/web-local-e2e",
process.env.NYATERM_LOCAL_E2E_BASE_PATH ? "subpath" : "root",
);
mkdirSync(artifacts, { recursive: true });
const basePath = process.env.NYATERM_LOCAL_E2E_BASE_PATH ?? "/";
const base = `http://127.0.0.1:${port}${basePath}`;
const password = randomBytes(32).toString("base64");
const env = {
...process.env,
NYATERM_WEB_PASSWORD: password,
NYATERM_WEB_ENCRYPTION_KEY: randomBytes(32).toString("base64"),
NYATERM_WEB_DATA_DIR: data,
NYATERM_WEB_DIST: resolve("dist"),
NYATERM_WEB_PUBLIC_URL: base,
NYATERM_WEB_BIND: `127.0.0.1:${port}`,
};
delete env.NYATERM_WEB_PASSWORD_FILE;
delete env.NYATERM_WEB_ENCRYPTION_KEY_FILE;
const exe = resolve(
"src-tauri/crates/nyaterm-web/target/debug/nyaterm-web" +
(process.platform === "win32" ? ".exe" : ""),
);
const server = spawn(exe, [], {
env,
stdio: ["ignore", "ignore", "inherit"],
windowsHide: true,
});
let serverError;
server.on("error", (error) => {
serverError = error;
});
try {
let ready = false;
for (let attempt = 0; attempt < 200; attempt++) {
if (serverError) throw serverError;
if (server.exitCode !== null)
throw new Error("Local Web server exited during startup");
try {
if ((await fetch(base)).ok) {
ready = true;
break;
}
} catch {}
await delay(50);
}
if (!ready) throw new Error("Local Web server was not ready");
const test = spawn(
process.execPath,
[
"node_modules/@playwright/test/cli.js",
"test",
"--config",
"e2e/playwright.config.mjs",
],
{
env: {
...process.env,
NYATERM_E2E_FEATURES_ONLY: "1",
NYATERM_E2E_URL: base,
NYATERM_E2E_PASSWORD: password,
NYATERM_E2E_ARTIFACTS: artifacts,
},
stdio: "inherit",
windowsHide: true,
},
);
const code = await new Promise((resolve, reject) => {
test.once("error", reject);
test.once("exit", resolve);
});
if (code !== 0) process.exitCode = 1;
} finally {
if (server.exitCode === null && !serverError) {
const exited = new Promise((resolve) => server.once("exit", resolve));
server.kill();
await exited;
}
rmSync(data, { recursive: true, force: true });
}
+51
View File
@@ -4944,6 +4944,7 @@ dependencies = [
"md-5 0.10.6",
"notify",
"nucleo-matcher",
"nyaterm-core",
"nyaterm-gpu",
"nyaterm-mcp-protocol",
"nyaterm-otp",
@@ -5002,6 +5003,56 @@ dependencies = [
"zmodem2",
]
[[package]]
name = "nyaterm-core"
version = "0.1.0"
dependencies = [
"aes 0.8.4",
"aes-gcm 0.10.3",
"async-http-proxy",
"base64 0.22.1",
"cbc 0.1.2",
"crypto_secretbox",
"dirs",
"encoding_rs",
"font-kit",
"futures-util",
"genai",
"hex",
"hmac 0.12.1",
"keyring",
"md-5 0.10.6",
"nucleo-matcher",
"nyaterm-gpu",
"pbkdf2 0.12.2",
"quick-xml 0.40.1",
"rand 0.8.8",
"redb",
"regex",
"reqwest 0.13.4",
"russh",
"russh-sftp",
"rusty-leveldb",
"serde",
"serde_json",
"sha1 0.10.7",
"sha2 0.10.9",
"sha3 0.10.9",
"ssh-key 0.6.7",
"strip-ansi-escapes",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-socks",
"tokio-util",
"tracing",
"urlencoding",
"uuid",
"vnc-rs",
"zeroize",
"zip 8.6.0",
]
[[package]]
name = "nyaterm-gpu"
version = "0.1.0"
+3 -1
View File
@@ -82,15 +82,17 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dev-dependencies]
nyaterm-core = { path = "crates/nyaterm-core", features = ["test-support"] }
tokio = { version = "1", features = ["test-util"] }
[dependencies]
nyaterm-core = { path = "crates/nyaterm-core", features = ["desktop-importer"] }
tempfile = "3"
nyaterm-gpu = { path = "crates/nyaterm-gpu" }
nyaterm-otp = { path = "crates/otp" }
nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" }
nyaterm-plugin-runtime = { path = "crates/nyaterm-plugin-runtime" }
tauri = { version = "2", features = ["tray-icon", "protocol-asset"] }
tauri = { version = "2", features = ["tray-icon", "protocol-asset", "macos-private-api"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
[package]
name = "nyaterm-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
[features]
test-support = []
desktop-importer = ["dep:keyring", "dep:crypto_secretbox", "dep:rusty-leveldb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
thiserror = "2"
redb = "4.1.0"
aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
hex = "0.4"
md-5 = "0.10"
urlencoding = "2"
rand = "0.8"
nyaterm-gpu = { path = "../nyaterm-gpu" }
zip = "8"
dirs = "6"
uuid = { version = "1", features = ["v4"] }
font-kit = "0.14.3"
nucleo-matcher = "0.3"
regex = "1"
tracing = "0.1"
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
russh = { path = "../../vendor/russh/russh", default-features = false, features = ["des", "dsa", "flate2", "ring", "rsa"] }
russh-sftp = { path = "../../vendor/russh-sftp" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time", "fs"] }
tokio-util = { version = "0.7", features = ["rt", "io"] }
futures-util = "0.3"
zeroize = "1"
time = { version = "0.3", features = ["formatting", "macros"] }
genai = { version = "0.6.5", default-features = false, features = ["native-tls"] }
reqwest = { version = "0.13", default-features = false, features = ["socks", "json", "form", "query", "native-tls"] }
encoding_rs = "0.8"
strip-ansi-escapes = "0.2"
vnc-rs = { path = "../../vendor/vnc-rs" }
tokio-socks = "0.5.2"
async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] }
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha3 = "0.10"
quick-xml = "0.40"
crypto_secretbox = { version = "0.1", optional = true }
rusty-leveldb = { version = "4.0", optional = true }
keyring = { version = "3.6", optional = true, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
[dev-dependencies]
tempfile = "3"
@@ -3,7 +3,6 @@ use crate::error::AppResult;
use crate::storage::{self, SettingsDocKey};
use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
pub const MASKED_SECRET_VALUE: &str = "__SET__";
@@ -345,12 +344,12 @@ fn default_conflict_kind() -> String {
"content_conflict".to_string()
}
pub fn load_cloud_sync_settings(app: &AppHandle) -> AppResult<CloudSyncSettings> {
pub fn load_cloud_sync_settings(app: &impl Sized) -> AppResult<CloudSyncSettings> {
let _ = app;
storage::load_settings_doc(SettingsDocKey::CloudSyncSettings)
}
pub fn load_cloud_sync_state(app: &AppHandle) -> AppResult<CloudSyncState> {
pub fn load_cloud_sync_state(app: &impl Sized) -> AppResult<CloudSyncState> {
let _ = app;
let mut state: CloudSyncState = storage::load_settings_doc(SettingsDocKey::CloudSyncState)?;
if state.device_id.is_empty() {
@@ -359,7 +358,7 @@ pub fn load_cloud_sync_state(app: &AppHandle) -> AppResult<CloudSyncState> {
Ok(state)
}
pub fn save_cloud_sync_state(app: &AppHandle, state: &CloudSyncState) -> AppResult<()> {
pub fn save_cloud_sync_state(app: &impl Sized, state: &CloudSyncState) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(SettingsDocKey::CloudSyncState, state)
}
@@ -8,7 +8,6 @@ use crate::storage;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use tauri::AppHandle;
// ── Connection type discriminator ───────────────────────────────────────────
@@ -1341,7 +1340,7 @@ pub fn connection_custom_icon_from_data_url(
// ── Loading / saving ────────────────────────────────────────────────────────
pub fn load_sessions(app: &AppHandle) -> AppResult<SessionsConfig> {
pub fn load_sessions(app: &impl Sized) -> AppResult<SessionsConfig> {
let mut config = storage::load_sessions()?;
let mut migrated = false;
for connection in &mut config.connections {
@@ -1355,7 +1354,7 @@ pub fn load_sessions(app: &AppHandle) -> AppResult<SessionsConfig> {
}
/// Saves sessions config to disk.
pub fn save_sessions(app: &AppHandle, config: &SessionsConfig) -> AppResult<()> {
pub fn save_sessions(app: &impl Sized, config: &SessionsConfig) -> AppResult<()> {
let _ = app;
let mut sanitized = config.clone();
for conn in &mut sanitized.connections {
@@ -1390,7 +1389,7 @@ pub fn save_sessions(app: &AppHandle, config: &SessionsConfig) -> AppResult<()>
/// Loads the main app config (sessions + groups).
/// Also runs one-time migrations.
pub fn load_config(app: &AppHandle) -> AppResult<AppConfig> {
pub fn load_config(app: &impl Sized) -> AppResult<AppConfig> {
let mut cfg = load_sessions(app)?;
migrate_global_proxy_to_connections(app, &mut cfg)?;
@@ -1401,7 +1400,10 @@ pub fn load_config(app: &AppHandle) -> AppResult<AppConfig> {
// ── Config migrations ───────────────────────────────────────────────────────
fn migrate_global_proxy_to_connections(app: &AppHandle, cfg: &mut SessionsConfig) -> AppResult<()> {
fn migrate_global_proxy_to_connections(
app: &impl Sized,
cfg: &mut SessionsConfig,
) -> AppResult<()> {
let mut settings = load_app_settings(app)?;
if !settings.proxy.enabled || cfg.connections.is_empty() {
return Ok(());
@@ -1446,7 +1448,7 @@ fn migrate_global_proxy_to_connections(app: &AppHandle, cfg: &mut SessionsConfig
}
fn migrate_connection_proxies_to_standalone(
_app: &AppHandle,
_app: &impl Sized,
_cfg: &mut SessionsConfig,
) -> AppResult<()> {
// Legacy `network.proxy` inline objects are no longer present in the new format.
@@ -1456,7 +1458,7 @@ fn migrate_connection_proxies_to_standalone(
/// Returns the effective encoding for a connection: per-connection override if set,
/// otherwise the global default from `interaction.default_encoding`.
pub fn resolve_connection_encoding(app: &AppHandle, conn: &SavedConnection) -> String {
pub fn resolve_connection_encoding(app: &impl Sized, conn: &SavedConnection) -> String {
let per_conn = match &conn.config {
ConnectionType::Ssh { encoding, .. }
| ConnectionType::LocalTerminal { encoding, .. }
@@ -1475,7 +1477,7 @@ pub fn resolve_connection_encoding(app: &AppHandle, conn: &SavedConnection) -> S
/// Loads a single connection by ID.
///
/// Returns `AppError::SessionNotFound` if no connection with that ID exists.
pub fn load_connection_by_id(app: &AppHandle, id: &str) -> AppResult<SavedConnection> {
pub fn load_connection_by_id(app: &impl Sized, id: &str) -> AppResult<SavedConnection> {
let mut conn = storage::get_connection(id)?
.ok_or_else(|| AppError::SessionNotFound(format!("Connection '{}' not found", id)))?;
let mut migrated = migrate_legacy_ssh_agent_settings(&mut conn);
@@ -1491,7 +1493,7 @@ pub fn load_connection_by_id(app: &AppHandle, id: &str) -> AppResult<SavedConnec
}
/// Saves the main app config.
pub fn save_config(app: &AppHandle, config: &AppConfig) -> AppResult<()> {
pub fn save_config(app: &impl Sized, config: &AppConfig) -> AppResult<()> {
save_sessions(app, config)
}
@@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult};
use crate::storage;
use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
fn default_enabled() -> bool {
true
@@ -38,7 +37,7 @@ pub struct CredentialsConfig {
pub credentials: Vec<SavedCredential>,
}
pub fn load_credentials(app: &AppHandle) -> AppResult<CredentialsConfig> {
pub fn load_credentials(app: &impl Sized) -> AppResult<CredentialsConfig> {
let _ = app;
let mut config = CredentialsConfig {
credentials: storage::list_credentials()?,
@@ -50,12 +49,12 @@ pub fn load_credentials(app: &AppHandle) -> AppResult<CredentialsConfig> {
Ok(config)
}
pub fn save_credentials(app: &AppHandle, config: &CredentialsConfig) -> AppResult<()> {
pub fn save_credentials(app: &impl Sized, config: &CredentialsConfig) -> AppResult<()> {
let _ = app;
storage::replace_credentials(config)
}
pub fn load_credential_by_id(app: &AppHandle, id: &str) -> AppResult<SavedCredential> {
pub fn load_credential_by_id(app: &impl Sized, id: &str) -> AppResult<SavedCredential> {
let cfg = load_credentials(app)?;
let mut entry = cfg
.credentials
@@ -5,7 +5,6 @@ use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{RwLock, RwLockReadGuard};
use tauri::AppHandle;
static SSH_KEY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0);
static SSH_KEY_ACCESS: RwLock<()> = RwLock::new(());
@@ -57,7 +56,7 @@ fn apply_key_status_flags(key: &mut SshKey) {
key.has_cert_data = key.cert.is_some();
}
pub fn load_keys(app: &AppHandle) -> AppResult<KeysConfig> {
pub fn load_keys(app: &impl Sized) -> AppResult<KeysConfig> {
let _ = app;
let mut config = KeysConfig {
keys: storage::list_ssh_keys()?,
@@ -68,7 +67,7 @@ pub fn load_keys(app: &AppHandle) -> AppResult<KeysConfig> {
Ok(config)
}
pub fn save_keys(app: &AppHandle, config: &KeysConfig) -> AppResult<()> {
pub fn save_keys(app: &impl Sized, config: &KeysConfig) -> AppResult<()> {
let _ = app;
let _write_guard = SSH_KEY_ACCESS
.write()
@@ -83,7 +82,7 @@ pub fn save_keys(app: &AppHandle, config: &KeysConfig) -> AppResult<()> {
/// Forwarding brokers may cache parsed signing identities for one epoch. Signing must still
/// acquire [`ssh_key_read_guard`] and verify the epoch before using cached private
/// material, so a successful save or deletion invalidates every stale signer.
pub(crate) fn ssh_key_change_epoch() -> u64 {
pub fn ssh_key_change_epoch() -> u64 {
SSH_KEY_CHANGE_EPOCH.load(Ordering::SeqCst)
}
@@ -92,13 +91,13 @@ pub(crate) fn ssh_key_change_epoch() -> u64 {
/// Keep this guard only while reading the persistent snapshot or producing a
/// signature. PEM decryption and parsing deliberately happen after snapshot reads
/// release the guard, preventing slow key formats from blocking saves.
pub(crate) fn ssh_key_read_guard() -> RwLockReadGuard<'static, ()> {
pub fn ssh_key_read_guard() -> RwLockReadGuard<'static, ()> {
SSH_KEY_ACCESS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn load_key_by_id(app: &AppHandle, id: &str) -> AppResult<SshKey> {
pub fn load_key_by_id(app: &impl Sized, id: &str) -> AppResult<SshKey> {
let cfg = load_keys(app)?;
let mut key = cfg
.keys
@@ -0,0 +1,116 @@
//! Config persistence for sessions, UI, and quick commands.
//!
//! Stores typed entities and small singleton documents in `~/.nyaterm/nyaterm.redb`.
//! Credentials are AES-256-GCM encrypted in-place.
mod cloud_sync;
mod connection;
mod credential;
mod key;
mod note;
mod otp;
mod password;
mod proxy;
mod quick_command;
mod settings;
mod tunnel;
mod ui;
#[allow(unused_imports)]
pub use cloud_sync::{
CloudConflictPreview, CloudSyncHistoryEntry, CloudSyncSettings, CloudSyncState,
CloudSyncStatus, GiteeSnippetSyncSettings, MASKED_SECRET_VALUE, S3SyncSettings,
WebdavSyncSettings, decrypt_cloud_sync_settings, encrypt_cloud_sync_settings,
load_cloud_sync_settings, load_cloud_sync_state, mask_cloud_sync_settings,
merge_masked_cloud_sync_settings, save_cloud_sync_state,
};
#[allow(unused_imports)]
pub use connection::{
AiExecutionProfile, AppConfig, AssetAccelerator, AssetAcceleratorType, AssetDeviceType,
AssetDisk, AssetDiskKind, AssetDiskPurpose, AssetMetadata, ConnectionAuth,
ConnectionCustomIcon, ConnectionNetwork, ConnectionRecordingSettings, ConnectionType, Group,
MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS, MAX_SSH_AGENT_FORWARDING_ENDPOINTS,
MAX_SSH_AGENT_FORWARDING_IDENTITIES, MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, RdpClipboardSettings,
RdpDisplaySettings, RdpReconnectSettings, RdpSecuritySettings, SavedConnection,
SerialFlowControl, SerialModemUploadProtocol, SessionsConfig, SftpCwdFollowMode, SftpSettings,
SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy,
SshAgentForwardingSources, SshAlgorithmMode, SshAlgorithmPreferences, SshProfile,
SshRuntimeMode, SshTerminalType, TelnetAutoLoginConfig, VncClipboardSettings,
VncDisplaySettings, VncReconnectSettings, VncSecuritySettings,
connection_custom_icon_from_data_url, connection_custom_icon_id_for_data_url,
effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile,
effective_cwd_follow_mode_for_runtime, is_connection_custom_icon_data_url, load_config,
load_connection_by_id, load_sessions, migrate_legacy_asset_tags,
migrate_legacy_ssh_agent_settings, normalize_connection_tags, resolve_connection_encoding,
resolve_ssh_terminal_type, save_config, save_sessions, ssh_agent_endpoint_key,
validate_ssh_agent_endpoint, validate_ssh_agent_endpoint_shape,
validate_ssh_agent_forwarding_config, validate_ssh_agent_forwarding_shape,
validate_ssh_agent_settings,
};
#[allow(unused_imports)]
pub use credential::{
CredentialsConfig, SavedCredential, load_credential_by_id, load_credentials,
reorder_credentials, save_credentials, upsert_credential,
};
#[allow(unused_imports)]
pub use key::{
KeysConfig, SshKey, decrypt_key_cert, decrypt_key_pem, load_key_by_id, load_keys, save_keys,
};
pub use key::{ssh_key_change_epoch, ssh_key_read_guard};
#[allow(unused_imports)]
pub use note::{
DeleteNoteNodeResult, NoteDocument, NoteFolder, NoteNodeChange, NoteSummary, NoteTreePayload,
NoteUpdateResult, NotesChangedEvent, NotesSnapshot,
};
#[allow(unused_imports)]
pub use otp::{OtpConfig, OtpEntry, load_otp_entries, load_otp_entry_by_id, save_otp_entries};
#[allow(unused_imports)]
pub use password::{
PasswordsConfig, SavedPassword, decrypt_account_password, effective_account_id,
load_password_by_id, load_passwords, load_saved_account, resolve_account_username,
save_passwords,
};
#[allow(unused_imports)]
pub use proxy::{
ProxyConfig, ProxyGroup, ProxyGroupsConfig, load_proxies, load_proxy_by_id, load_proxy_groups,
save_proxies, save_proxy_groups,
};
#[allow(unused_imports)]
pub use quick_command::{
QuickCommand, QuickCommandCategory, QuickCommandsConfig, load_quick_commands,
save_quick_commands,
};
#[allow(unused_imports)]
pub use settings::{
AI_REQUEST_USER_AGENT_DEFAULT, ActionLinksMatcherSettings, AgentCommandExecutionMode,
AiAgentKind, AiApiFormat, AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem,
AiModelSource, AiPermissionMode, AiProviderApiProtocol, AiProviderCredential, AiProviderKind,
AiProviderProfile, AiProxyMode, AiProxyProtocol, AiProxySettings, AiReasoningEffort,
AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings,
CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings,
ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings,
KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine,
SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig,
ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential,
ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings,
mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings,
};
#[allow(unused_imports)]
pub use tunnel::{
TunnelConfig, TunnelGroup, TunnelGroupsConfig, TunnelsConfig, load_tunnel_groups, load_tunnels,
save_tunnel_groups, save_tunnels,
};
#[allow(unused_imports)]
pub use ui::{ActivityBarLayout, RestorablePaneNode, RestorableTab, UiConfig};
pub fn uuid_v4() -> String {
uuid::Uuid::new_v4().to_string()
}
pub fn default_true() -> bool {
true
}
pub fn default_false() -> bool {
false
}
@@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult};
use crate::storage;
use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
fn default_digits() -> u8 {
6
@@ -50,7 +49,7 @@ pub struct OtpConfig {
pub entries: Vec<OtpEntry>,
}
pub fn load_otp_entries(app: &AppHandle) -> AppResult<OtpConfig> {
pub fn load_otp_entries(app: &impl Sized) -> AppResult<OtpConfig> {
let _ = app;
let mut config = OtpConfig {
entries: storage::list_otp_accounts()?,
@@ -61,12 +60,12 @@ pub fn load_otp_entries(app: &AppHandle) -> AppResult<OtpConfig> {
Ok(config)
}
pub fn save_otp_entries(app: &AppHandle, config: &OtpConfig) -> AppResult<()> {
pub fn save_otp_entries(app: &impl Sized, config: &OtpConfig) -> AppResult<()> {
let _ = app;
storage::replace_otp_accounts(config)
}
pub fn load_otp_entry_by_id(app: &AppHandle, id: &str) -> AppResult<OtpEntry> {
pub fn load_otp_entry_by_id(app: &impl Sized, id: &str) -> AppResult<OtpEntry> {
let cfg = load_otp_entries(app)?;
let mut entry = cfg
.entries
@@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult};
use crate::storage;
use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
/// Managed account entry. The password field is AES-256-GCM encrypted on disk.
///
@@ -38,7 +37,7 @@ pub fn effective_account_id<'a>(
/// Loads account metadata without decrypting its password. Missing references
/// are treated as unavailable so callers can apply protocol-specific fallback.
pub fn load_saved_account(
app: &AppHandle,
app: &impl Sized,
account_id: Option<&str>,
legacy_password_id: Option<&str>,
) -> AppResult<Option<SavedPassword>> {
@@ -72,7 +71,7 @@ pub struct PasswordsConfig {
pub passwords: Vec<SavedPassword>,
}
pub fn load_passwords(app: &AppHandle) -> AppResult<PasswordsConfig> {
pub fn load_passwords(app: &impl Sized) -> AppResult<PasswordsConfig> {
let _ = app;
let mut config = PasswordsConfig {
passwords: storage::list_passwords()?,
@@ -83,12 +82,12 @@ pub fn load_passwords(app: &AppHandle) -> AppResult<PasswordsConfig> {
Ok(config)
}
pub fn save_passwords(app: &AppHandle, config: &PasswordsConfig) -> AppResult<()> {
pub fn save_passwords(app: &impl Sized, config: &PasswordsConfig) -> AppResult<()> {
let _ = app;
storage::replace_passwords(config)
}
pub fn load_password_by_id(app: &AppHandle, id: &str) -> AppResult<SavedPassword> {
pub fn load_password_by_id(app: &impl Sized, id: &str) -> AppResult<SavedPassword> {
let cfg = load_passwords(app)?;
let mut entry = cfg
.passwords
@@ -2,7 +2,6 @@ use super::uuid_v4;
use crate::error::AppResult;
use crate::storage::{self, SettingsDocKey};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
fn default_protocol() -> String {
"socks5".to_string()
@@ -52,27 +51,27 @@ pub struct ProxyGroupsConfig {
pub groups: Vec<ProxyGroup>,
}
pub fn load_proxies(app: &AppHandle) -> AppResult<Vec<ProxyConfig>> {
pub fn load_proxies(app: &impl Sized) -> AppResult<Vec<ProxyConfig>> {
let _ = app;
storage::list_proxies()
}
pub fn save_proxies(app: &AppHandle, proxies: &[ProxyConfig]) -> AppResult<()> {
pub fn save_proxies(app: &impl Sized, proxies: &[ProxyConfig]) -> AppResult<()> {
let _ = app;
storage::replace_proxies(proxies)
}
pub fn load_proxy_by_id(app: &AppHandle, id: &str) -> AppResult<Option<ProxyConfig>> {
pub fn load_proxy_by_id(app: &impl Sized, id: &str) -> AppResult<Option<ProxyConfig>> {
let proxies = load_proxies(app)?;
Ok(proxies.into_iter().find(|p| p.id == id))
}
pub fn load_proxy_groups(app: &AppHandle) -> AppResult<Vec<ProxyGroup>> {
pub fn load_proxy_groups(app: &impl Sized) -> AppResult<Vec<ProxyGroup>> {
let _ = app;
Ok(storage::load_settings_doc::<ProxyGroupsConfig>(SettingsDocKey::ProxyGroups)?.groups)
}
pub fn save_proxy_groups(app: &AppHandle, groups: &[ProxyGroup]) -> AppResult<()> {
pub fn save_proxy_groups(app: &impl Sized, groups: &[ProxyGroup]) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(
SettingsDocKey::ProxyGroups,
@@ -1,7 +1,6 @@
use crate::error::AppResult;
use crate::storage::{self, SettingsDocKey};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
fn default_execute() -> String {
"execute".to_string()
@@ -58,13 +57,13 @@ pub struct QuickCommandsConfig {
}
/// Loads quick commands from local app storage.
pub fn load_quick_commands(app: &AppHandle) -> AppResult<QuickCommandsConfig> {
pub fn load_quick_commands(app: &impl Sized) -> AppResult<QuickCommandsConfig> {
let _ = app;
storage::load_settings_doc(SettingsDocKey::QuickCommands)
}
/// Saves quick commands to local app storage.
pub fn save_quick_commands(app: &AppHandle, config: &QuickCommandsConfig) -> AppResult<()> {
pub fn save_quick_commands(app: &impl Sized, config: &QuickCommandsConfig) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(SettingsDocKey::QuickCommands, config)
}
@@ -42,7 +42,6 @@ use crate::error::{AppError, AppResult};
use crate::storage::{self, SettingsDocKey};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppSettings {
@@ -79,7 +78,7 @@ pub struct AppSettings {
pub keybindings: HashMap<String, String>,
}
pub fn load_app_settings(app: &AppHandle) -> AppResult<AppSettings> {
pub fn load_app_settings(app: &impl Sized) -> AppResult<AppSettings> {
let raw_settings =
storage::load_settings_doc::<serde_json::Value>(SettingsDocKey::AppSettings)?;
let mut settings: AppSettings = if raw_settings.is_null() {
@@ -507,7 +506,7 @@ fn migrate_legacy_recording_settings(
true
}
fn persist_migrated_app_settings(app: &AppHandle, settings: &AppSettings) {
fn persist_migrated_app_settings(app: &impl Sized, settings: &AppSettings) {
let mut persisted = settings.clone();
let Ok(cloud_sync) = encrypt_cloud_sync_settings(persisted.cloud_sync.clone()) else {
return;
@@ -521,7 +520,7 @@ fn persist_migrated_app_settings(app: &AppHandle, settings: &AppSettings) {
let _ = save_app_settings(app, &persisted);
}
pub fn save_app_settings(app: &AppHandle, config: &AppSettings) -> AppResult<()> {
pub fn save_app_settings(app: &impl Sized, config: &AppSettings) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(SettingsDocKey::AppSettings, config)
}
@@ -2,7 +2,6 @@ use super::{default_false, default_true, uuid_v4};
use crate::error::AppResult;
use crate::storage::{self, SettingsDocKey};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelConfig {
@@ -73,22 +72,22 @@ pub struct TunnelGroupsConfig {
pub groups: Vec<TunnelGroup>,
}
pub fn load_tunnels(app: &AppHandle) -> AppResult<Vec<TunnelConfig>> {
pub fn load_tunnels(app: &impl Sized) -> AppResult<Vec<TunnelConfig>> {
let _ = app;
storage::list_tunnels()
}
pub fn save_tunnels(app: &AppHandle, tunnels: &[TunnelConfig]) -> AppResult<()> {
pub fn save_tunnels(app: &impl Sized, tunnels: &[TunnelConfig]) -> AppResult<()> {
let _ = app;
storage::replace_tunnels(tunnels)
}
pub fn load_tunnel_groups(app: &AppHandle) -> AppResult<Vec<TunnelGroup>> {
pub fn load_tunnel_groups(app: &impl Sized) -> AppResult<Vec<TunnelGroup>> {
let _ = app;
Ok(storage::load_settings_doc::<TunnelGroupsConfig>(SettingsDocKey::TunnelGroups)?.groups)
}
pub fn save_tunnel_groups(app: &AppHandle, groups: &[TunnelGroup]) -> AppResult<()> {
pub fn save_tunnel_groups(app: &impl Sized, groups: &[TunnelGroup]) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(
SettingsDocKey::TunnelGroups,
File renamed without changes.
@@ -0,0 +1,67 @@
use crate::config;
pub fn merge_model_discoveries(
settings: &config::AiSettings,
discoveries: Vec<super::types::AiModelDiscovery>,
) -> Vec<config::AiModelConfigItem> {
let old_by_id = settings
.models
.iter()
.map(|model| (model.id.as_str(), model))
.collect::<std::collections::HashMap<_, _>>();
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| String::new());
let mut seen = std::collections::HashSet::new();
let mut merged = Vec::new();
for item in discoveries {
if !seen.insert(item.id.clone()) {
continue;
}
if let Some(old) = old_by_id.get(item.id.as_str()) {
let mut model = (*old).clone();
model.last_seen_at = Some(now.clone());
merged.push(model);
} else {
merged.push(config::AiModelConfigItem {
id: item.id,
name: item.name,
backend: item.backend,
provider_kind: item.provider_kind,
credential_id: item.credential_id,
enabled: false,
source: item.source,
last_seen_at: Some(now.clone()),
supported_reasoning_efforts: None,
});
}
}
for old in &settings.models {
if !seen.contains(&old.id) {
merged.push(old.clone());
}
}
merged.sort_by(|left, right| left.name.cmp(&right.name));
merged
}
pub fn update_default_model_id(
settings: &config::AiSettings,
models: &[config::AiModelConfigItem],
) -> Option<String> {
if let Some(default_model_id) = settings.default_model_id.as_deref() {
if models
.iter()
.any(|model| model.enabled && model.id == default_model_id)
{
return Some(default_model_id.to_string());
}
}
models
.iter()
.find(|model| model.enabled)
.map(|model| model.id.clone())
}
@@ -0,0 +1,477 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::config::{AiAgentKind, AiBackendKind};
use crate::error::{AppError, AppResult};
use crate::storage::{self, SettingsDocKey};
use super::types::{
AiAuditLog, AiChatRequest, AiMessage, AiMessageRole, AiSession, AiSessionBackendMetadata,
AiSessionScope, AiSessionScopeType, AppendAiAuditRequest, now_rfc3339, uuid,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AiHistoryFile {
#[serde(default)]
pub sessions: Vec<AiSession>,
#[serde(default)]
pub messages: Vec<AiMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct AiAuditFile {
#[serde(default)]
logs: Vec<AiAuditLog>,
}
const AI_HISTORY_MAX_SESSIONS: usize = 200;
const AI_HISTORY_MAX_MESSAGES: usize = 2_000;
const AI_AUDIT_MAX_LOGS: usize = 2_000;
fn normalize_session_scope(session: &mut AiSession) {
if session.agent_kind == AiAgentKind::Nyaterm
&& let Some(metadata) = session.backend_metadata.as_ref()
&& metadata.backend == AiBackendKind::Codex
{
session.agent_kind = AiAgentKind::Codex;
if session.external_session_id.is_none() {
session.external_session_id = metadata.external_thread_id.clone();
}
}
if session.scope.r#type == AiSessionScopeType::Unbound && session.scope.target_id.is_none() {
if let Some(connection_id) = session.connection_id.as_ref() {
if !connection_id.trim().is_empty()
&& !session
.scope
.connection_ids
.iter()
.any(|value| value == connection_id)
{
session.scope.connection_ids.push(connection_id.clone());
}
}
}
}
fn normalize_history(history: &mut AiHistoryFile) {
for session in &mut history.sessions {
normalize_session_scope(session);
}
}
pub fn load_history(_app: &impl Sized) -> AppResult<AiHistoryFile> {
let mut history: AiHistoryFile = storage::load_settings_doc(SettingsDocKey::AiHistory)?;
normalize_history(&mut history);
Ok(history)
}
pub fn trim_history(history: &mut AiHistoryFile) {
history
.sessions
.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
if history.sessions.len() > AI_HISTORY_MAX_SESSIONS {
history.sessions.truncate(AI_HISTORY_MAX_SESSIONS);
}
let retained_sessions: HashSet<&str> = history
.sessions
.iter()
.map(|session| session.id.as_str())
.collect();
history
.messages
.retain(|message| retained_sessions.contains(message.session_id.as_str()));
if history.messages.len() > AI_HISTORY_MAX_MESSAGES {
history
.messages
.sort_by(|left, right| left.created_at.cmp(&right.created_at));
let remove_count = history.messages.len() - AI_HISTORY_MAX_MESSAGES;
history.messages.drain(0..remove_count);
}
let sessions_with_messages: HashSet<&str> = history
.messages
.iter()
.map(|message| message.session_id.as_str())
.collect();
history
.sessions
.retain(|session| sessions_with_messages.contains(session.id.as_str()));
}
pub fn save_user_message(
app: &impl Sized,
session_id: &str,
request: &AiChatRequest,
) -> AppResult<()> {
tracing::debug!(
session_id = %session_id,
connection_id = ?request.connection_id,
action = ?request.action,
"Persisting AI user message"
);
let now = now_rfc3339();
let title = request
.user_input
.chars()
.take(42)
.collect::<String>()
.trim()
.to_string();
let connection_id = request.connection_id.clone();
let scope = request.owner_scope.clone();
let user_input = request.user_input.clone();
let session_id = session_id.to_string();
let _ = app;
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
if let Some(session) = history
.sessions
.iter_mut()
.find(|item| item.id == session_id)
{
session.updated_at = now.clone();
} else {
history.sessions.push(AiSession {
id: session_id.clone(),
agent_kind: request.agent_kind.clone(),
scope,
connection_id,
title: if title.is_empty() {
"AI Session".to_string()
} else {
title
},
created_at: now.clone(),
updated_at: now.clone(),
external_session_id: request.existing_external_session_id.clone(),
backend_metadata: None,
});
}
history.messages.push(AiMessage {
id: format!("msg-{}", uuid()),
session_id,
role: AiMessageRole::User,
content: user_input,
created_at: now,
reasoning_content: None,
command_cards: vec![],
});
trim_history(history);
Ok(())
})
}
pub fn validate_session_scope(
app: &impl Sized,
session_id: &str,
request: &AiChatRequest,
) -> AppResult<()> {
let history = load_history(app)?;
let Some(existing) = history.sessions.iter().find(|item| item.id == session_id) else {
return Ok(());
};
if existing.agent_kind != request.agent_kind {
return Err(AppError::Config(
"AI session belongs to another agent type".to_string(),
));
}
if existing.scope.r#type == AiSessionScopeType::Unbound {
return Ok(());
}
if existing.scope.r#type != request.owner_scope.r#type
|| existing.scope.target_id != request.owner_scope.target_id
{
return Err(AppError::Config(
"AI session belongs to another terminal scope".to_string(),
));
}
Ok(())
}
pub fn append_message(app: &impl Sized, message: AiMessage) -> AppResult<()> {
tracing::debug!(
session_id = %message.session_id,
role = ?message.role,
content_len = message.content.len(),
command_card_count = message.command_cards.len(),
has_reasoning = message.reasoning_content.is_some(),
"Persisting AI message"
);
let _ = app;
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
if let Some(session) = history
.sessions
.iter_mut()
.find(|item| item.id == message.session_id)
{
session.updated_at = message.created_at.clone();
}
history.messages.push(message);
trim_history(history);
Ok(())
})
}
pub fn get_session_backend_metadata(
app: &impl Sized,
session_id: &str,
) -> AppResult<Option<AiSessionBackendMetadata>> {
Ok(load_history(app)?
.sessions
.into_iter()
.find(|session| session.id == session_id)
.and_then(|session| session.backend_metadata))
}
pub fn set_session_external_session_id(
app: &impl Sized,
session_id: &str,
agent_kind: AiAgentKind,
external_session_id: String,
) -> AppResult<()> {
let _ = app;
let session_id = session_id.to_string();
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
normalize_history(history);
if let Some(session) = history
.sessions
.iter_mut()
.find(|item| item.id == session_id)
{
session.agent_kind = agent_kind;
session.external_session_id = Some(external_session_id);
}
Ok(())
})
}
pub fn set_session_backend_metadata(
app: &impl Sized,
session_id: &str,
metadata: AiSessionBackendMetadata,
) -> AppResult<()> {
let _ = app;
let session_id = session_id.to_string();
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
if let Some(session) = history
.sessions
.iter_mut()
.find(|item| item.id == session_id)
{
session.backend_metadata = Some(metadata);
}
Ok(())
})
}
pub fn get_ai_sessions(app: &impl Sized) -> AppResult<Vec<AiSession>> {
let mut sessions = load_history(app)?.sessions;
sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
Ok(sessions)
}
pub fn get_ai_messages(app: &impl Sized, session_id: String) -> AppResult<Vec<AiMessage>> {
Ok(load_history(app)?
.messages
.into_iter()
.filter(|message| message.session_id == session_id)
.collect())
}
pub fn clear_ai_history_storage(app: &impl Sized) -> AppResult<()> {
let _ = app;
storage::save_settings_doc(SettingsDocKey::AiHistory, &AiHistoryFile::default())
}
pub fn delete_ai_session(app: &impl Sized, session_id: String) -> AppResult<()> {
let _ = app;
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
history.sessions.retain(|s| s.id != session_id);
history.messages.retain(|m| m.session_id != session_id);
trim_history(history);
Ok(())
})
}
pub fn rebind_ai_session(
app: &impl Sized,
session_id: String,
owner_scope: AiSessionScope,
) -> AppResult<AiSession> {
let _ = app;
storage::update_settings_doc::<AiHistoryFile, _, _>(SettingsDocKey::AiHistory, |history| {
normalize_history(history);
let Some(session) = history
.sessions
.iter_mut()
.find(|item| item.id == session_id)
else {
return Err(AppError::Config("AI session not found".to_string()));
};
session.scope = owner_scope;
session.updated_at = now_rfc3339();
Ok(session.clone())
})
}
pub fn append_ai_audit(app: &impl Sized, request: AppendAiAuditRequest) -> AppResult<AiAuditLog> {
tracing::info!(
connection_id = ?request.connection_id,
action = %request.action,
risk_level = ?request.risk_level,
inserted_to_terminal = request.inserted_to_terminal,
executed = request.executed,
blocked = request.blocked,
"Appending AI audit log"
);
let _ = app;
let log = AiAuditLog {
id: format!("audit-{}", uuid()),
connection_id: request.connection_id,
action: request.action,
user_input: request.user_input,
generated_command: request.generated_command,
risk_level: request.risk_level,
inserted_to_terminal: request.inserted_to_terminal,
executed: request.executed,
blocked: request.blocked,
source: request.source,
client: request.client,
capability: request.capability,
session_id: request.session_id,
permission_mode: request.permission_mode,
approval_decision: request.approval_decision,
success: request.success,
duration_ms: request.duration_ms,
error: request.error,
created_at: now_rfc3339(),
};
storage::update_settings_doc::<AiAuditFile, _, _>(SettingsDocKey::AiAudit, |file| {
file.logs.push(log.clone());
if file.logs.len() > AI_AUDIT_MAX_LOGS {
let keep_from = file.logs.len().saturating_sub(AI_AUDIT_MAX_LOGS);
file.logs = file.logs.split_off(keep_from);
}
Ok(log)
})
}
pub fn get_ai_audit_logs(app: &impl Sized, limit: Option<usize>) -> AppResult<Vec<AiAuditLog>> {
let _ = app;
let mut logs = storage::load_settings_doc::<AiAuditFile>(SettingsDocKey::AiAudit)?.logs;
logs.sort_by(|left, right| right.created_at.cmp(&left.created_at));
if let Some(limit) = limit {
logs.truncate(limit);
}
Ok(logs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_history_without_reasoning_defaults_cleanly() {
let raw = r#"{"sessions":[],"messages":[{"id":"m1","sessionId":"s1","role":"assistant","content":"hello","createdAt":"2026-04-28T00:00:00Z","commandCards":[]}]}"#;
let history: AiHistoryFile = serde_json::from_str(raw).unwrap();
assert_eq!(history.messages.len(), 1);
assert_eq!(history.messages[0].reasoning_content, None);
}
#[test]
fn migrates_legacy_codex_thread_metadata_to_external_session() {
let mut session = AiSession {
id: "s1".to_string(),
agent_kind: AiAgentKind::Nyaterm,
scope: AiSessionScope::default(),
connection_id: None,
title: "legacy".to_string(),
created_at: "2026-04-28T00:00:00Z".to_string(),
updated_at: "2026-04-28T00:00:00Z".to_string(),
external_session_id: None,
backend_metadata: Some(AiSessionBackendMetadata {
backend: AiBackendKind::Codex,
external_thread_id: Some("thread-123".to_string()),
codex_terminal_tools_version: None,
}),
};
normalize_session_scope(&mut session);
assert_eq!(session.agent_kind, AiAgentKind::Codex);
assert_eq!(session.external_session_id.as_deref(), Some("thread-123"));
}
#[test]
fn trims_ai_history_to_session_and_message_limits() {
let mut history = AiHistoryFile::default();
for session_idx in 0..220 {
let session_id = format!("s-{session_idx:03}");
let updated_at = format!(
"2026-04-28T00:{:02}:{:02}Z",
session_idx / 60,
session_idx % 60
);
history.sessions.push(AiSession {
id: session_id.clone(),
agent_kind: AiAgentKind::Nyaterm,
scope: AiSessionScope::default(),
connection_id: None,
title: session_id.clone(),
created_at: updated_at.clone(),
updated_at,
external_session_id: None,
backend_metadata: None,
});
for message_idx in 0..10 {
history.messages.push(AiMessage {
id: format!("m-{session_idx:03}-{message_idx:02}"),
session_id: session_id.clone(),
role: if message_idx % 2 == 0 {
AiMessageRole::User
} else {
AiMessageRole::Assistant
},
content: "message".to_string(),
created_at: format!(
"2026-04-28T00:{:02}:{:02}.{:03}Z",
session_idx / 60,
session_idx % 60,
message_idx
),
reasoning_content: None,
command_cards: vec![],
});
}
}
trim_history(&mut history);
assert_eq!(history.sessions.len(), AI_HISTORY_MAX_SESSIONS);
assert_eq!(history.messages.len(), AI_HISTORY_MAX_MESSAGES);
let retained_sessions: HashSet<&str> = history
.sessions
.iter()
.map(|session| session.id.as_str())
.collect();
assert!(!retained_sessions.contains("s-000"));
assert!(retained_sessions.contains("s-219"));
assert!(
history
.messages
.iter()
.all(|message| retained_sessions.contains(message.session_id.as_str()))
);
}
}
@@ -0,0 +1,93 @@
use genai::WebConfig;
use reqwest::{Client, ClientBuilder, NoProxy, Proxy, Url};
use crate::config::{AiProxyMode, AiProxyProtocol, AiProxySettings, AiSettings};
use crate::error::{AppError, AppResult};
use super::model::ai_request_headers;
/// Every built-in AI transport uses this builder. Keep timeouts at the request /
/// stream level: a total client timeout would interrupt long-running streams.
pub fn build_http_client(settings: &AiSettings) -> AppResult<Client> {
let web_config = WebConfig::default().with_default_headers(ai_request_headers(settings)?);
let builder = web_config.apply_to_builder(Client::builder());
apply_proxy(builder, &settings.proxy)?
.build()
.map_err(|_| AppError::Config("Failed to build AI HTTP client".to_string()))
}
fn apply_proxy(builder: ClientBuilder, settings: &AiProxySettings) -> AppResult<ClientBuilder> {
match settings.mode {
AiProxyMode::System => Ok(builder),
AiProxyMode::Direct => Ok(builder.no_proxy()),
AiProxyMode::Custom => {
let url = proxy_url(settings)?;
let username = settings
.username
.as_deref()
.filter(|value| !value.is_empty());
let password = settings
.password
.as_deref()
.filter(|value| !value.is_empty());
if password.is_some() && username.is_none() {
return Err(AppError::Config(
"AI proxy password requires a username".to_string(),
));
}
if settings.protocol == AiProxyProtocol::Socks5
&& (username.is_some_and(|value| value.len() > 255)
|| password.is_some_and(|value| value.len() > 255))
{
return Err(AppError::Config(
"SOCKS5 proxy credentials must be at most 255 bytes".to_string(),
));
}
let mut proxy = Proxy::all(url)
.map_err(|_| AppError::Config("Invalid AI proxy configuration".to_string()))?
.no_proxy(NoProxy::from_string(&settings.no_proxy));
if let Some(username) = username {
proxy = proxy.basic_auth(username, password.unwrap_or_default());
}
// Explicitly disable automatic proxies so bypassed requests connect
// directly, even when environment / system proxies are configured.
Ok(builder.no_proxy().proxy(proxy))
}
}
}
fn proxy_url(settings: &AiProxySettings) -> AppResult<Url> {
let invalid = || AppError::Config("Invalid AI proxy host or port".to_string());
let host = settings.host.trim();
if host.is_empty()
|| settings.port == 0
|| host
.chars()
.any(|value| value.is_whitespace() || "/\\@?#%".contains(value))
{
return Err(invalid());
}
let unbracketed = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
let host = if unbracketed.contains(':') {
let address = unbracketed
.parse::<std::net::Ipv6Addr>()
.map_err(|_| invalid())?;
format!("[{address}]")
} else {
host.to_string()
};
let scheme = match settings.protocol {
AiProxyProtocol::Http => "http",
AiProxyProtocol::Socks5 => "socks5h",
};
// Validate DNS names using a special URL scheme too: SOCKS URLs otherwise
// allow opaque hosts with characters that cannot be used as a DNS name.
Url::parse(&format!("http://{host}:{}", settings.port)).map_err(|_| invalid())?;
Url::parse(&format!("{scheme}://{host}:{}", settings.port)).map_err(|_| invalid())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,11 @@
pub mod history;
pub mod http;
pub mod model;
pub mod parser;
pub mod prompt;
pub mod redaction;
pub mod responses;
pub mod stream;
pub mod types;
pub mod catalog;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,363 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use super::types::{AiChatRequest, AiCommandCard, AiTerminalTarget};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiModelOutput {
#[serde(default)]
pub text: String,
#[serde(default)]
pub reasoning: Option<String>,
#[serde(default)]
pub command_cards: Vec<AiCommandCard>,
}
pub fn parse_model_output(
raw_text: &str,
stream_reasoning: Option<String>,
) -> (String, Option<String>, Vec<AiCommandCard>) {
let candidate = extract_json_object(raw_text).unwrap_or_else(|| raw_text.trim().to_string());
match serde_json::from_str::<AiModelOutput>(&candidate) {
Ok(output) => {
let text = if output.text.trim().is_empty() {
raw_text.trim().to_string()
} else {
output.text
};
let reasoning_content = trim_optional_to_option(output.reasoning)
.or_else(|| trim_optional_to_option(stream_reasoning));
let (text, extracted_reasoning) = extract_think_block(&text);
let result = (
text,
extracted_reasoning.or(reasoning_content),
output.command_cards,
);
if !result.0.is_empty() {
return result;
}
promote_reasoning_to_text(result)
}
Err(_) => {
let normalized_reasoning = trim_optional_to_option(stream_reasoning);
let (text, extracted_reasoning) = extract_think_block(raw_text);
let result = (text, extracted_reasoning.or(normalized_reasoning), vec![]);
if !result.0.is_empty() {
return result;
}
promote_reasoning_to_text(result)
}
}
}
pub fn bind_command_card_targets(cards: &mut [AiCommandCard], request: &AiChatRequest) {
match request.targets.as_slice() {
[] => {}
[target] => {
for card in cards {
card.target = Some(target.clone());
card.target_terminal_session_id = Some(target.terminal_session_id.clone());
}
}
targets => {
for card in cards {
card.target = resolve_card_target(card, targets);
}
}
}
}
fn resolve_card_target(
card: &AiCommandCard,
targets: &[AiTerminalTarget],
) -> Option<AiTerminalTarget> {
if let Some(target_id) = card
.target_terminal_session_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return targets
.iter()
.find(|target| target.terminal_session_id == target_id)
.cloned();
}
if let Some(model_target) = card.target.as_ref() {
return targets
.iter()
.find(|target| target.terminal_session_id == model_target.terminal_session_id)
.cloned();
}
None
}
/// When the primary text is empty but reasoning content exists, try to
/// extract a usable answer from the reasoning. Thinking models (e.g. Qwen3)
/// sometimes put the entire response in the reasoning channel.
fn promote_reasoning_to_text(
(text, reasoning, cards): (String, Option<String>, Vec<AiCommandCard>),
) -> (String, Option<String>, Vec<AiCommandCard>) {
if !text.is_empty() {
return (text, reasoning, cards);
}
let reasoning_str = match reasoning.as_deref() {
Some(r) if !r.trim().is_empty() => r,
_ => return (text, reasoning, cards),
};
tracing::info!(
reasoning_preview = %truncate_preview(reasoning_str, 300),
"Text content empty; attempting to extract answer from reasoning"
);
if let Some(json_str) = extract_json_object(reasoning_str) {
if let Ok(output) = serde_json::from_str::<AiModelOutput>(&json_str) {
let promoted_text = if output.text.trim().is_empty() {
json_str.clone()
} else {
output.text
};
let inner_reasoning = trim_optional_to_option(output.reasoning);
return (promoted_text, inner_reasoning, output.command_cards);
}
}
let (visible, inner_reasoning) = extract_think_block(reasoning_str);
if !visible.is_empty() {
return (visible, inner_reasoning, cards);
}
(reasoning.unwrap_or_default(), None, cards)
}
pub fn extract_json_object(raw_text: &str) -> Option<String> {
let trimmed = raw_text
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
return Some(trimmed.to_string());
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
if start >= end {
return None;
}
Some(trimmed[start..=end].to_string())
}
pub fn extract_text_from_assistant(content: &str) -> String {
let trimmed = content.trim();
if let Some(json_str) = extract_json_object(trimmed) {
if let Ok(output) = serde_json::from_str::<AiModelOutput>(&json_str) {
if !output.text.trim().is_empty() {
return output.text;
}
}
}
trimmed.to_string()
}
fn extract_think_block(raw_text: &str) -> (String, Option<String>) {
static THINK_REGEX: OnceLock<Regex> = OnceLock::new();
let regex = THINK_REGEX.get_or_init(|| Regex::new(r"(?is)<think>(.*?)</think>").unwrap());
let mut reasoning_parts = Vec::new();
for captures in regex.captures_iter(raw_text) {
if let Some(value) = captures.get(1) {
let reasoning = value.as_str().trim();
if !reasoning.is_empty() {
reasoning_parts.push(reasoning.to_string());
}
}
}
let visible_text = regex.replace_all(raw_text, "").to_string();
(
visible_text.trim().to_string(),
trim_string_to_option(reasoning_parts.join("\n\n")),
)
}
pub fn truncate_preview(s: &str, max_len: usize) -> String {
let trimmed = s.trim();
if trimmed.len() <= max_len {
trimmed.to_string()
} else {
let boundary = trimmed
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max_len)
.last()
.unwrap_or(0);
format!("{}…", &trimmed[..boundary])
}
}
pub fn trim_string_to_option(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn trim_optional_to_option(value: Option<String>) -> Option<String> {
value.and_then(trim_string_to_option)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ai::types::AiAction;
#[test]
fn parses_json_command_cards() {
let raw = r#"{"text":"ok","commandCards":[{"id":"1","title":"CPU","command":"ps aux","explanation":"x","riskLevel":"low","riskReason":"read only","expectedEffect":"list","rollback":"none"}]}"#;
let (text, reasoning, cards) = parse_model_output(raw, None);
assert_eq!(text, "ok");
assert_eq!(reasoning, None);
assert_eq!(cards.len(), 1);
}
#[test]
fn single_target_request_binds_all_command_cards() {
let mut cards = vec![AiCommandCard {
id: "1".to_string(),
title: "CPU".to_string(),
command: "ps aux".to_string(),
explanation: "x".to_string(),
risk_level: None,
risk_reason: None,
expected_effect: "list".to_string(),
rollback: None,
category: None,
references: vec![],
target_terminal_session_id: None,
target: None,
}];
let mut request = test_request();
request.targets.push(AiTerminalTarget {
terminal_session_id: "term-a".to_string(),
connection_id: Some("conn-a".to_string()),
label: "Prod".to_string(),
host: Some("10.0.0.1".to_string()),
username: Some("root".to_string()),
session_type: "SSH".to_string(),
});
bind_command_card_targets(&mut cards, &request);
assert_eq!(
cards[0]
.target
.as_ref()
.map(|target| target.terminal_session_id.as_str()),
Some("term-a")
);
}
#[test]
fn multi_target_request_rejects_unknown_command_target() {
let mut cards = vec![AiCommandCard {
id: "1".to_string(),
title: "CPU".to_string(),
command: "ps aux".to_string(),
explanation: "x".to_string(),
risk_level: None,
risk_reason: None,
expected_effect: "list".to_string(),
rollback: None,
category: None,
references: vec![],
target_terminal_session_id: Some("term-x".to_string()),
target: None,
}];
let mut request = test_request();
request.targets.push(AiTerminalTarget {
terminal_session_id: "term-a".to_string(),
connection_id: None,
label: "Prod".to_string(),
host: None,
username: None,
session_type: "SSH".to_string(),
});
request.targets.push(AiTerminalTarget {
terminal_session_id: "term-b".to_string(),
connection_id: None,
label: "Test".to_string(),
host: None,
username: None,
session_type: "SSH".to_string(),
});
bind_command_card_targets(&mut cards, &request);
assert!(cards[0].target.is_none());
}
fn test_request() -> AiChatRequest {
AiChatRequest {
stream_id: None,
session_id: None,
connection_id: None,
terminal_session_id: None,
owner_scope: Default::default(),
targets: vec![],
target_contexts: vec![],
mode: crate::config::AiMode::Ask,
agent_kind: crate::config::AiAgentKind::Nyaterm,
permission_mode: crate::config::AiPermissionMode::Confirm,
model_id: None,
model_name: None,
default_target_session_id: None,
existing_external_session_id: None,
attachments: vec![],
action: AiAction::GenerateCommand,
user_input: "test".to_string(),
context: Default::default(),
options: Default::default(),
}
}
#[test]
fn parse_failure_returns_text_without_cards() {
let (text, reasoning, cards) = parse_model_output("plain text", None);
assert_eq!(text, "plain text");
assert_eq!(reasoning, None);
assert!(cards.is_empty());
}
#[test]
fn extracts_think_block_into_reasoning() {
let (text, reasoning, cards) =
parse_model_output("<think>step 1\nstep 2</think>final answer", None);
assert_eq!(text, "final answer");
assert_eq!(reasoning.as_deref(), Some("step 1\nstep 2"));
assert!(cards.is_empty());
}
#[test]
fn keeps_markdown_text_when_json_parse_fails() {
let markdown = "## Summary\n\n- item 1\n- item 2";
let (text, reasoning, cards) = parse_model_output(markdown, None);
assert_eq!(text, markdown);
assert_eq!(reasoning, None);
assert!(cards.is_empty());
}
#[test]
fn prefers_json_reasoning_when_present() {
let raw = r#"{"text":"answer","reasoning":"first\nsecond","commandCards":[]}"#;
let (text, reasoning, cards) = parse_model_output(raw, None);
assert_eq!(text, "answer");
assert_eq!(reasoning.as_deref(), Some("first\nsecond"));
assert!(cards.is_empty());
}
}
@@ -0,0 +1,996 @@
use crate::config::AiSettings;
use super::types::{AiAction, AiChatRequest, CommandObservation};
const SYSTEM_PROMPT_ZH: &str = r#"你是一个专业、谨慎、安全优先的 Linux / DevOps / 云原生终端助手。
你的任务是帮助用户解释终端输出、生成 Shell 命令、分析错误、提供排查步骤。
必须遵守:
1. 不要建议不可逆高危操作,除非明确说明风险和安全替代方案。
2. 默认生成只读诊断命令。
3. 对任何删除、格式化、重启、停服务、改权限、批量变更命令标记风险。
4. 命令必须适配用户当前系统、架构、shell 和权限上下文。
5. 输出必须结构化,包含命令、说明、风险等级、影响范围和回滚建议。
6. 不要编造当前系统不存在的信息;不确定时给出验证命令。
7. 不要要求用户粘贴密码、私钥、token。
只返回一个 JSON 对象,不要使用 Markdown 代码块。格式:
{
"text": "给用户看的说明",
"commandCards": [
{
"id": "cmd-uuid",
"title": "标题",
"command": "shell command",
"explanation": "命令说明",
"riskLevel": "low|medium|high|critical",
"riskReason": "风险原因",
"expectedEffect": "预计影响",
"rollback": "回滚方式或无需回滚",
"category": "Linux 性能"
}
]
}"#;
const SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個專業、謹慎、安全優先的 Linux / DevOps / 雲端原生終端助手。
你的任務是協助使用者解釋終端輸出、產生 Shell 命令、分析錯誤、提供排查步驟。
必須遵守:
1. 不要建議不可逆的高風險操作,除非明確說明風險並提供較安全的替代方案。
2. 預設產生唯讀診斷命令。
3. 對任何刪除、格式化、重新啟動、停止服務、修改權限、批次變更命令標記風險。
4. 命令必須符合使用者目前的系統、架構、Shell 和權限情境。
5. 輸出必須結構化,包含命令、說明、風險等級、影響範圍和復原建議。
6. 不要編造目前系統不存在的資訊;不確定時提供驗證命令。
7. 不要要求使用者貼上密碼、私鑰、token。
只回傳一個 JSON 物件,不要使用 Markdown 程式碼區塊。格式:
{
"text": "給使用者看的說明",
"commandCards": [
{
"id": "cmd-uuid",
"title": "標題",
"command": "shell command",
"explanation": "命令說明",
"riskLevel": "low|medium|high|critical",
"riskReason": "風險原因",
"expectedEffect": "預期影響",
"rollback": "復原方式或無需復原",
"category": "Linux 效能"
}
]
}"#;
const SYSTEM_PROMPT_EN: &str = r#"You are a professional, careful, safety-first Linux / DevOps / cloud-native terminal assistant.
Your job is to explain terminal output, generate Shell commands, analyze errors, and suggest next troubleshooting steps.
You must follow these rules:
1. Do not suggest irreversible high-risk actions unless you clearly explain the risk and provide safer alternatives.
2. Prefer read-only diagnostic commands by default.
3. Mark any delete, format, restart, stop-service, permission-change, or bulk-change command with the appropriate risk.
4. Commands must fit the user's current system, architecture, shell, and privilege context.
5. Output must be structured and include commands, explanations, risk level, expected effect, and rollback guidance.
6. Do not invent facts about the current system. If uncertain, provide verification commands.
7. Do not ask the user to paste passwords, private keys, or tokens.
Return exactly one JSON object and do not use Markdown code fences. Format:
{
"text": "user-facing explanation",
"commandCards": [
{
"id": "cmd-uuid",
"title": "title",
"command": "shell command",
"explanation": "command explanation",
"riskLevel": "low|medium|high|critical",
"riskReason": "why this risk applies",
"expectedEffect": "expected effect",
"rollback": "rollback steps or state that rollback is unnecessary",
"category": "Linux performance"
}
]
}"#;
const SYSTEM_PROMPT_KO: &str = r#"당신은 전문적이고 신중하며 안전을 최우선으로 하는 Linux / DevOps / 클라우드 네이티브 터미널 어시스턴트입니다.
사용자의 터미널 출력 설명, Shell 명령 생성, 오류 분석, 다음 문제 해결 단계 제안을 돕는 것이 임무입니다.
반드시 다음 규칙을 따르세요:
1. 되돌릴 수 없는 고위험 작업은 위험을 명확히 설명하고 더 안전한 대안을 제공하지 않는 한 제안하지 마세요.
2. 기본적으로 읽기 전용 진단 명령을 우선하세요.
3. 삭제, 포맷, 재시작, 서비스 중지, 권한 변경, 대량 변경 명령에는 적절한 위험 등급을 표시하세요.
4. 명령은 사용자의 현재 시스템, 아키텍처, shell, 권한 컨텍스트에 맞아야 합니다.
5. 출력은 구조화되어야 하며 명령, 설명, 위험 등급, 예상 효과, 롤백 안내를 포함해야 합니다.
6. 현재 시스템에 대해 존재하지 않는 정보를 지어내지 마세요. 확실하지 않으면 확인 명령을 제공하세요.
7. 사용자에게 비밀번호, 개인 키, token을 붙여 넣으라고 요청하지 마세요.
Markdown 코드 블록을 사용하지 말고 정확히 하나의 JSON 객체만 반환하세요. 형식:
{
"text": "사용자에게 보여줄 설명",
"commandCards": [
{
"id": "cmd-uuid",
"title": "제목",
"command": "shell command",
"explanation": "명령 설명",
"riskLevel": "low|medium|high|critical",
"riskReason": "위험 이유",
"expectedEffect": "예상 효과",
"rollback": "롤백 단계 또는 롤백이 필요 없다는 설명",
"category": "Linux 성능"
}
]
}"#;
const AGENT_SYSTEM_PROMPT_ZH: &str = r#"你是一个终端自动化 Agent,通过"思考—执行—观察"循环完成用户的任务。
每一轮你只能做一件事:调用 execute_command 工具执行一条命令,或调用 final_answer 工具给出最终回答。
规则:
1. 每轮必须且只能调用一个工具,不要在普通正文里输出 JSON。
2. 如果需要执行命令,调用 execute_command。
3. 任务完成或无需执行命令时,调用 final_answer。
4. thought 和 answer 尽量使用用户请求指定的目标语言。
5. 优先使用只读命令收集信息,再做修改操作。
6. 不要执行不可逆高危命令(如 rm -rf /、mkfs、停止 SSH 等),改为在 thought 中说明风险并调用 final_answer。
7. 不要编造信息;不确定时先用验证命令确认。
8. 不要要求用户提供密码、私钥、token。
9. 命令必须适配用户当前的系统和 shell 环境。
10. riskLevel 规则:只读命令 → low,普通写操作 → medium,删除/重启/权限修改 → high,不可逆破坏 → critical。
11. 调用 execute_command 时必须同时提供 riskLevel 和 riskReason;riskReason 要简短说明为什么这样分级。"#;
const AGENT_SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個終端自動化 Agent,透過「思考—執行—觀察」迴圈完成使用者的任務。
每一輪你只能做一件事:呼叫 execute_command 工具執行一個命令,或呼叫 final_answer 工具提供最終回答。
規則:
1. 每輪必須且只能呼叫一個工具,不要在一般正文裡輸出 JSON。
2. 如果需要執行命令,呼叫 execute_command。
3. 任務完成或無需執行命令時,呼叫 final_answer。
4. thought 和 answer 盡量使用使用者請求指定的目標語言。
5. 優先使用唯讀命令收集資訊,再做修改操作。
6. 不要執行不可逆的高風險命令(如 rm -rf /、mkfs、停止 SSH 等),改為在 thought 中說明風險並呼叫 final_answer。
7. 不要編造資訊;不確定時先用驗證命令確認。
8. 不要要求使用者提供密碼、私鑰、token。
9. 命令必須符合使用者目前的系統和 Shell 環境。
10. riskLevel 規則:唯讀命令 → low,一般寫入操作 → medium,刪除/重新啟動/權限修改 → high,不可逆破壞 → critical。
11. 呼叫 execute_command 時必須同時提供 riskLevel 和 riskReason;riskReason 要簡短說明為什麼這樣分級。"#;
const AGENT_SYSTEM_PROMPT_EN: &str = r#"You are a terminal automation agent that completes tasks using a think-execute-observe loop.
In each turn, do exactly one thing: call the execute_command tool to execute one command, or call the final_answer tool to finish.
Rules:
1. You must call exactly one tool per turn. Do not put protocol JSON in normal assistant text.
2. If a command must be executed, call execute_command.
3. If the task is complete or no command is needed, call final_answer.
4. Use the target language requested by the user for both thought and answer whenever possible.
5. Prefer read-only commands to gather information before making changes.
6. Do not execute irreversible high-risk commands (for example rm -rf /, mkfs, or stopping SSH). Explain the risk in thought and call final_answer instead.
7. Do not invent facts. If uncertain, verify first.
8. Do not ask the user for passwords, private keys, or tokens.
9. Commands must fit the user's current system and shell environment.
10. riskLevel guidance: read-only commands -> low, normal write actions -> medium, delete/restart/permission changes -> high, irreversible destructive actions -> critical.
11. execute_command calls must include both riskLevel and riskReason. Keep riskReason brief and explain why the risk applies."#;
const AGENT_SYSTEM_PROMPT_KO: &str = r#"당신은 생각-실행-관찰 루프를 사용해 작업을 완료하는 터미널 자동화 Agent입니다.
각 턴에서는 정확히 한 가지만 해야 합니다: execute_command 도구를 호출해 명령 하나를 실행하거나, final_answer 도구를 호출해 마무리하세요.
규칙:
1. 각 턴에서 반드시 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요.
2. 명령을 실행해야 한다면 execute_command를 호출하세요.
3. 작업이 완료되었거나 명령이 필요 없다면 final_answer를 호출하세요.
4. 가능하면 thought와 answer 모두 사용자 요청의 대상 언어를 사용하세요.
5. 변경 작업 전에 읽기 전용 명령으로 정보를 수집하는 것을 우선하세요.
6. 되돌릴 수 없는 고위험 명령(예: rm -rf /, mkfs, SSH 중지)은 실행하지 마세요. thought에서 위험을 설명하고 대신 final_answer를 호출하세요.
7. 정보를 지어내지 마세요. 확실하지 않으면 먼저 확인하세요.
8. 사용자에게 비밀번호, 개인 키, token을 요청하지 마세요.
9. 명령은 사용자의 현재 시스템과 shell 환경에 맞아야 합니다.
10. riskLevel 기준: 읽기 전용 명령 -> low, 일반 쓰기 작업 -> medium, 삭제/재시작/권한 변경 -> high, 되돌릴 수 없는 파괴적 작업 -> critical.
11. execute_command 호출에는 반드시 riskLevel과 riskReason을 모두 포함하세요. riskReason은 짧게 작성하고 해당 위험 등급의 이유를 설명하세요."#;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum PromptLanguage {
ZhHans,
ZhHant,
En,
Ko,
}
fn resolve_prompt_language(language: &str) -> PromptLanguage {
let normalized = language.trim().replace('_', "-").to_ascii_lowercase();
match normalized.as_str() {
"zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => PromptLanguage::ZhHant,
"zh" | "zh-cn" | "zh-sg" | "zh-hans" => PromptLanguage::ZhHans,
"en" | "en-us" | "en-gb" => PromptLanguage::En,
"ko" | "ko-kr" => PromptLanguage::Ko,
code if code.starts_with("zh-hant-") => PromptLanguage::ZhHant,
code if code.starts_with("zh-hans-") => PromptLanguage::ZhHans,
_ => PromptLanguage::En,
}
}
fn user_input_with_target_contexts(request: &AiChatRequest) -> String {
if request.targets.is_empty() && request.target_contexts.is_empty() {
return request.user_input.clone();
}
let mut result = request.user_input.clone();
result.push_str("\n\nAvailable terminal targets:\n");
for target in &request.targets {
result.push_str(&format!(
"- {}: {}{}{}\n",
target.terminal_session_id,
target.label,
target
.host
.as_deref()
.map(|host| format!(" host={host}"))
.unwrap_or_default(),
target
.username
.as_deref()
.map(|username| format!(" user={username}"))
.unwrap_or_default()
));
}
if request.targets.len() > 1 {
result.push_str(
"\nEvery executable command card or agent execute_command call must include a valid targetTerminalSessionId from the list above. If no target is clear, do not execute.\n",
);
}
if !request.target_contexts.is_empty() {
result.push_str("\nTerminal target context snapshots:\n");
for item in &request.target_contexts {
let target_label = item
.target
.as_ref()
.map(|target| format!("{} ({})", target.label, target.terminal_session_id))
.unwrap_or_else(|| "unknown".to_string());
let ctx = &item.context;
result.push_str(&format!(
"\n[{}]\n- cwd: {}\n- input: {}\n- selected text:\n{}\n- recent output:\n{}\n",
target_label,
ctx.cwd.as_deref().unwrap_or("-"),
ctx.input_buffer,
ctx.selected_text,
ctx.recent_output
));
}
}
result
}
pub fn system_prompt(language: &str) -> &'static str {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => SYSTEM_PROMPT_ZH,
PromptLanguage::ZhHant => SYSTEM_PROMPT_ZH_HANT,
PromptLanguage::En => SYSTEM_PROMPT_EN,
PromptLanguage::Ko => SYSTEM_PROMPT_KO,
}
}
pub fn agent_system_prompt(language: &str) -> &'static str {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => AGENT_SYSTEM_PROMPT_ZH,
PromptLanguage::ZhHant => AGENT_SYSTEM_PROMPT_ZH_HANT,
PromptLanguage::En => AGENT_SYSTEM_PROMPT_EN,
PromptLanguage::Ko => AGENT_SYSTEM_PROMPT_KO,
}
}
pub fn build_agent_prompt(request: &AiChatRequest, settings: &AiSettings) -> String {
let ctx = &request.context;
let user_input = user_input_with_target_contexts(request);
match resolve_prompt_language(&request.options.language) {
PromptLanguage::ZhHans => format!(
r#"用户任务:
{user_input}
当前连接上下文:
- 连接名:{connection_name}
- 主机:{host}
- 用户:{username}
- 当前目录:{cwd}
- 操作系统:{os}
- 架构:{arch}
最近终端输出(最多 {line_limit} 行):
{recent_output}
要求:
- 面向用户的说明、总结以及推理过程使用:{language}
- 命令、路径、文件名、配置键名保持原样,不要翻译
- 通过 execute_command 执行的命令必须是非交互式的,不能等待用户输入、确认或翻页
- 对可能启动分页器的工具使用其非交互或禁用分页选项,例如 git --no-pager ...、journalctl --no-pager;不要依赖用户按键继续
- 如果命令必须进入交互式 UI、确认提示或等待输入且没有非交互替代方案,不要自动执行;改用非交互替代方案,或调用 final_answer 向用户说明
请开始执行任务。每轮调用且只调用一个工具。"#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
),
PromptLanguage::ZhHant => format!(
r#"使用者任務:
{user_input}
目前連線情境:
- 連線名稱:{connection_name}
- 主機:{host}
- 使用者:{username}
- 目前目錄:{cwd}
- 作業系統:{os}
- 架構:{arch}
最近終端輸出(最多 {line_limit} 行):
{recent_output}
要求:
- 面向使用者的說明、摘要以及推理過程使用:{language}
- 命令、路徑、檔名、設定鍵名保持原樣,不要翻譯
- 透過 execute_command 執行的命令必須是非互動式的,不能等待使用者輸入、確認或翻頁
- 對可能啟動分頁器的工具使用其非互動或停用分頁選項,例如 git --no-pager ...、journalctl --no-pager;不要依賴使用者按鍵繼續
- 如果命令必須進入互動式 UI、確認提示或等待輸入且沒有非互動替代方案,不要自動執行;改用非互動替代方案,或呼叫 final_answer 向使用者說明
請開始執行任務。每輪呼叫且只呼叫一個工具。"#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
),
PromptLanguage::Ko => format!(
r#"사용자 작업:
{user_input}
현재 연결 컨텍스트:
- 연결 이름: {connection_name}
- 호스트: {host}
- 사용자: {username}
- 현재 디렉터리: {cwd}
- 운영 체제: {os}
- 아키텍처: {arch}
최근 터미널 출력(최대 {line_limit}줄):
{recent_output}
요구 사항:
- 사용자에게 보이는 설명, 요약, 추론에는 {language}을(를) 사용하세요.
- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요.
- execute_command로 실행하는 명령은 비대화형이어야 하며 사용자 입력, 확인 또는 페이지 넘김을 기다리면 안 됩니다.
- pager를 실행할 수 있는 도구는 git --no-pager ..., journalctl --no-pager 같은 도구별 비대화형 또는 pager 비활성화 옵션을 사용하고 사용자 키 입력에 의존하지 마세요.
- 명령이 대화형 UI, 확인 프롬프트 또는 입력 대기를 반드시 필요로 하고 비대화형 대안이 없다면 자동 실행하지 말고, 비대화형 대안을 사용하거나 final_answer로 사용자에게 설명하세요.
지금 작업을 시작하세요. 각 턴에서 정확히 하나의 도구만 호출하세요."#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
),
PromptLanguage::En => format!(
r#"User task:
{user_input}
Current connection context:
- Connection name: {connection_name}
- Host: {host}
- User: {username}
- Current directory: {cwd}
- Operating system: {os}
- Architecture: {arch}
Recent terminal output (up to {line_limit} lines):
{recent_output}
Requirements:
- Use {language} for user-facing explanations and summaries.
- Prefer {language} for reasoning when possible.
- Keep commands, paths, file names, and configuration keys unchanged.
- Commands sent through execute_command must be non-interactive and must not wait for user input, confirmation, or pager navigation.
- For tools that may start a pager, use tool-specific non-interactive or no-pager options such as git --no-pager ... or journalctl --no-pager; never rely on user key presses to continue.
- If a command requires an interactive UI, confirmation prompt, or input and has no non-interactive alternative, do not execute it automatically; choose a non-interactive alternative or call final_answer to explain it to the user.
Start the task now. Call exactly one tool per turn."#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
),
}
}
pub fn build_observation_message(
obs: &CommandObservation,
command: &str,
language: &str,
) -> String {
let status = obs
.exit_code
.map(|c| format!("exit code {c}"))
.unwrap_or_else(|| "unknown exit code".to_string());
let output = if obs.output.len() > 8000 {
let mut start = obs.output.len() - 8000;
while !obs.output.is_char_boundary(start) {
start += 1;
}
let truncated = &obs.output[start..];
format!("...(truncated)\n{truncated}")
} else {
obs.output.clone()
};
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => format!(
"命令 `{command}` 执行完成({status},耗时 {duration}ms)。\n\n输出:\n{output}\n\n请根据观察结果决定下一步。每轮必须且只能调用一个工具:execute_command 或 final_answer。不要在普通正文里输出 JSON。",
duration = obs.duration_ms,
),
PromptLanguage::ZhHant => format!(
"命令 `{command}` 執行完成({status},耗時 {duration}ms)。\n\n輸出:\n{output}\n\n請根據觀察結果決定下一步。每輪必須且只能呼叫一個工具:execute_command 或 final_answer。不要在一般正文裡輸出 JSON。",
duration = obs.duration_ms,
),
PromptLanguage::Ko => format!(
"명령 `{command}` 실행이 완료되었습니다({status}, {duration}ms).\n\n출력:\n{output}\n\n이 관찰 결과를 바탕으로 다음 단계를 결정하세요. execute_command 또는 final_answer 중 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요.",
duration = obs.duration_ms,
),
PromptLanguage::En => format!(
"Command `{command}` finished ({status}, {duration}ms).\n\nOutput:\n{output}\n\nDecide the next step based on this observation. Call exactly one tool: execute_command or final_answer. Do not put protocol JSON in normal assistant text.",
duration = obs.duration_ms,
),
}
}
pub fn agent_send_only_observation(language: &str) -> &'static str {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => "命令已发送到终端,但当前会话使用仅发送模式,未捕获输出。",
PromptLanguage::ZhHant => "命令已傳送到終端,但目前工作階段使用僅傳送模式,未擷取輸出。",
PromptLanguage::Ko => {
"명령을 터미널로 보냈지만 현재 세션은 전송 전용 모드이므로 출력을 캡처하지 않았습니다."
}
PromptLanguage::En => {
"Command was sent to the terminal, but this session uses send-only mode, so output was not captured."
}
}
}
pub fn agent_execution_disabled_message(language: &str) -> &'static str {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => "当前会话已禁用 AI Agent 命令执行。",
PromptLanguage::ZhHant => "目前工作階段已停用 AI Agent 命令執行。",
PromptLanguage::Ko => "현재 세션에서 AI Agent 명령 실행이 비활성화되어 있습니다.",
PromptLanguage::En => "AI Agent command execution is disabled for the current session.",
}
}
pub fn build_agent_rejected_message(command: &str, language: &str) -> String {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => {
format!("用户拒绝执行命令 `{command}`。请换用其他方案或给出 final_answer。")
}
PromptLanguage::ZhHant => {
format!("使用者拒絕執行命令 `{command}`。請改用其他方案或呼叫 final_answer。")
}
PromptLanguage::Ko => {
format!(
"사용자가 명령 `{command}` 실행을 거부했습니다. 다른 방법을 사용하거나 final_answer를 제공하세요."
)
}
PromptLanguage::En => {
format!(
"The user rejected command `{command}`. Use another approach or provide final_answer."
)
}
}
}
pub fn build_agent_failed_message(error: &str, language: &str) -> String {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => format!("命令执行失败:{error}。请分析原因并给出下一步。"),
PromptLanguage::ZhHant => format!("命令執行失敗:{error}。請分析原因並提供下一步。"),
PromptLanguage::Ko => {
format!("명령 실행이 실패했습니다: {error}. 원인을 분석하고 다음 단계를 제시하세요.")
}
PromptLanguage::En => {
format!(
"Command execution failed: {error}. Analyze the cause and provide the next step."
)
}
}
}
pub fn build_agent_unknown_action_message(action: &str, fallback: &str, language: &str) -> String {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => {
format!("未知动作 `{action}`。将其作为最终回答处理。{fallback}")
}
PromptLanguage::ZhHant => {
format!("未知動作 `{action}`。將其作為最終回答處理。{fallback}")
}
PromptLanguage::Ko => {
format!("알 수 없는 작업 `{action}`입니다. 최종 답변으로 처리합니다. {fallback}")
}
PromptLanguage::En => {
format!("Unknown action `{action}`. Treating as final answer. {fallback}")
}
}
}
pub fn agent_max_steps_message(language: &str) -> &'static str {
match resolve_prompt_language(language) {
PromptLanguage::ZhHans => "Agent 已达到最大步数限制,任务可能未完成。",
PromptLanguage::ZhHant => "Agent 已達到最大步數限制,任務可能尚未完成。",
PromptLanguage::Ko => {
"Agent가 최대 단계 수 제한에 도달했으며 작업이 완료되지 않았을 수 있습니다."
}
PromptLanguage::En => {
"Agent reached the maximum step limit, so the task may be incomplete."
}
}
}
pub fn build_prompt(request: &AiChatRequest, settings: &AiSettings) -> String {
let ctx = &request.context;
let user_input = user_input_with_target_contexts(request);
match resolve_prompt_language(&request.options.language) {
PromptLanguage::ZhHans => {
let action = match request.action {
AiAction::GenerateCommand => "根据自然语言需求生成 1 到 2 条 Shell 命令",
AiAction::ExplainOutput => "解释最近终端输出并给出下一步建议",
AiAction::ExplainSelected => "解释用户选中的终端文本并给出下一步建议",
AiAction::AnalyzeError => "分析终端错误输出并给出排查步骤",
AiAction::RepairFromSelection => "根据选中内容生成修复或排查命令",
AiAction::CustomTerminalAction => "根据用户配置的终端 AI 功能处理选中内容",
AiAction::CustomFileAction => "根据用户配置的文件 AI 功能处理文件内容",
};
format!(
r#"任务:{action}
用户需求:
{user_input}
当前连接上下文:
- 连接名:{connection_name}
- 主机:{host}
- 端口:{port}
- 用户:{username}
- 当前目录:{cwd}
- 操作系统:{os}
- 架构:{arch}
- 当前输入:{input_buffer}
选中文本:
{selected_text}
最近终端输出(最多 {line_limit} 行):
{recent_output}
要求:
- 语言:{language}
- 面向用户的说明和推理过程使用该语言;命令、路径、文件名、配置键名保持原样
- 安全模式:{safety_mode}
- 最多生成 {max_commands} 条命令
- 优先生成只读诊断命令
- 如果信息不足,请给出验证命令
- 必须返回 JSON 对象,不要返回 Markdown"#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
port = ctx
.port
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
input_buffer = ctx.input_buffer,
selected_text = ctx.selected_text,
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
safety_mode = request.options.safety_mode,
max_commands = request.options.max_output_commands,
)
}
PromptLanguage::ZhHant => {
let action = match request.action {
AiAction::GenerateCommand => "根據自然語言需求產生 1 到 2 個 Shell 命令",
AiAction::ExplainOutput => "解釋最近終端輸出並提供下一步建議",
AiAction::ExplainSelected => "解釋使用者選取的終端文字並提供下一步建議",
AiAction::AnalyzeError => "分析終端錯誤輸出並提供排查步驟",
AiAction::RepairFromSelection => "根據選取內容產生修復或排查命令",
AiAction::CustomTerminalAction => "根據使用者設定的終端 AI 功能處理選取內容",
AiAction::CustomFileAction => "根據使用者設定的檔案 AI 功能處理檔案內容",
};
format!(
r#"任務:{action}
使用者需求:
{user_input}
目前連線情境:
- 連線名稱:{connection_name}
- 主機:{host}
- 連接埠:{port}
- 使用者:{username}
- 目前目錄:{cwd}
- 作業系統:{os}
- 架構:{arch}
- 目前輸入:{input_buffer}
選取文字:
{selected_text}
最近終端輸出(最多 {line_limit} 行):
{recent_output}
要求:
- 語言:{language}
- 面向使用者的說明和推理過程使用該語言;命令、路徑、檔名、設定鍵名保持原樣
- 安全模式:{safety_mode}
- 最多產生 {max_commands} 條命令
- 優先產生唯讀診斷命令
- 如果資訊不足,請提供驗證命令
- 必須回傳 JSON 物件,不要回傳 Markdown"#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
port = ctx
.port
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
input_buffer = ctx.input_buffer,
selected_text = ctx.selected_text,
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
safety_mode = request.options.safety_mode,
max_commands = request.options.max_output_commands,
)
}
PromptLanguage::Ko => {
let action = match request.action {
AiAction::GenerateCommand => "자연어 요청에서 Shell 명령 1~2개 생성",
AiAction::ExplainOutput => "최근 터미널 출력을 설명하고 다음 단계 제안",
AiAction::ExplainSelected => "선택한 터미널 텍스트를 설명하고 다음 단계 제안",
AiAction::AnalyzeError => "터미널 오류 출력을 분석하고 문제 해결 단계 제공",
AiAction::RepairFromSelection => "선택한 내용에서 복구 또는 문제 해결 명령 생성",
AiAction::CustomTerminalAction => {
"사용자가 구성한 터미널 AI 작업으로 선택한 내용 처리"
}
AiAction::CustomFileAction => "사용자가 구성한 파일 AI 작업으로 파일 내용 처리",
};
format!(
r#"작업: {action}
사용자 요청:
{user_input}
현재 연결 컨텍스트:
- 연결 이름: {connection_name}
- 호스트: {host}
- 포트: {port}
- 사용자: {username}
- 현재 디렉터리: {cwd}
- 운영 체제: {os}
- 아키텍처: {arch}
- 현재 입력: {input_buffer}
선택한 텍스트:
{selected_text}
최근 터미널 출력(최대 {line_limit}줄):
{recent_output}
요구 사항:
- 대상 언어: {language}
- 사용자에게 보이는 설명과 추론에는 가능하면 해당 언어를 사용하세요.
- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요.
- 안전 모드: {safety_mode}
- 최대 {max_commands}개의 명령을 생성하세요.
- 읽기 전용 진단 명령을 먼저 우선하세요.
- 정보가 부족하면 확인 명령을 제공하세요.
- JSON 객체만 반환하세요. Markdown을 반환하지 마세요."#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
port = ctx
.port
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
input_buffer = ctx.input_buffer,
selected_text = ctx.selected_text,
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
safety_mode = request.options.safety_mode,
max_commands = request.options.max_output_commands,
)
}
PromptLanguage::En => {
let action = match request.action {
AiAction::GenerateCommand => {
"Generate 1 to 2 Shell commands from the natural language request"
}
AiAction::ExplainOutput => {
"Explain the recent terminal output and suggest the next step"
}
AiAction::ExplainSelected => {
"Explain the selected terminal text and suggest the next step"
}
AiAction::AnalyzeError => {
"Analyze the terminal error output and provide troubleshooting steps"
}
AiAction::RepairFromSelection => {
"Generate repair or troubleshooting commands from the selected content"
}
AiAction::CustomTerminalAction => {
"Handle the selected content using the configured terminal AI action"
}
AiAction::CustomFileAction => {
"Handle the file content using the configured file AI action"
}
};
format!(
r#"Task: {action}
User request:
{user_input}
Current connection context:
- Connection name: {connection_name}
- Host: {host}
- Port: {port}
- User: {username}
- Current directory: {cwd}
- Operating system: {os}
- Architecture: {arch}
- Current input: {input_buffer}
Selected text:
{selected_text}
Recent terminal output (up to {line_limit} lines):
{recent_output}
Requirements:
- Target language: {language}
- Use that language for user-facing explanation and reasoning when possible.
- Keep commands, paths, file names, and configuration keys unchanged.
- Safety mode: {safety_mode}
- Generate at most {max_commands} commands.
- Prefer read-only diagnostic commands first.
- If information is insufficient, provide verification commands.
- Return a JSON object only. Do not return Markdown."#,
user_input = user_input,
connection_name = ctx.connection_name.as_deref().unwrap_or("-"),
host = ctx.host.as_deref().unwrap_or("-"),
port = ctx
.port
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
username = ctx.username.as_deref().unwrap_or("-"),
cwd = ctx.cwd.as_deref().unwrap_or("-"),
os = ctx.os.as_deref().unwrap_or("-"),
arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH),
input_buffer = ctx.input_buffer,
selected_text = ctx.selected_text,
line_limit = settings.context_line_limit,
recent_output = ctx.recent_output,
language = request.options.language,
safety_mode = request.options.safety_mode,
max_commands = request.options.max_output_commands,
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AiMode;
use super::super::types::{AiContext, AiRequestOptions};
fn test_request(language: &str) -> AiChatRequest {
AiChatRequest {
stream_id: None,
session_id: None,
connection_id: None,
terminal_session_id: None,
owner_scope: Default::default(),
targets: vec![],
target_contexts: vec![],
mode: AiMode::Ask,
agent_kind: crate::config::AiAgentKind::Nyaterm,
permission_mode: crate::config::AiPermissionMode::Confirm,
model_id: None,
model_name: None,
default_target_session_id: None,
existing_external_session_id: None,
attachments: vec![],
action: AiAction::GenerateCommand,
user_input: "check disk usage".to_string(),
context: AiContext {
connection_name: Some("prod".to_string()),
host: Some("example.com".to_string()),
username: Some("ops".to_string()),
cwd: Some("/srv/app".to_string()),
os: Some("Linux".to_string()),
recent_output: "Filesystem Size Used Avail Use% Mounted on".to_string(),
..AiContext::default()
},
options: AiRequestOptions {
language: language.to_string(),
..AiRequestOptions::default()
},
}
}
#[test]
fn resolves_chinese_locale_variants_to_writing_systems() {
let simplified_cases = ["zh", "zh-CN", "zh_CN", "zh-SG", "zh-Hans", "zh-Hans-CN"];
for language in simplified_cases {
assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHans);
}
let traditional_cases = ["zh-TW", "zh_TW", "zh-Hant", "zh-Hant-TW", "zh-HK", "zh-MO"];
for language in traditional_cases {
assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHant);
}
assert_eq!(resolve_prompt_language("zh-unknown"), PromptLanguage::En);
}
#[test]
fn returns_traditional_chinese_system_prompts() {
assert!(system_prompt("zh-TW").contains("雲端原生終端助手"));
assert!(agent_system_prompt("zh-Hant").contains("終端自動化 Agent"));
}
#[test]
fn builds_traditional_chinese_chat_and_agent_prompts() {
let settings = AiSettings::default();
let request = test_request("zh-TW");
let prompt = build_prompt(&request, &settings);
assert!(prompt.contains("任務:根據自然語言需求產生"));
assert!(prompt.contains("目前連線情境:"));
assert!(prompt.contains("語言:zh-TW"));
let agent_prompt = build_agent_prompt(&request, &settings);
assert!(agent_prompt.contains("使用者任務:"));
assert!(agent_prompt.contains("最近終端輸出"));
assert!(agent_prompt.contains("zh-TW"));
}
#[test]
fn agent_prompts_require_non_interactive_commands() {
let settings = AiSettings::default();
for (language, localized_rule) in [
("zh-CN", "不能等待用户输入"),
("zh-TW", "不能等待使用者輸入"),
("en", "must be non-interactive"),
("ko", "비대화형이어야"),
] {
let request = test_request(language);
let prompt = build_agent_prompt(&request, &settings);
assert!(prompt.contains(localized_rule), "language={language}");
assert!(prompt.contains("git --no-pager"), "language={language}");
assert!(
prompt.contains("journalctl --no-pager"),
"language={language}"
);
assert!(prompt.contains("final_answer"), "language={language}");
}
}
#[test]
fn builds_traditional_chinese_agent_runtime_messages() {
assert!(agent_send_only_observation("zh-TW").contains("僅傳送模式"));
assert!(agent_execution_disabled_message("zh-Hant").contains("已停用"));
assert!(build_agent_rejected_message("rm -rf tmp", "zh-TW").contains("拒絕執行"));
assert!(build_agent_failed_message("boom", "zh-TW").contains("下一步"));
assert!(
build_agent_unknown_action_message("noop", "fallback", "zh-TW").contains("fallback")
);
assert!(agent_max_steps_message("zh-TW").contains("最大步數限制"));
}
#[test]
fn resolves_korean_locale_variants() {
assert!(resolve_prompt_language("ko") == PromptLanguage::Ko);
assert!(resolve_prompt_language("ko-KR") == PromptLanguage::Ko);
assert!(resolve_prompt_language("ko_KR") == PromptLanguage::Ko);
}
#[test]
fn returns_korean_system_prompts() {
assert!(system_prompt("ko").contains("터미널 어시스턴트"));
assert!(agent_system_prompt("ko").contains("터미널 자동화 Agent"));
}
#[test]
fn builds_korean_chat_and_agent_prompts() {
let settings = AiSettings::default();
let request = test_request("ko");
let prompt = build_prompt(&request, &settings);
assert!(prompt.contains("작업: 자연어 요청에서 Shell 명령"));
assert!(prompt.contains("현재 연결 컨텍스트:"));
assert!(prompt.contains("대상 언어: ko"));
let agent_prompt = build_agent_prompt(&request, &settings);
assert!(agent_prompt.contains("사용자 작업:"));
assert!(agent_prompt.contains("최근 터미널 출력"));
assert!(agent_prompt.contains("ko"));
}
#[test]
fn builds_korean_observation_message() {
let obs = CommandObservation {
output: "ok".to_string(),
exit_code: Some(0),
duration_ms: 42,
};
let message = build_observation_message(&obs, "ls", "ko-KR");
assert!(message.contains("명령 `ls` 실행이 완료되었습니다"));
assert!(message.contains("출력:"));
assert!(message.contains("execute_command 또는 final_answer"));
}
#[test]
fn safely_truncates_multibyte_observation_output() {
let obs = CommandObservation {
output: "你".repeat(3000),
exit_code: Some(0),
duration_ms: 42,
};
let message = build_observation_message(&obs, "cat file", "zh-CN");
assert!(message.contains("...(truncated)\n你"));
assert!(message.contains("命令 `cat file` 执行完成"));
}
#[test]
fn builds_korean_agent_runtime_messages() {
assert!(agent_send_only_observation("ko").contains("전송 전용 모드"));
assert!(agent_execution_disabled_message("ko-KR").contains("비활성화"));
assert!(build_agent_rejected_message("rm -rf tmp", "ko").contains("거부했습니다"));
assert!(build_agent_failed_message("boom", "ko").contains("다음 단계"));
assert!(build_agent_unknown_action_message("noop", "fallback", "ko").contains("fallback"));
assert!(agent_max_steps_message("ko").contains("최대 단계 수"));
}
}
@@ -0,0 +1,113 @@
use regex::Regex;
use std::sync::OnceLock;
use super::types::AiContext;
pub fn redact_context(context: &mut AiContext) {
context.recent_output = redact_sensitive_text(&context.recent_output);
context.selected_text = redact_sensitive_text(&context.selected_text);
context.input_buffer = redact_sensitive_text(&context.input_buffer);
}
pub fn redact_sensitive_text(input: &str) -> String {
let mut output = input.to_string();
for (pattern, replacement) in redaction_patterns() {
output = pattern.replace_all(&output, *replacement).to_string();
}
output
}
pub fn redact_marker_values(input: &str, markers: &[&str]) -> String {
let mut output = input.to_string();
for marker in markers.iter().copied().filter(|marker| !marker.is_empty()) {
let mut search_start = 0;
while let Some(relative_index) = output[search_start..].find(marker) {
let marker_index = search_start + relative_index;
let value_start = marker_index + marker.len();
let value_end = output[value_start..]
.find(['&', ' ', '"'])
.map(|offset| value_start + offset)
.unwrap_or(output.len());
output.replace_range(value_start..value_end, "[redacted]");
search_start = value_start + "[redacted]".len();
}
}
output
}
fn redaction_patterns() -> &'static [(Regex, &'static str)] {
static PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
PATTERNS.get_or_init(|| {
vec![
(
Regex::new(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
)
.unwrap(),
"[REDACTED_PRIVATE_KEY]",
),
(
Regex::new(r"(?i)Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+").unwrap(),
"Authorization: Bearer [REDACTED]",
),
(
Regex::new(r"(?i)(password|passwd|pwd)\s*[:=]\s*[^\s;&|]+").unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(
r"(?i)(token|api[_-]?key|secret[_-]?key|access[_-]?key)\s*[:=]\s*[^\s;&|]+",
)
.unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
"[REDACTED_AWS_ACCESS_KEY]",
),
(
Regex::new(r"(?i)(postgres|mysql|mongodb)://[^@\s]+@").unwrap(),
"$1://[REDACTED]@",
),
]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sensitive_values() {
let raw = "password=secret token:abc Authorization: Bearer abc.def AKIA1234567890ABCDEF";
let redacted = redact_sensitive_text(raw);
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("abc.def"));
assert!(!redacted.contains("AKIA1234567890ABCDEF"));
}
#[test]
fn redacts_marker_values_without_revisiting_redacted_markers() {
let raw = "access_token=abc access_token=[redacted] code=xyz&state=ok refresh_token=end";
let redacted = redact_marker_values(raw, &["access_token=", "code=", "refresh_token="]);
assert_eq!(
redacted,
"access_token=[redacted] access_token=[redacted] code=[redacted]&state=ok refresh_token=[redacted]"
);
assert!(!redacted.contains("abc"));
assert!(!redacted.contains("code=xyz"));
assert!(!redacted.contains("refresh_token=end"));
}
#[test]
fn redacts_marker_values_until_space_quote_ampersand_or_line_end() {
let raw = r#"api_key=one next="api_key=two" code=three&state=ok id_token=four"#;
let redacted = redact_marker_values(raw, &["api_key=", "code=", "id_token="]);
assert_eq!(
redacted,
r#"api_key=[redacted] next="api_key=[redacted]" code=[redacted]&state=ok id_token=[redacted]"#
);
}
}
@@ -0,0 +1,721 @@
use std::time::Duration;
use super::stream::AiEventSink;
use genai::chat::{ChatMessage, ChatRole};
use serde::Serialize;
use serde_json::{Value, json};
use tokio::sync::oneshot;
use crate::config::{
AiApiFormat, AiProviderApiProtocol, AiProviderKind, AiReasoningEffort, AiSettings,
};
use crate::error::{AppError, AppResult};
use crate::utils::url::{join_api_base_url, normalize_api_base_url};
use super::http::build_http_client;
use super::model::ResolvedAiModel;
use super::parser::{trim_string_to_option, truncate_preview};
use super::stream::{AiStreamResult, emit_stream_event};
use super::types::{AiChatRequest, AiStreamEventPayload};
const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/";
#[derive(Debug, Clone, Serialize)]
struct ResponsesInputMessage {
role: &'static str,
content: String,
}
#[derive(Debug)]
enum ResponsesSseEvent {
TextDelta(String),
ReasoningDelta(String),
Completed {
text: Option<String>,
reasoning: Option<String>,
},
Error(String),
Ignored,
}
pub fn uses_responses_api(model: &ResolvedAiModel) -> bool {
if model.api_format != AiApiFormat::Responses {
return false;
}
match model
.credential
.as_ref()
.and_then(|credential| credential.api_protocol.as_ref())
{
Some(AiProviderApiProtocol::OpenaiCompatible) => true,
Some(_) => false,
None => matches!(
model.provider_kind,
AiProviderKind::Openai | AiProviderKind::OpenaiCompatible
),
}
}
pub async fn test_responses_model(
settings: &AiSettings,
model: &ResolvedAiModel,
system_prompt: &str,
user_prompt: &str,
max_output_tokens: u32,
) -> AppResult<()> {
let mut body = json!({
"model": model.model_name,
"input": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
"max_output_tokens": max_output_tokens,
"stream": false,
"store": false,
});
if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) {
body["reasoning"] = json!({ "effort": effort });
}
let response = responses_request(settings, model, &body)?
.send()
.await
.map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(AppError::Config(format!(
"AI model test failed: {status} {}",
truncate_preview(&body, 500)
)));
}
let body: Value = response
.json()
.await
.map_err(|error| AppError::Config(format!("Invalid AI model test response: {error}")))?;
if let Some(error) = body.get("error").filter(|error| !error.is_null()) {
let message = error
.get("message")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
return Err(AppError::Config(format!("AI model test failed: {message}")));
}
if matches!(body["status"].as_str(), Some("failed" | "cancelled")) {
return Err(AppError::Config("AI model test failed".to_string()));
}
if body["id"].as_str().is_none() && body["output"].as_array().is_none() {
return Err(AppError::Config(
"AI model test returned an invalid response".to_string(),
));
}
Ok(())
}
pub async fn run_responses_chat_messages_stream(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
settings: &AiSettings,
resolved_model: &ResolvedAiModel,
messages: &[ChatMessage],
cancel_rx: &mut oneshot::Receiver<()>,
) -> AppResult<AiStreamResult> {
let input = responses_input_messages(messages)?;
run_responses_input_stream(
app,
stream_id,
request,
settings,
resolved_model,
input,
true,
cancel_rx,
)
.await
}
pub async fn run_responses_chat_messages_stream_without_text_deltas(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
settings: &AiSettings,
resolved_model: &ResolvedAiModel,
messages: &[ChatMessage],
cancel_rx: &mut oneshot::Receiver<()>,
) -> AppResult<AiStreamResult> {
let input = responses_input_messages(messages)?;
run_responses_input_stream(
app,
stream_id,
request,
settings,
resolved_model,
input,
false,
cancel_rx,
)
.await
}
fn responses_input_messages(messages: &[ChatMessage]) -> AppResult<Vec<ResponsesInputMessage>> {
let mut input = Vec::new();
for message in messages {
let role = match message.role {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => continue,
};
let content = message.content.joined_texts().unwrap_or_default();
if content.trim().is_empty() {
continue;
}
input.push(ResponsesInputMessage { role, content });
}
if input.is_empty() {
return Err(AppError::Config(
"Responses API request has no text input".to_string(),
));
}
Ok(input)
}
async fn run_responses_input_stream(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
settings: &AiSettings,
resolved_model: &ResolvedAiModel,
input: Vec<ResponsesInputMessage>,
emit_text_deltas: bool,
cancel_rx: &mut oneshot::Receiver<()>,
) -> AppResult<AiStreamResult> {
let url = responses_url(resolved_model)?;
let mut body = json!({
"model": resolved_model.model_name,
"input": input,
"stream": true,
"store": false,
});
if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) {
body["reasoning"] = json!({ "effort": effort });
}
let req = responses_request(settings, resolved_model, &body)?;
tracing::debug!(
stream_id = %stream_id,
model_name = %resolved_model.model_name,
provider_kind = ?resolved_model.provider_kind,
url = %url,
"Dispatching Responses API stream request"
);
let send_future = req
.timeout(Duration::from_millis(settings.timeout_ms))
.send();
let mut resp = tokio::select! {
_ = &mut *cancel_rx => {
return Err(AppError::Cancelled("AI stream cancelled".to_string()));
}
result = send_future => {
result.map_err(|error| AppError::Config(format!("AI request failed: {error}")))?
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Config(format!(
"Responses API request failed: {status} {body}"
)));
}
let mut output = String::new();
let mut reasoning_output = String::new();
let mut sse_buffer = String::new();
let idle_duration = Duration::from_millis(settings.timeout_ms);
let idle_deadline = tokio::time::sleep(idle_duration);
tokio::pin!(idle_deadline);
loop {
tokio::select! {
_ = &mut idle_deadline => {
return Err(AppError::Config("AI stream timed out (no data received)".to_string()));
}
_ = &mut *cancel_rx => {
return Err(AppError::Cancelled("AI stream cancelled".to_string()));
}
chunk = resp.chunk() => {
idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration);
let Some(chunk) = chunk
.map_err(|error| AppError::Config(format!("AI stream failed: {error}")))?
else {
break;
};
sse_buffer.push_str(&String::from_utf8_lossy(&chunk));
process_sse_buffer(
app,
stream_id,
request,
&mut sse_buffer,
&mut output,
&mut reasoning_output,
emit_text_deltas,
)?;
}
}
}
if !sse_buffer.trim().is_empty() {
process_sse_block_into_output(
app,
stream_id,
request,
sse_buffer.trim(),
&mut output,
&mut reasoning_output,
emit_text_deltas,
)?;
}
tracing::info!(
stream_id = %stream_id,
text_len = output.len(),
reasoning_len = reasoning_output.len(),
text_preview = %truncate_preview(&output, 200),
reasoning_preview = %truncate_preview(&reasoning_output, 200),
"Responses API stream completed"
);
Ok(AiStreamResult {
text: output,
reasoning_content: trim_string_to_option(reasoning_output),
})
}
fn process_sse_buffer(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
buffer: &mut String,
output: &mut String,
reasoning_output: &mut String,
emit_text_deltas: bool,
) -> AppResult<()> {
while let Some((index, separator_len)) = find_sse_separator(buffer) {
let block = buffer[..index].trim().to_string();
buffer.drain(..index + separator_len);
if !block.is_empty() {
process_sse_block_into_output(
app,
stream_id,
request,
&block,
output,
reasoning_output,
emit_text_deltas,
)?;
}
}
Ok(())
}
fn find_sse_separator(buffer: &str) -> Option<(usize, usize)> {
let lf = buffer.find("\n\n").map(|index| (index, 2));
let crlf = buffer.find("\r\n\r\n").map(|index| (index, 4));
match (lf, crlf) {
(Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }),
(Some(found), None) | (None, Some(found)) => Some(found),
(None, None) => None,
}
}
fn process_sse_block_into_output(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
block: &str,
output: &mut String,
reasoning_output: &mut String,
emit_text_deltas: bool,
) -> AppResult<()> {
match parse_responses_sse_block(block)? {
ResponsesSseEvent::TextDelta(text_delta) => {
if !text_delta.is_empty() {
output.push_str(&text_delta);
if emit_text_deltas {
emit_stream_event(
app,
stream_id,
AiStreamEventPayload {
event_type: "delta".to_string(),
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: Some(text_delta),
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
error: None,
},
);
}
}
}
ResponsesSseEvent::ReasoningDelta(reasoning_delta) => {
if !reasoning_delta.is_empty() {
reasoning_output.push_str(&reasoning_delta);
emit_stream_event(
app,
stream_id,
AiStreamEventPayload {
event_type: "reasoning_delta".to_string(),
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: None,
reasoning_delta: Some(reasoning_delta),
message: None,
command_cards: vec![],
usage: None,
error: None,
},
);
}
}
ResponsesSseEvent::Completed { text, reasoning } => {
if output.is_empty()
&& let Some(text) = text
{
output.push_str(&text);
}
if reasoning_output.is_empty()
&& let Some(reasoning) = reasoning
{
reasoning_output.push_str(&reasoning);
}
}
ResponsesSseEvent::Error(message) => {
return Err(AppError::Config(format!(
"Responses API stream failed: {message}"
)));
}
ResponsesSseEvent::Ignored => {}
}
Ok(())
}
fn parse_responses_sse_block(block: &str) -> AppResult<ResponsesSseEvent> {
let mut event_type: Option<&str> = None;
let mut data_lines = Vec::new();
for line in block.lines() {
let line = line.trim_end_matches('\r');
if let Some(value) = line.strip_prefix("event:") {
event_type = Some(value.trim());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim_start());
}
}
let data = data_lines.join("\n");
if data.is_empty() || data == "[DONE]" {
return Ok(ResponsesSseEvent::Ignored);
}
let value: Value = serde_json::from_str(&data)
.map_err(|error| AppError::Config(format!("Invalid Responses API stream JSON: {error}")))?;
let kind = value
.get("type")
.and_then(Value::as_str)
.or(event_type)
.unwrap_or_default();
if kind == "error" {
return Ok(ResponsesSseEvent::Error(error_message(&value)));
}
if kind == "response.output_text.delta" {
return Ok(ResponsesSseEvent::TextDelta(
value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
));
}
if kind.ends_with(".delta") && kind.contains("reasoning") {
return Ok(ResponsesSseEvent::ReasoningDelta(
value
.get("delta")
.or_else(|| value.get("text"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
));
}
if kind == "response.completed" {
let response = value.get("response").unwrap_or(&value);
return Ok(ResponsesSseEvent::Completed {
text: completed_output_text(response),
reasoning: completed_reasoning_text(response),
});
}
Ok(ResponsesSseEvent::Ignored)
}
fn error_message(value: &Value) -> String {
value
.get("error")
.and_then(|error| {
error
.get("message")
.or_else(|| error.get("type"))
.and_then(Value::as_str)
})
.or_else(|| value.get("message").and_then(Value::as_str))
.unwrap_or("Unknown Responses API error")
.to_string()
}
fn completed_output_text(response: &Value) -> Option<String> {
let mut text = String::new();
for item in response.get("output")?.as_array()? {
if item.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let Some(content) = item.get("content").and_then(Value::as_array) else {
continue;
};
for part in content {
if part.get("type").and_then(Value::as_str) == Some("output_text")
&& let Some(part_text) = part.get("text").and_then(Value::as_str)
{
text.push_str(part_text);
}
}
}
trim_string_to_option(text)
}
fn completed_reasoning_text(response: &Value) -> Option<String> {
let mut text = String::new();
for item in response.get("output")?.as_array()? {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
continue;
}
for key in ["summary", "content"] {
if let Some(parts) = item.get(key).and_then(Value::as_array) {
for part in parts {
if let Some(part_text) = part
.get("text")
.or_else(|| part.get("summary"))
.and_then(Value::as_str)
{
if !text.is_empty() {
text.push('\n');
}
text.push_str(part_text);
}
}
}
}
}
trim_string_to_option(text)
}
fn responses_reasoning_effort(value: &AiReasoningEffort) -> Option<&'static str> {
match value {
AiReasoningEffort::Auto => None,
AiReasoningEffort::None => Some("none"),
AiReasoningEffort::Minimal => Some("minimal"),
AiReasoningEffort::Low => Some("low"),
AiReasoningEffort::Medium => Some("medium"),
AiReasoningEffort::High => Some("high"),
AiReasoningEffort::XHigh => Some("xhigh"),
AiReasoningEffort::Max => Some("max"),
AiReasoningEffort::Ultra => Some("ultra"),
}
}
pub fn responses_request(
settings: &AiSettings,
model: &ResolvedAiModel,
body: &Value,
) -> AppResult<reqwest::RequestBuilder> {
let mut request = build_http_client(settings)?
.post(responses_url(model)?)
.json(body);
if let Some(key) = model
.credential
.as_ref()
.and_then(|credential| credential.api_key.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.bearer_auth(key);
}
Ok(request)
}
fn responses_url(model: &ResolvedAiModel) -> AppResult<String> {
let base_url = responses_base_url(model)?;
join_api_base_url(&base_url, "responses")
}
fn responses_base_url(model: &ResolvedAiModel) -> AppResult<String> {
let configured = model
.credential
.as_ref()
.and_then(|credential| credential.base_url.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty());
match (configured, &model.provider_kind) {
(Some(base_url), _) => normalize_api_base_url(base_url),
(None, AiProviderKind::Openai) => Ok(OPENAI_DEFAULT_BASE_URL.to_string()),
(None, AiProviderKind::OpenaiCompatible) => Err(AppError::Config(
"Responses API requires an API base URL for OpenAI-compatible credentials".to_string(),
)),
(None, provider_kind) => Err(AppError::Config(format!(
"Responses API requires an API base URL for {provider_kind:?}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AiProviderCredential, AiProviderKind};
fn resolved_model(base_url: Option<&str>) -> ResolvedAiModel {
ResolvedAiModel {
model_name: "gpt-test".to_string(),
provider_kind: AiProviderKind::OpenaiCompatible,
api_format: AiApiFormat::Responses,
credential: Some(AiProviderCredential {
id: "credential-test".to_string(),
name: "Test".to_string(),
provider_kind: AiProviderKind::OpenaiCompatible,
icon_data_url: None,
api_protocol: None,
api_format: AiApiFormat::Responses,
base_url: base_url.map(str::to_string),
api_key: Some("key".to_string()),
enabled: true,
}),
}
}
#[test]
fn responses_url_accepts_missing_trailing_slash() {
assert_eq!(
responses_url(&resolved_model(Some("https://api.example.com/v1"))).unwrap(),
"https://api.example.com/v1/responses"
);
}
#[test]
fn responses_url_accepts_trailing_slash() {
assert_eq!(
responses_url(&resolved_model(Some("https://api.example.com/v1/"))).unwrap(),
"https://api.example.com/v1/responses"
);
}
#[test]
fn responses_url_preserves_query() {
assert_eq!(
responses_url(&resolved_model(Some(
"https://api.example.com/v1?api-version=1"
)))
.unwrap(),
"https://api.example.com/v1/responses?api-version=1"
);
}
#[test]
fn parses_text_delta_event() {
let event = parse_responses_sse_block(
r#"event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"hello"}"#,
)
.unwrap();
assert!(matches!(event, ResponsesSseEvent::TextDelta(ref text) if text == "hello"));
}
#[test]
fn parses_reasoning_delta_event() {
let event = parse_responses_sse_block(
r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking"}"#,
)
.unwrap();
assert!(matches!(event, ResponsesSseEvent::ReasoningDelta(ref text) if text == "thinking"));
}
#[test]
fn parses_completed_event_text() {
let event = parse_responses_sse_block(
r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}"#,
)
.unwrap();
assert!(matches!(
event,
ResponsesSseEvent::Completed { text: Some(ref text), .. } if text == "done"
));
}
#[test]
fn parses_error_event() {
let event = parse_responses_sse_block(
r#"event: error
data: {"type":"error","error":{"message":"bad request"}}"#,
)
.unwrap();
assert!(matches!(event, ResponsesSseEvent::Error(ref message) if message == "bad request"));
}
#[test]
fn rejects_malformed_json() {
let error = parse_responses_sse_block("data: {not-json").unwrap_err();
assert!(
error
.to_string()
.contains("Invalid Responses API stream JSON")
);
}
#[test]
fn finds_crlf_sse_separator() {
assert_eq!(find_sse_separator("data: {}\r\n\r\nnext"), Some((8, 4)));
}
#[test]
fn routes_only_openai_protocol_responses_credentials() {
let mut model = resolved_model(Some("https://api.example.com/v1/"));
assert!(uses_responses_api(&model));
model.provider_kind = AiProviderKind::Anthropic;
assert!(!uses_responses_api(&model));
model.provider_kind = AiProviderKind::Openai;
model.api_format = AiApiFormat::ChatCompletions;
assert!(!uses_responses_api(&model));
}
}
@@ -0,0 +1,200 @@
use super::{
history::load_history,
model::{build_chat_options, build_client, resolve_request_model},
parser::{extract_text_from_assistant, trim_string_to_option},
prompt::{build_prompt, system_prompt},
types::{AiChatRequest, AiMessage, AiMessageRole, AiStreamEventPayload},
};
use crate::config::AiSettings;
use crate::error::{AppError, AppResult};
use futures_util::StreamExt;
use genai::chat::{ChatMessage, ChatRequest, ChatStreamEvent};
use std::time::Duration;
use tokio::sync::oneshot;
pub trait AiEventSink: Send + Sync {
fn emit(&self, stream_id: &str, payload: AiStreamEventPayload);
}
pub struct EventSink<F>(pub F);
impl<F: Fn(&str, AiStreamEventPayload) + Send + Sync> AiEventSink for EventSink<F> {
fn emit(&self, id: &str, payload: AiStreamEventPayload) {
(self.0)(id, payload)
}
}
pub fn emit_stream_event(sink: &impl AiEventSink, id: &str, payload: AiStreamEventPayload) {
sink.emit(id, payload)
}
#[derive(Debug, Clone)]
pub struct AiStreamResult {
pub text: String,
pub reasoning_content: Option<String>,
}
pub async fn run_model_stream(
app: &impl AiEventSink,
stream_id: &str,
request: &AiChatRequest,
settings: &AiSettings,
cancel_rx: &mut oneshot::Receiver<()>,
) -> AppResult<AiStreamResult> {
tracing::debug!(
stream_id = %stream_id,
action = ?request.action,
session_id = ?request.session_id,
"Preparing AI model stream"
);
let resolved_model = resolve_request_model(settings, request)?;
let prompt = build_prompt(request, settings);
let mut messages = vec![ChatMessage::system(system_prompt(
&request.options.language,
))];
if let Some(session_id) = &request.session_id {
let max_turns = request.options.history_turns as usize;
if max_turns > 0 {
if let Ok(history) = load_history(app) {
let history_msgs: Vec<&AiMessage> = history
.messages
.iter()
.filter(|m| m.session_id == *session_id)
.collect();
let skip = history_msgs.len().saturating_sub(max_turns);
for msg in history_msgs.into_iter().skip(skip) {
match msg.role {
AiMessageRole::User => {
messages.push(ChatMessage::user(&msg.content));
}
AiMessageRole::Assistant => {
let content = extract_text_from_assistant(&msg.content);
if !content.is_empty() {
messages.push(ChatMessage::assistant(&content));
}
}
AiMessageRole::System => {}
}
}
}
}
}
messages.push(ChatMessage::user(prompt));
if super::responses::uses_responses_api(&resolved_model) {
return super::responses::run_responses_chat_messages_stream(
app,
stream_id,
request,
settings,
&resolved_model,
&messages,
cancel_rx,
)
.await;
}
let client = build_client(&resolved_model, settings)?;
tracing::debug!(
stream_id = %stream_id,
message_count = messages.len(),
model_name = %resolved_model.model_name,
provider_kind = ?resolved_model.provider_kind,
"Dispatching AI model stream request"
);
let chat_req = ChatRequest::new(messages);
let chat_options = build_chat_options(settings);
let stream_result = tokio::time::timeout(
Duration::from_millis(settings.timeout_ms),
client.exec_chat_stream(&resolved_model.model_name, chat_req, Some(&chat_options)),
)
.await
.map_err(|_| AppError::Config("AI request timed out".to_string()))?
.map_err(|error| AppError::Config(format!("AI request failed: {error}")))?;
let mut stream = stream_result.stream;
let mut output = String::new();
let mut reasoning_output = String::new();
let idle_duration = Duration::from_millis(settings.timeout_ms);
let idle_deadline = tokio::time::sleep(idle_duration);
tokio::pin!(idle_deadline);
loop {
tokio::select! {
_ = &mut idle_deadline => {
return Err(AppError::Config("AI stream timed out (no data received)".to_string()));
}
_ = &mut *cancel_rx => {
return Err(AppError::Cancelled("AI stream cancelled".to_string()));
}
item = stream.next() => {
idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration);
match item {
Some(Ok(ChatStreamEvent::Chunk(chunk))) => {
let text_delta = chunk.content;
if !text_delta.is_empty() {
output.push_str(&text_delta);
emit_stream_event(app, stream_id, AiStreamEventPayload {
event_type: "delta".to_string(),
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: Some(text_delta),
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
error: None,
});
}
}
Some(Ok(ChatStreamEvent::ReasoningChunk(chunk))) => {
let reasoning_delta = chunk.content;
if !reasoning_delta.is_empty() {
reasoning_output.push_str(&reasoning_delta);
emit_stream_event(app, stream_id, AiStreamEventPayload {
event_type: "reasoning_delta".to_string(),
stream_id: stream_id.to_string(),
session_id: request.session_id.clone(),
text_delta: None,
reasoning_delta: Some(reasoning_delta),
message: None,
command_cards: vec![],
usage: None,
error: None,
});
}
}
Some(Ok(ChatStreamEvent::End(end))) => {
if reasoning_output.is_empty() {
if let Some(captured_reasoning_content) = end.captured_reasoning_content {
reasoning_output = captured_reasoning_content;
}
}
break;
}
None => break,
Some(Ok(_)) => {}
Some(Err(error)) => {
return Err(AppError::Config(format!("AI stream failed: {error}")));
}
}
}
}
}
tracing::info!(
stream_id = %stream_id,
text_len = output.len(),
reasoning_len = reasoning_output.len(),
"AI model stream completed"
);
Ok(AiStreamResult {
text: output,
reasoning_content: trim_string_to_option(reasoning_output),
})
}
@@ -0,0 +1,553 @@
use crate::config::{
AiAgentKind, AiBackendKind, AiMode, AiModelSource, AiPermissionMode, AiProviderKind, RiskLevel,
};
use crate::core::capabilities::RiskReasonCode;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiSessionScopeType {
Terminal,
Workspace,
Global,
Unbound,
}
impl Default for AiSessionScopeType {
fn default() -> Self {
Self::Unbound
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiSessionScope {
#[serde(default)]
pub r#type: AiSessionScopeType,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub connection_ids: Vec<String>,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTerminalTarget {
pub terminal_session_id: String,
#[serde(default)]
pub connection_id: Option<String>,
pub label: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub username: Option<String>,
pub session_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AiTargetContext {
pub target: Option<AiTerminalTarget>,
#[serde(default)]
pub context: AiContext,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AiAttachment {
pub id: String,
pub name: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub mime_type: Option<String>,
#[serde(default)]
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommandCard {
pub id: String,
pub title: String,
pub command: String,
pub explanation: String,
#[serde(default)]
pub risk_level: Option<RiskLevel>,
#[serde(default)]
pub risk_reason: Option<String>,
pub expected_effect: String,
#[serde(default)]
pub rollback: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub references: Vec<String>,
#[serde(default)]
pub target_terminal_session_id: Option<String>,
#[serde(default)]
pub target: Option<AiTerminalTarget>,
}
// ---------------------------------------------------------------------------
// Agent (ReAct) types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentActionKind {
ExecuteCommand,
FinalAnswer,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentApprovalReasonCode {
ConfirmEachCommand,
CriticalRisk,
RiskExceedsThreshold,
ExternalAgentPermission,
SafeAutoUnknownOrHighRisk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentStepAction {
pub kind: AgentActionKind,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub target: Option<AiTerminalTarget>,
#[serde(default)]
pub risk_level: Option<RiskLevel>,
#[serde(default)]
pub model_risk_level: Option<RiskLevel>,
#[serde(default)]
pub local_risk_level: Option<RiskLevel>,
#[serde(default)]
pub model_risk_reason: Option<String>,
#[serde(default)]
pub local_risk_reason_code: Option<RiskReasonCode>,
#[serde(default)]
pub approval_reason_code: Option<AgentApprovalReasonCode>,
#[serde(default)]
pub answer: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandObservation {
pub output: String,
#[serde(default)]
pub exit_code: Option<i32>,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentStepStatus {
Running,
Completed,
NeedsApproval,
Rejected,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentStepPayload {
pub stream_id: String,
#[serde(default)]
pub session_id: Option<String>,
pub step_index: u16,
pub thought: String,
pub action: AgentStepAction,
#[serde(default)]
pub observation: Option<CommandObservation>,
pub status: AgentStepStatus,
#[serde(default)]
pub error: Option<String>,
}
/// Parsed single-step agent response from the LLM.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct AgentLlmResponse {
#[serde(default)]
pub thought: String,
pub action: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub target_terminal_session_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_risk_level")]
pub risk_level: Option<RiskLevel>,
#[serde(default)]
pub risk_reason: Option<String>,
#[serde(default)]
pub answer: Option<String>,
}
fn deserialize_optional_risk_level<'de, D>(deserializer: D) -> Result<Option<RiskLevel>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.and_then(|raw| parse_risk_level_label(&raw)))
}
pub fn parse_risk_level_label(value: &str) -> Option<RiskLevel> {
match value.trim().replace('-', "_").to_ascii_lowercase().as_str() {
"low" => Some(RiskLevel::Low),
"medium" | "moderate" => Some(RiskLevel::Medium),
"high" => Some(RiskLevel::High),
"critical" | "danger" | "dangerous" => Some(RiskLevel::Critical),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AiContext {
#[serde(default)]
pub connection_name: Option<String>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub os: Option<String>,
#[serde(default)]
pub arch: Option<String>,
#[serde(default)]
pub recent_output: String,
#[serde(default)]
pub selected_text: String,
#[serde(default)]
pub input_buffer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AiAction {
GenerateCommand,
ExplainOutput,
ExplainSelected,
AnalyzeError,
RepairFromSelection,
CustomTerminalAction,
CustomFileAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AiRequestOptions {
#[serde(default = "default_max_output_commands")]
pub max_output_commands: u8,
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_safety_mode")]
pub safety_mode: String,
#[serde(default = "default_history_turns")]
pub history_turns: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiChatRequest {
#[serde(default)]
pub stream_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub connection_id: Option<String>,
/// The terminal session id to execute commands on (Agent mode).
#[serde(default)]
pub terminal_session_id: Option<String>,
#[serde(default)]
pub owner_scope: AiSessionScope,
#[serde(default)]
pub targets: Vec<AiTerminalTarget>,
#[serde(default)]
pub target_contexts: Vec<AiTargetContext>,
#[serde(default = "default_mode")]
pub mode: AiMode,
#[serde(default)]
pub agent_kind: AiAgentKind,
#[serde(default)]
pub permission_mode: AiPermissionMode,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub model_name: Option<String>,
#[serde(default)]
pub default_target_session_id: Option<String>,
#[serde(default)]
pub existing_external_session_id: Option<String>,
#[serde(default)]
pub attachments: Vec<AiAttachment>,
pub action: AiAction,
pub user_input: String,
#[serde(default)]
pub context: AiContext,
#[serde(default)]
pub options: AiRequestOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiStreamStart {
pub stream_id: String,
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiStreamEventPayload {
#[serde(rename = "type")]
pub event_type: String,
pub stream_id: String,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub text_delta: Option<String>,
#[serde(default)]
pub reasoning_delta: Option<String>,
#[serde(default)]
pub message: Option<AiMessage>,
#[serde(default)]
pub command_cards: Vec<AiCommandCard>,
#[serde(default)]
pub usage: Option<Value>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiSession {
pub id: String,
#[serde(default)]
pub agent_kind: AiAgentKind,
#[serde(default)]
pub scope: AiSessionScope,
#[serde(default)]
pub connection_id: Option<String>,
pub title: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub external_session_id: Option<String>,
#[serde(default)]
pub backend_metadata: Option<AiSessionBackendMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AiSessionBackendMetadata {
#[serde(default)]
pub backend: AiBackendKind,
#[serde(default)]
pub external_thread_id: Option<String>,
#[serde(default)]
pub codex_terminal_tools_version: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AiMessageRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiMessage {
pub id: String,
pub session_id: String,
pub role: AiMessageRole,
pub content: String,
pub created_at: String,
#[serde(default)]
pub reasoning_content: Option<String>,
#[serde(default)]
pub command_cards: Vec<AiCommandCard>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiAuditLog {
pub id: String,
#[serde(default)]
pub connection_id: Option<String>,
pub action: String,
#[serde(default)]
pub user_input: Option<String>,
#[serde(default)]
pub generated_command: Option<String>,
#[serde(default)]
pub risk_level: Option<RiskLevel>,
#[serde(default)]
pub inserted_to_terminal: bool,
#[serde(default)]
pub executed: bool,
#[serde(default)]
pub blocked: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub client: Option<String>,
#[serde(default)]
pub capability: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub permission_mode: Option<AiPermissionMode>,
#[serde(default)]
pub approval_decision: Option<String>,
#[serde(default)]
pub success: Option<bool>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub error: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendAiAuditRequest {
#[serde(default)]
pub connection_id: Option<String>,
pub action: String,
#[serde(default)]
pub user_input: Option<String>,
#[serde(default)]
pub generated_command: Option<String>,
#[serde(default)]
pub risk_level: Option<RiskLevel>,
#[serde(default)]
pub inserted_to_terminal: bool,
#[serde(default)]
pub executed: bool,
#[serde(default)]
pub blocked: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub client: Option<String>,
#[serde(default)]
pub capability: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub permission_mode: Option<AiPermissionMode>,
#[serde(default)]
pub approval_decision: Option<String>,
#[serde(default)]
pub success: Option<bool>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiModelDiscovery {
pub id: String,
pub name: String,
#[serde(default)]
pub backend: AiBackendKind,
#[serde(default)]
pub provider_kind: Option<AiProviderKind>,
#[serde(default)]
pub credential_id: Option<String>,
pub source: AiModelSource,
}
// ---------------------------------------------------------------------------
// AI capture terminal display events
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum AiCaptureEvent {
#[serde(rename_all = "camelCase")]
CommandStart { command: String, step_index: u16 },
#[serde(rename_all = "camelCase")]
CommandEnd {
output: String,
exit_code: Option<i32>,
duration_ms: u64,
truncated: bool,
},
}
// ---------------------------------------------------------------------------
// Default value functions for serde
// ---------------------------------------------------------------------------
pub fn default_max_output_commands() -> u8 {
5
}
pub fn default_language() -> String {
"en".to_string()
}
pub fn default_safety_mode() -> String {
"strict".to_string()
}
pub fn default_history_turns() -> u16 {
20
}
pub fn default_mode() -> AiMode {
AiMode::Ask
}
// ---------------------------------------------------------------------------
// Shared utility helpers
// ---------------------------------------------------------------------------
pub fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
pub fn uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_agent_risk_level_case_insensitively() {
let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"HIGH","riskReason":"x"}"#;
let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.risk_level, Some(RiskLevel::High));
assert_eq!(parsed.risk_reason.as_deref(), Some("x"));
}
#[test]
fn invalid_agent_risk_level_does_not_fail_response_parse() {
let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"spicy","riskReason":"x"}"#;
let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.risk_level, None);
}
}
@@ -0,0 +1,159 @@
//! Password-portable backups. Runtime key material is never replaced during an operation.
use super::{backup_crypto, portable_snapshot::*};
use crate::{
config,
error::{AppError, AppResult},
storage,
utils::crypto,
};
use aes_gcm::{Aes256Gcm, Key, KeyInit, aead::OsRng};
pub const MAX_BACKUP_BYTES: usize = 50 * 1024 * 1024;
pub fn build_backup(app_version: &str) -> AppResult<PortableSnapshot> {
let settings = config::load_app_settings(&())?;
// Loading can retain undecipherable settings for recovery; exports must reject them.
// Validate the stored ciphertext: load_app_settings already decrypts valid AI settings.
let stored_settings: config::AppSettings =
storage::load_settings_doc(storage::SettingsDocKey::AppSettings)?;
config::decrypt_ai_settings(stored_settings.ai)?;
let mut snapshot = PortableSnapshot {
schema_version: PORTABLE_SNAPSHOT_SCHEMA_VERSION,
snapshot_kind: PortableSnapshotKind::Backup,
revision_id: uuid::Uuid::new_v4().to_string(),
device_id: config::load_cloud_sync_state(&())?.device_id,
created_at_ms: current_time_ms(),
payload_hash: String::new(),
app_version: app_version.into(),
settings: PortableAppSettings::from_app_settings(&settings, &PortableSnapshotKind::Backup),
sessions: config::load_sessions(&())?,
keys: config::load_keys(&())?,
passwords: config::load_passwords(&())?,
credentials: config::load_credentials(&())?,
otp: config::load_otp_entries(&())?,
proxies: config::load_proxies(&())?,
proxy_groups: config::load_proxy_groups(&())?,
tunnels: config::load_tunnels(&())?,
tunnel_groups: config::load_tunnel_groups(&())?,
quick_commands: config::load_quick_commands(&())?,
history: storage::list_command_history_entries(usize::MAX)?,
master_key_token: storage::load_master_key_token()?,
known_hosts: storage::render_known_hosts_export()?,
notes: storage::load_notes_snapshot()?,
};
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
Ok(snapshot)
}
fn transcode(
snapshot: &mut PortableSnapshot,
source: &Key<Aes256Gcm>,
target: &Key<Aes256Gcm>,
legacy_connection_passwords: bool,
) -> AppResult<()> {
let convert = |value: &mut Option<String>| -> AppResult<()> {
if let Some(token) = value {
*token = crypto::transcode_secret(token, source, target)?;
}
Ok(())
};
for connection in &mut snapshot.sessions.connections {
if let Some(auth) = &mut connection.auth {
match auth.password.as_deref() {
Some("") => auth.password = None,
Some(token) if legacy_connection_passwords => {
auth.password = Some(crypto::transcode_legacy_connection_password(
token, source, target,
)?);
}
_ => convert(&mut auth.password)?,
}
}
}
for key in &mut snapshot.keys.keys {
convert(&mut key.key)?;
convert(&mut key.cert)?;
convert(&mut key.passphrase)?;
}
for account in &mut snapshot.passwords.passwords {
convert(&mut account.password)?;
}
for credential in &mut snapshot.credentials.credentials {
convert(&mut credential.password)?;
}
for otp in &mut snapshot.otp.entries {
convert(&mut otp.secret)?;
}
for proxy in &mut snapshot.proxies {
convert(&mut proxy.password)?;
}
Ok(())
}
pub fn export_backup(password: &str, app_version: &str) -> AppResult<Vec<u8>> {
validate_password(password)?;
let mut snapshot = build_backup(app_version)?;
let portable_key = Aes256Gcm::generate_key(OsRng);
transcode(
&mut snapshot,
&crypto::get_master_key()?,
&portable_key,
false,
)?;
snapshot.master_key_token = Some(crypto::backup_key_token(&portable_key, password)?);
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
let encoded = zeroize::Zeroizing::new(encode_portable_snapshot(&snapshot)?);
let encrypted = backup_crypto::encrypt_snapshot_bytes(&encoded, password)?;
if encrypted.len() > MAX_BACKUP_BYTES {
return Err(AppError::Config("Backup exceeds 50 MiB".into()));
}
Ok(encrypted)
}
/// Decode and re-encrypt everything before acquiring a database write transaction.
pub fn prepare_import(bytes: &[u8], password: &str) -> AppResult<PortableSnapshot> {
validate_password(password)?;
if bytes.len() > MAX_BACKUP_BYTES {
return Err(AppError::Config("Backup exceeds 50 MiB".into()));
}
let decoded = zeroize::Zeroizing::new(backup_crypto::decrypt_snapshot_bytes(bytes, password)?);
let mut snapshot = decode_portable_snapshot(&decoded)?;
if snapshot.snapshot_kind != PortableSnapshotKind::Backup {
return Err(AppError::Config("Expected a configuration backup".into()));
}
storage::backup::validate_backup_data(&snapshot)?;
let source = crypto::backup_key(
snapshot
.master_key_token
.as_deref()
.ok_or_else(|| AppError::Crypto("Backup key missing".into()))?,
password,
)?;
// Desktop snapshots can contain legacy plaintext inline passwords. Only accept
// those after the outer encryption, payload hash and source key are verified.
transcode(&mut snapshot, &source, &crypto::get_master_key()?, true)?;
// Keep the destination key token and master password. AI fields in portable settings are plaintext.
snapshot.master_key_token = storage::load_master_key_token()?;
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
Ok(snapshot)
}
/// Merge target-only settings immediately before the transaction, under the instance lock.
pub fn settings_for_import(snapshot: &PortableSnapshot) -> AppResult<config::AppSettings> {
let mut settings = snapshot.settings.clone().apply_to(
config::load_app_settings(&())?,
&PortableSnapshotKind::Backup,
);
settings.ai = config::encrypt_ai_settings(settings.ai)?;
settings.cloud_sync = config::encrypt_cloud_sync_settings(settings.cloud_sync)?;
Ok(settings)
}
fn validate_password(password: &str) -> AppResult<()> {
if password.is_empty() || password.len() > 1024 {
return Err(AppError::Config(
"Backup password must contain 1–1024 bytes".into(),
));
}
Ok(())
}
@@ -0,0 +1,67 @@
use crate::error::{AppError, AppResult};
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
use sha2::{Digest, Sha256};
const CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"nyaterm-cloud-snapshot-v1:";
const LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"dragonfly-cloud-snapshot-v1:";
fn derive_snapshot_key_with_prefix(prefix: &[u8], master_password: &str) -> Key<Aes256Gcm> {
let mut hasher = Sha256::new();
hasher.update(prefix);
hasher.update(master_password.as_bytes());
let digest = hasher.finalize();
*Key::<Aes256Gcm>::from_slice(&digest)
}
fn derive_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
derive_snapshot_key_with_prefix(CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
}
fn derive_legacy_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
derive_snapshot_key_with_prefix(LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
}
pub fn encrypt_snapshot_bytes(plaintext: &[u8], master_password: &str) -> AppResult<Vec<u8>> {
let key = derive_snapshot_key(&master_password);
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|error| AppError::Crypto(format!("cloud snapshot encryption failed: {error}")))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(combined)
}
pub fn decrypt_snapshot_bytes(ciphertext: &[u8], master_password: &str) -> AppResult<Vec<u8>> {
if ciphertext.len() < 13 {
return Err(AppError::Crypto(
"cloud snapshot ciphertext is too short".to_string(),
));
}
match decrypt_snapshot_bytes_with_key(ciphertext, &derive_snapshot_key(&master_password)) {
Ok(plaintext) => Ok(plaintext),
Err(new_error) => decrypt_snapshot_bytes_with_key(
ciphertext,
&derive_legacy_snapshot_key(&master_password),
)
.map_err(|legacy_error| {
AppError::Crypto(format!(
"cloud snapshot decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})"
))
}),
}
}
fn decrypt_snapshot_bytes_with_key(ciphertext: &[u8], key: &Key<Aes256Gcm>) -> AppResult<Vec<u8>> {
let cipher = Aes256Gcm::new(key);
let (nonce_bytes, payload) = ciphertext.split_at(12);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, payload)
.map_err(|error| AppError::Crypto(format!("cloud snapshot decryption failed: {error}")))
}
@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum RiskReasonCode {
EmptyCommand,
IrreversiblePattern,
UnclassifiedCommand,
PrivilegedMutation,
UnknownCommand,
OrdinaryWrite,
ReadOnlyDiagnostic,
}
@@ -0,0 +1,16 @@
use crate::error::{AppError, AppResult};
use crate::utils::fuzzy::{FuzzyResult, fuzzy_search_items};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_HISTORY: usize = 5000;
const HISTORY_STORE_VERSION: u32 = 2;
include!("types.rs");
include!("store.rs");
include!("migration.rs");
include!("sanitize.rs");
include!("util.rs");
include!("tests.rs");
@@ -4,7 +4,7 @@ pub struct CommandHistoryStore {
history_path: Option<PathBuf>,
}
pub(crate) enum PreparedHistorySave {
pub enum PreparedHistorySave {
File(PathBuf, Vec<u8>),
Redb(String),
}
@@ -18,7 +18,7 @@ impl CommandHistoryStore {
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_history_path(&mut self, path: PathBuf) {
self.history_path = Some(path);
}
@@ -39,6 +39,7 @@ impl CommandHistoryStore {
return Ok(());
}
self.entries = entries;
self.entries.sort_by_key(|entry| entry.last_used_at_ms);
self.dirty = false;
return Ok(());
};
@@ -67,7 +68,7 @@ impl CommandHistoryStore {
/// Serializes dirty state and marks clean. Returns a prepared write for
/// the caller to persist (possibly via `spawn_blocking`).
pub(crate) fn prepare_save(&mut self) -> Option<PreparedHistorySave> {
pub fn prepare_save(&mut self) -> Option<PreparedHistorySave> {
if !self.dirty {
return None;
}
@@ -159,7 +160,7 @@ impl CommandHistoryStore {
}
}
pub(crate) fn sanitize_history_command(input: &str) -> Option<String> {
pub fn sanitize_history_command(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
@@ -7,14 +7,14 @@ fn current_time_ms() -> u64 {
}
/// Writes serialized history bytes to disk. Safe to call from a blocking context.
pub(crate) fn flush_to_disk(path: &Path, bytes: &[u8]) -> AppResult<()> {
pub fn flush_to_disk(path: &Path, bytes: &[u8]) -> AppResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_atomic(path, bytes)
}
pub(crate) fn flush_prepared_save(pending: PreparedHistorySave) -> AppResult<()> {
pub fn flush_prepared_save(pending: PreparedHistorySave) -> AppResult<()> {
match pending {
PreparedHistorySave::File(path, bytes) => flush_to_disk(&path, &bytes),
PreparedHistorySave::Redb(content) => {
@@ -1,7 +1,11 @@
fn build_group_path(groups: &[Group], id: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current = id;
let mut visited = std::collections::HashSet::new();
loop {
if !visited.insert(current) {
break;
}
if let Some(g) = groups.iter().find(|g| g.id == current) {
segments.push(g.name.clone());
if let Some(ref pid) = g.parent_id {
@@ -65,10 +69,7 @@ fn ensure_group_path(
Some(leaf_id)
}
fn import_legacy_sessions(
app: &tauri::AppHandle,
imported: Vec<ImportedSession>,
) -> AppResult<usize> {
fn import_legacy_sessions(app: &impl Sized, imported: Vec<ImportedSession>) -> AppResult<usize> {
if imported.is_empty() {
return Ok(0);
}
@@ -134,7 +135,7 @@ fn import_legacy_sessions(
}
fn import_prepared_nyaterm_json(
app: &tauri::AppHandle,
app: &impl Sized,
prepared: PreparedJsonImport,
) -> AppResult<usize> {
if prepared.connections.is_empty() {
@@ -182,13 +183,18 @@ fn import_prepared_nyaterm_json(
let mut passwords = config::load_passwords(app)?;
passwords.passwords.extend(prepared.passwords);
config::save_passwords(app, &passwords)?;
let mut keys = config::load_keys(app)?;
keys.keys.extend(prepared.ssh_keys);
config::save_keys(app, &keys)?;
config::save_config(app, &cfg)?;
crate::storage::backup::import_connections(
&config::SessionsConfig {
connections: cfg.connections,
groups: cfg.groups,
custom_icons: cfg.custom_icons,
},
&passwords,
&keys,
)?;
Ok(count)
}
@@ -216,9 +222,9 @@ fn parse_json_import_content(content: &str) -> AppResult<PreparedJsonImport> {
}
fn is_electerm_bookmarks_json(value: &serde_json::Value) -> bool {
value
.as_object()
.is_some_and(|object| object.contains_key("bookmarkGroups") && object.contains_key("bookmarks"))
value.as_object().is_some_and(|object| {
object.contains_key("bookmarkGroups") && object.contains_key("bookmarks")
})
}
fn is_nyaterm_json_import(value: &serde_json::Value) -> bool {
@@ -234,16 +240,13 @@ fn is_nyaterm_json_import(value: &serde_json::Value) -> bool {
// ── Tauri Command ───────────────────────────────────────────────────────────
pub fn import_sessions(
app: tauri::AppHandle,
app: &impl Sized,
file_path: String,
windterm_master_password: Option<String>,
) -> AppResult<usize> {
let path = Path::new(&file_path);
if path.is_dir() {
let count = import_legacy_sessions(&app, parse_finalshell(&file_path)?)?;
if count > 0 {
let _ = app.emit("connections-changed", ());
}
return Ok(count);
}
@@ -268,19 +271,14 @@ pub fn import_sessions(
));
};
if count > 0 {
let _ = app.emit("connections-changed", ());
}
Ok(count)
}
#[cfg(feature = "desktop-importer")]
pub fn import_termius_sessions(
app: tauri::AppHandle,
app: &impl Sized,
indexed_db_path: Option<String>,
) -> AppResult<usize> {
let count = import_prepared_nyaterm_json(&app, parse_termius_indexed_db(indexed_db_path)?)?;
if count > 0 {
let _ = app.emit("connections-changed", ());
}
Ok(count)
}
@@ -1,7 +1,11 @@
fn parse_mobaxterm(path: &str) -> AppResult<Vec<ImportedSession>> {
let raw =
std::fs::read(path).map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?;
let content = decode_bytes(&raw);
parse_mobaxterm_bytes(&raw)
}
fn parse_mobaxterm_bytes(raw: &[u8]) -> AppResult<Vec<ImportedSession>> {
let content = decode_bytes(raw);
let sections = parse_ini_sections(&content);
let mut sessions = Vec::new();
@@ -0,0 +1,110 @@
//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), WindTerm (.sessions),
//! SecureCRT (.xml), FinalShell conn directories, NyaTerm JSON files, and Electerm bookmarks.
use crate::config::{
self, AiExecutionProfile, ConnectionAuth, ConnectionType, Group, SavedConnection,
};
use crate::error::{AppError, AppResult};
#[cfg(not(test))]
use crate::utils::crypto;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::Path;
#[cfg(not(test))]
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
crypto::encrypt(plaintext)
}
#[cfg(test)]
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
Ok(format!("test-encrypted:{plaintext}"))
}
include!("types.rs");
include!("text.rs");
include!("common.rs");
include!("xshell.rs");
include!("mobaxterm.rs");
include!("windterm.rs");
include!("securecrt.rs");
include!("finalshell.rs");
include!("nyaterm_json.rs");
include!("electerm.rs");
#[cfg(feature = "desktop-importer")]
include!("termius.rs");
include!("merge.rs");
include!("tests.rs");
#[derive(serde::Serialize)]
pub struct BrowserImportResult {
pub imported: usize,
pub warnings: Vec<String>,
}
/// Content-only import: never resolves paths from uploaded files on the server.
pub fn import_browser_connections(source: &str, bytes: &[u8]) -> AppResult<BrowserImportResult> {
if bytes.len() > 10 * 1024 * 1024 {
return Err(AppError::Config("Import exceeds 10 MiB".into()));
}
let content = decode_bytes(bytes);
let mut warnings = Vec::new();
let imported = match source {
"xshell" => {
warnings.push("credentials_not_in_export".into());
import_legacy_sessions(&(), parse_xshell_reader(std::io::Cursor::new(bytes))?)?
}
"mobaxterm" => {
warnings.push("credentials_not_in_export".into());
import_legacy_sessions(&(), parse_mobaxterm_bytes(bytes)?)?
}
"securecrt" => {
warnings.push("credentials_not_in_export".into());
import_legacy_sessions(&(), parse_securecrt_content(&content)?)?
}
"windterm" => {
let entries: Vec<serde_json::Value> = serde_json::from_str(&content)?;
// Encrypted auto-login and external key files require Desktop's profile resources.
for entry in &entries {
let login = entry.get("session.autoLogin");
let has_encrypted = login.and_then(|v| v.as_str()).is_some_and(|v| {
!v.trim().is_empty() && serde_json::from_str::<serde_json::Value>(v).is_err()
});
let parsed_login = login
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
let login_value = parsed_login.as_ref().or(login);
let has_key = entry.as_object().is_some_and(|fields| {
fields.iter().any(|(key, value)| {
key.starts_with("ssh.identityFilePath")
&& value.as_str().is_some_and(|path| !path.trim().is_empty())
})
}) || login_value
.and_then(|v| v.get("Public Key"))
.and_then(|v| v.as_object())
.is_some_and(|fields| {
fields.iter().any(|(key, value)| {
key.ends_with(".path")
&& value.as_str().is_some_and(|path| !path.trim().is_empty())
})
});
if has_encrypted || has_key {
return Err(AppError::Config("WindTerm profile resources required; import on Desktop and transfer a .nya backup".into()));
}
}
import_prepared_nyaterm_json(
&(),
parse_windterm_content_impl(&content, None, None, false)?,
)?
}
"electerm" => import_prepared_nyaterm_json(&(), parse_electerm_json_content(&content)?)?,
"nyaterm_json" => import_prepared_nyaterm_json(&(), parse_nyaterm_json_content(&content)?)?,
_ => {
return Err(AppError::Config(
"This import source requires Desktop".into(),
));
}
};
Ok(BrowserImportResult { imported, warnings })
}
@@ -361,10 +361,7 @@ fn prepare_json_ssh_auth(
})
}
"agent" => {
if auth.password.is_some()
|| auth.password_ref.is_some()
|| auth.key_ref.is_some()
{
if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() {
return Err(AppError::Config(format!(
"{context}: agent auth cannot include password, password_ref, or key_ref"
)));
@@ -1,6 +1,6 @@
use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
#[derive(Default)]
struct SecureCrtKeyFrame {
@@ -105,7 +105,9 @@ fn securecrt_session_from_frame(
frame: &SecureCrtKeyFrame,
ancestors: &[SecureCrtKeyFrame],
) -> Option<ImportedSession> {
let sessions_index = ancestors.iter().position(|frame| frame.name == "Sessions")?;
let sessions_index = ancestors
.iter()
.position(|frame| frame.name == "Sessions")?;
let protocol = frame
.fields
.get("Protocol Name")
@@ -196,10 +196,7 @@ fn load_termius_local_key() -> AppResult<Zeroizing<[u8; 32]>> {
}
}
let candidates = [
("Termius/localKey", "localKey"),
("Termius", "localKey"),
];
let candidates = [("Termius/localKey", "localKey"), ("Termius", "localKey")];
for (service, user) in candidates {
match keyring::Entry::new(service, user).and_then(|entry| entry.get_secret()) {
@@ -297,10 +294,8 @@ fn normalize_termius_local_key_candidates(
}
fn read_leveldb_records(db_path: &Path) -> AppResult<Vec<u8>> {
let tmp_path = std::env::temp_dir().join(format!(
"nyaterm-termius-leveldb-{}",
uuid::Uuid::new_v4()
));
let tmp_path =
std::env::temp_dir().join(format!("nyaterm-termius-leveldb-{}", uuid::Uuid::new_v4()));
copy_leveldb_dir(db_path, &tmp_path)?;
let result = read_copied_leveldb_records(&tmp_path);
@@ -516,7 +511,12 @@ fn is_record_marker(strings: &[TermiusTaggedValue], index: usize) -> bool {
}
fn collect_host_record(strings: &[TermiusTaggedValue], index: usize) -> Option<TermiusRawHost> {
let record = collect_fields(strings, index, &["address", "label", "username", "password"], 80);
let record = collect_fields(
strings,
index,
&["address", "label", "username", "password"],
80,
);
let address = record.get("address").cloned();
let label = record.get("label").cloned();
if address.as_ref().is_none_or(|value| value.trim().is_empty())
@@ -562,7 +562,10 @@ fn collect_identity_record(
index: usize,
) -> Option<TermiusRawIdentity> {
let record = collect_fields(strings, index, &["label", "username", "password"], 80);
if !record.contains_key("username") && !record.contains_key("password") && !record.contains_key("ssh_key") {
if !record.contains_key("username")
&& !record.contains_key("password")
&& !record.contains_key("ssh_key")
{
return None;
}
@@ -631,7 +634,9 @@ fn collect_fields(
.as_str()
.is_some_and(|text| is_termius_field_name(text) || is_record_type_name(text));
if !is_nested_name {
fields.entry(key.to_string()).or_insert(value.to_field_value());
fields
.entry(key.to_string())
.or_insert(value.to_field_value());
}
}
cursor += 1;
@@ -675,8 +680,7 @@ fn is_record_type_name(value: &str) -> bool {
fn is_termius_field_name(value: &str) -> bool {
matches!(
value,
"id"
| "local_id"
"id" | "local_id"
| "updated_at"
| "label"
| "address"
@@ -722,17 +726,11 @@ fn record_id(strings: &[TermiusTaggedValue], index: usize) -> String {
uuid::Uuid::new_v4().to_string()
}
fn nested_record_id(
strings: &[TermiusTaggedValue],
index: usize,
field: &str,
) -> Option<String> {
fn nested_record_id(strings: &[TermiusTaggedValue], index: usize, field: &str) -> Option<String> {
let start = record_start(strings, index);
let end = record_end(strings, index, 80);
for cursor in start..end.saturating_sub(2) {
if strings[cursor].as_str() == Some(field)
&& strings[cursor + 1].as_str() == Some("id")
{
if strings[cursor].as_str() == Some(field) && strings[cursor + 1].as_str() == Some("id") {
let value = strings[cursor + 2].to_field_value();
let trimmed = value.trim();
if !trimmed.is_empty() {
@@ -815,7 +813,9 @@ fn dedupe_latest_ssh_configs(configs: Vec<TermiusRawSshConfig>) -> Vec<TermiusRa
}
fn dedupe_latest_identities(identities: Vec<TermiusRawIdentity>) -> Vec<TermiusRawIdentity> {
dedupe_latest(identities, termius_identity_key, |item| item.updated_at.as_deref())
dedupe_latest(identities, termius_identity_key, |item| {
item.updated_at.as_deref()
})
}
fn dedupe_latest_ssh_keys(keys: Vec<TermiusRawSshKey>) -> Vec<TermiusRawSshKey> {
@@ -884,8 +884,10 @@ fn termius_group_key(group: &TermiusRawGroup) -> String {
}
fn build_termius_group_paths(groups: &[TermiusRawGroup]) -> HashMap<String, Vec<String>> {
let by_key: HashMap<String, &TermiusRawGroup> =
groups.iter().map(|group| (termius_group_key(group), group)).collect();
let by_key: HashMap<String, &TermiusRawGroup> = groups
.iter()
.map(|group| (termius_group_key(group), group))
.collect();
let mut paths = HashMap::new();
for group in groups {
let key = termius_group_key(group);
@@ -907,12 +909,12 @@ fn build_termius_group_path(
}
let group = by_key.get(key)?;
let label = normalize_optional_string(group.label.clone())?;
let mut path = if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty())
{
build_termius_group_path(parent, by_key, visited).unwrap_or_default()
} else {
Vec::new()
};
let mut path =
if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty()) {
build_termius_group_path(parent, by_key, visited).unwrap_or_default()
} else {
Vec::new()
};
path.push(label);
Some(path)
}
@@ -994,7 +996,10 @@ fn prepare_termius_passwords(
for identity in identities {
if let Some(password) = normalize_optional_string(identity.password.clone()) {
let id = uuid::Uuid::new_v4().to_string();
ids.insert(format!("identity:{}", termius_identity_key(identity)), id.clone());
ids.insert(
format!("identity:{}", termius_identity_key(identity)),
id.clone(),
);
for alias in termius_record_aliases(&identity.id, identity.local_id.as_deref()) {
ids.insert(format!("identity:{alias}"), id.clone());
}
@@ -1052,8 +1057,7 @@ fn prepare_termius_connections(
.identity_id
.as_deref()
.or_else(|| ssh_config.and_then(|config| config.identity_id.as_deref()));
let identity = identity_id
.and_then(|id| identities_by_key.get(id).copied());
let identity = identity_id.and_then(|id| identities_by_key.get(id).copied());
let username = normalize_optional_string(host.username.clone())
.or_else(|| identity.and_then(|item| normalize_optional_string(item.username.clone())))
.unwrap_or_else(|| "root".to_string());
@@ -1147,7 +1151,8 @@ fn prepare_termius_auth(
}
let host_password_key = format!("host:{}", termius_host_key(host));
let identity_password_key = identity.map(|item| format!("identity:{}", termius_identity_key(item)));
let identity_password_key =
identity.map(|item| format!("identity:{}", termius_identity_key(item)));
let password_id = password_ids
.get(&host_password_key)
.cloned()
@@ -1,4 +1,3 @@
#[cfg(test)]
mod tests {
use super::*;
@@ -343,7 +342,10 @@ mod tests {
ConnectionType::Ssh { host, username, .. }
if host == "bad.example.com" && username == "fallback"
));
assert_eq!(prepared.connections[0].auth.as_ref().expect("auth").mode, "none");
assert_eq!(
prepared.connections[0].auth.as_ref().expect("auth").mode,
"none"
);
assert!(matches!(
&prepared.connections[1].config,
ConnectionType::Ssh { host, username, .. }
@@ -646,10 +648,9 @@ mod tests {
let mut buffer = vec![0_u8; plaintext.len() + WINDTERM_AES_IV_LENGTH];
buffer[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
let ciphertext =
cbc::Encryptor::<aes::Aes256>::new(&crypto.key.into(), &crypto.iv.into())
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
.expect("encrypt windterm payload");
let ciphertext = cbc::Encryptor::<aes::Aes256>::new(&crypto.key.into(), &crypto.iv.into())
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
.expect("encrypt windterm payload");
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext)
}
@@ -854,6 +855,7 @@ mod tests {
);
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_normalizes_local_key_variants() {
use base64::Engine as _;
@@ -869,10 +871,7 @@ mod tests {
let key = normalize_termius_local_key_bytes(raw.as_bytes()).expect("raw bytes key");
assert_eq!(key.as_ref(), raw.as_bytes());
let utf16le: Vec<u8> = raw
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
let utf16le: Vec<u8> = raw.encode_utf16().flat_map(u16::to_le_bytes).collect();
let key = normalize_termius_local_key_bytes(&utf16le).expect("utf16 key");
assert_eq!(key.as_ref(), raw.as_bytes());
@@ -881,6 +880,7 @@ mod tests {
assert_eq!(key.as_ref(), raw.as_bytes());
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_decrypts_secretbox_values_and_rejects_corruption() {
let key = *b"12345678901234567890123456789012";
@@ -896,6 +896,7 @@ mod tests {
assert!(error.to_string().contains("Cannot decrypt Termius"));
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_tagged_string_parser_handles_multibyte_lengths() {
let long = "x".repeat(300);
@@ -907,6 +908,7 @@ mod tests {
assert_eq!(strings, vec!["private_key".to_string(), long]);
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_maps_host_ports_from_linked_ssh_configs() {
let mut bytes = Vec::new();
@@ -1031,7 +1033,10 @@ mod tests {
let prepared = parse_json_import_content(json).expect("parse electerm");
assert_eq!(prepared.groups, vec![vec!["Prod".to_string(), "Web".to_string()]]);
assert_eq!(
prepared.groups,
vec![vec!["Prod".to_string(), "Web".to_string()]]
);
assert_eq!(
prepared.connections[0].group_path,
Some(vec!["Prod".to_string(), "Web".to_string()])
@@ -1088,6 +1093,7 @@ mod tests {
);
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_maps_hosts_groups_passwords_and_keys() {
crate::utils::crypto::set_master_password(None);
@@ -1166,17 +1172,28 @@ mod tests {
Some(vec!["Production".to_string()])
);
let password_auth = prepared.connections[1].auth.as_ref().expect("password auth");
let password_auth = prepared.connections[1]
.auth
.as_ref()
.expect("password auth");
assert_eq!(password_auth.mode, "password");
assert!(password_auth.account_id.is_some());
assert!(password_auth.password_id.is_none());
assert!(prepared.passwords.iter().any(|entry| entry.username == "root"));
assert!(prepared
.passwords
.iter()
.any(|entry| entry.username == "deploy"));
assert!(
prepared
.passwords
.iter()
.any(|entry| entry.username == "root")
);
assert!(
prepared
.passwords
.iter()
.any(|entry| entry.username == "deploy")
);
}
#[cfg(feature = "desktop-importer")]
#[test]
fn termius_identity_password_keeps_host_username() {
crate::utils::crypto::set_master_password(None);
@@ -209,4 +209,3 @@ fn default_serial_stop_bits() -> String {
fn default_serial_backspace_mode() -> String {
"ctrl_h".to_string()
}
@@ -34,6 +34,15 @@ fn parse_windterm_content_with_crypto(
content: &str,
crypto: Option<&WindtermCrypto>,
source_path: Option<&Path>,
) -> AppResult<PreparedJsonImport> {
parse_windterm_content_impl(content, crypto, source_path, true)
}
fn parse_windterm_content_impl(
content: &str,
crypto: Option<&WindtermCrypto>,
source_path: Option<&Path>,
allow_local_files: bool,
) -> AppResult<PreparedJsonImport> {
let entries: Vec<serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("Invalid WindTerm JSON: {e}")))?;
@@ -118,6 +127,7 @@ fn parse_windterm_content_with_crypto(
.and_then(|value| value.as_bool())
.unwrap_or(false);
let auth = prepare_windterm_auth(
allow_local_files,
entry,
auto_login.as_ref(),
source_path,
@@ -252,17 +262,15 @@ fn parse_windterm_auto_login(
let plaintext = String::from_utf8(plaintext).map_err(|_| {
AppError::Config("Failed to decode decrypted WindTerm autoLogin text".to_string())
})?;
let value: serde_json::Value = serde_json::from_str(&plaintext)
.map_err(|_| AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string()))?;
let value: serde_json::Value = serde_json::from_str(&plaintext).map_err(|_| {
AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string())
})?;
value.as_object().cloned().map(Some).ok_or_else(|| {
AppError::Config("WindTerm session.autoLogin must decode to a JSON object".to_string())
})
}
fn decrypt_windterm_auto_login(
ciphertext: &[u8],
crypto: &WindtermCrypto,
) -> AppResult<Vec<u8>> {
fn decrypt_windterm_auto_login(ciphertext: &[u8], crypto: &WindtermCrypto) -> AppResult<Vec<u8>> {
if ciphertext.is_empty() || ciphertext.len() % WINDTERM_AES_IV_LENGTH != 0 {
return Err(AppError::Config(
"Invalid WindTerm autoLogin ciphertext length".to_string(),
@@ -294,6 +302,7 @@ fn parse_windterm_group_path(entry: &serde_json::Value) -> Option<Vec<String>> {
}
fn prepare_windterm_auth(
allow_local_files: bool,
entry: &serde_json::Value,
auto_login: Option<&serde_json::Map<String, serde_json::Value>>,
source_path: Option<&Path>,
@@ -328,7 +337,16 @@ fn prepare_windterm_auth(
});
}
if let Some(key_id) = import_windterm_key(entry, auto_login, source_path, session_name, ssh_keys, key_ids)? {
if allow_local_files
&& let Some(key_id) = import_windterm_key(
entry,
auto_login,
source_path,
session_name,
ssh_keys,
key_ids,
)?
{
return Ok(ConnectionAuth {
mode: "key".to_string(),
account_id: None,
@@ -386,17 +404,15 @@ fn import_windterm_key(
return Ok(None);
}
let Some((key_path, resolved_path, key_content)) =
key_paths.into_iter().find_map(|path| {
let resolved = resolve_windterm_key_path(path, source_path)?;
let content = std::fs::read_to_string(&resolved).ok()?;
if content.trim().is_empty() {
None
} else {
Some((path, resolved, content))
}
})
else {
let Some((key_path, resolved_path, key_content)) = key_paths.into_iter().find_map(|path| {
let resolved = resolve_windterm_key_path(path, source_path)?;
let content = std::fs::read_to_string(&resolved).ok()?;
if content.trim().is_empty() {
None
} else {
Some((path, resolved, content))
}
}) else {
return Ok(None);
};
@@ -412,6 +428,13 @@ fn import_windterm_key(
if let Some(id) = key_ids.get(&dedupe_key) {
return Ok(Some(id.clone()));
}
if passphrase.is_none()
&& let Some((_, id)) = key_ids
.iter()
.find(|((path, _), _)| path == &normalized_path)
{
return Ok(Some(id.clone()));
}
let id = uuid::Uuid::new_v4().to_string();
let display_name = Path::new(key_path)
@@ -457,7 +480,10 @@ fn resolve_windterm_key_path(path: &str, source_path: Option<&Path>) -> Option<s
.replace("${HomeDir}", &home_str);
if expanded == "~" {
expanded = home_str.to_string();
} else if let Some(rest) = expanded.strip_prefix("~/").or_else(|| expanded.strip_prefix("~\\")) {
} else if let Some(rest) = expanded
.strip_prefix("~/")
.or_else(|| expanded.strip_prefix("~\\"))
{
expanded = home.join(rest).to_string_lossy().to_string();
}
}
@@ -1,13 +1,21 @@
fn parse_xshell(path: &str) -> AppResult<Vec<ImportedSession>> {
let file = std::fs::File::open(path)
.map_err(|e| AppError::Config(format!("Cannot open file: {e}")))?;
parse_xshell_reader(file)
}
fn parse_xshell_reader(file: impl Read + std::io::Seek) -> AppResult<Vec<ImportedSession>> {
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| AppError::Config(format!("Invalid ZIP/XTS file: {e}")))?;
let mut sessions = Vec::new();
let mut expanded_bytes = 0u64;
if archive.len() > 10_000 {
return Err(AppError::Config("Too many archive entries".into()));
}
for i in 0..archive.len() {
let mut entry = archive
let entry = archive
.by_index(i)
.map_err(|e| AppError::Config(format!("ZIP entry error: {e}")))?;
@@ -19,10 +27,18 @@ fn parse_xshell(path: &str) -> AppResult<Vec<ImportedSession>> {
}
let mut raw = Vec::new();
expanded_bytes = expanded_bytes.saturating_add(entry.size());
if expanded_bytes > 50 * 1024 * 1024 {
return Err(AppError::Config("Archive exceeds 50 MiB".into()));
}
entry
.take(50 * 1024 * 1024 + 1)
.read_to_end(&mut raw)
.map_err(|e| AppError::Config(format!("Failed to read {entry_path}: {e}")))?;
if raw.len() > 50 * 1024 * 1024 {
return Err(AppError::Config("Archive exceeds 50 MiB".into()));
}
let content = decode_bytes(&raw);
if let Some(sess) = parse_xsh_content(&content, &entry_path) {
@@ -0,0 +1,84 @@
use crate::config;
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize)]
pub struct KeywordHighlightImportResult {
pub imported_rules: usize,
pub updated_rules: usize,
pub total_rules: usize,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum KeywordHighlightImportFile {
Config {
keyword_highlights: Vec<config::KeywordHighlightRule>,
},
Rules(Vec<config::KeywordHighlightRule>),
}
pub fn parse_keyword_highlight_import(raw: &str) -> AppResult<Vec<config::KeywordHighlightRule>> {
let import_file: KeywordHighlightImportFile = serde_json::from_str(raw)
.map_err(|error| AppError::Config(format!("Invalid highlight rules JSON: {error}")))?;
Ok(match import_file {
KeywordHighlightImportFile::Config { keyword_highlights } => keyword_highlights,
KeywordHighlightImportFile::Rules(rules) => rules,
})
}
pub fn merge_keyword_highlight_rules(
existing: &mut Vec<config::KeywordHighlightRule>,
imported: Vec<config::KeywordHighlightRule>,
mut next_id: impl FnMut() -> String,
) -> AppResult<KeywordHighlightImportResult> {
let mut imported_rules = 0;
let mut updated_rules = 0;
let mut indexes = existing
.iter()
.enumerate()
.filter_map(|(index, rule)| (!rule.id.trim().is_empty()).then(|| (rule.id.clone(), index)))
.collect::<HashMap<_, _>>();
for mut rule in imported {
rule.name = rule.name.trim().to_string();
rule.patterns = rule
.patterns
.into_iter()
.map(|pattern| pattern.trim().to_string())
.filter(|pattern| !pattern.is_empty())
.collect();
if rule.name.is_empty() || rule.patterns.is_empty() {
continue;
}
rule.id = rule.id.trim().to_string();
if rule.id.is_empty() {
rule.id = next_id();
}
if let Some(index) = indexes.get(&rule.id).copied() {
existing[index] = rule;
updated_rules += 1;
} else {
let id = rule.id.clone();
existing.push(rule);
indexes.insert(id, existing.len() - 1);
imported_rules += 1;
}
}
if imported_rules == 0 && updated_rules == 0 {
return Err(AppError::Config(
"No valid highlight rules found in import file".to_string(),
));
}
Ok(KeywordHighlightImportResult {
imported_rules,
updated_rules,
total_rules: existing.len(),
})
}
@@ -0,0 +1,14 @@
pub mod history;
mod recording;
pub use recording::{ExistingFileBehavior, RecordingMode, RotationPolicy};
pub mod ai;
pub mod capabilities;
pub mod monitoring;
pub mod quick_commands;
pub mod translate;
pub mod backup;
pub mod backup_crypto;
pub mod importer;
pub mod keyword_highlights;
pub mod portable_snapshot;
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more