feat: add web deployment support

This commit is contained in:
Kang committed 2026-10-04 00:56:09 +08:00
1 parent 15922a5605
commit 5d1df9e237
238 files changed
+23777 -8879

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
.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
+6
View File
@@ -39,3 +39,9 @@ temp/
.claude
.codex
.trae
# Web runtime secrets and data
.env
.env.*
nyaterm-web-data/
web-test-data/
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
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/vendor/russh src-tauri/vendor/russh
COPY src-tauri/vendor/russh-sftp src-tauri/vendor/russh-sftp
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"]
+25
View File
@@ -0,0 +1,25 @@
services:
nyaterm:
build:
context: .
args:
NYATERM_WEB_BASE_PATH: ${NYATERM_WEB_BASE_PATH:-/}
ports:
- "8080:8080"
environment:
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:
+115
View File
@@ -0,0 +1,115 @@
# Web / Desktop 实现与验收报告
## 源码审查与边界
实际入口为 `src/main.tsx`,主窗口复用 `AppProvider`、`PluginProvider` 和 `App.tsx`,子页面复用 `ChildAppProvider` / `ChildWindowRouter`。原来的 `src/lib/invoke.ts` 是主要业务调用入口,但会话工厂、日志、事件、窗口、文件和设置代码也存在直接 Tauri 调用。本次将这些调用转到 `src/lib/backend`;没有新增第二套 React 应用或替换现有 workspace/state 模型。
Desktop 的 `cmd/session.rs` 在进入 `core/ssh/session.rs` 前处理连接解析、认证提示、录制和 ownership。`core/ssh/{client,auth,io}.rs` 包含 Tauri state/event 依赖,以及 OSC、录制、Zmodem、命令捕获等原生逻辑,继续留在 Desktop。SFTP 使用同一 russh 连接和 vendored russh-sftp。配置/存储/加密中的 AppHandle 原来主要是接口参数,适合抽到共享库。AI provider、模型选择、流式解析、prompt/redaction/history 同样抽到共享库;Desktop 的 Agent 执行、审批和原生集成保留。
参考了 `temp/dbx/crates/dbx-web` 的 Core/HTTP/SSE/静态资源分离、限额和 base path 设计;没有复制 DBX 源码或引入其依赖。
## 已实现架构
```mermaid
flowchart TD
UI[现有 React / xterm / Providers] --> API[backend API / capability / platform]
API --> D[Tauri invoke / event]
API --> W[HTTP JSON / WS / SSE]
D --> DC[原有 Tauri commands / SessionManager]
W --> WC[Axum routes / owned WebSession]
DC --> C[nyaterm-core]
WC --> C
C --> P[共享 config / redb / crypto / SSH协议 / SFTP工具 / AI]
DC --> N[Desktop 原生 I/O / PTY / 录制 / Zmodem等]
```
Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Core。没有把 vendored russh 纳入新的顶层 workspace,避免其已有 workspace dependency 解析冲突。
共享范围包括配置类型与迁移、redb 文档、AES-GCM/主密码密钥层次、历史工具、SSH 算法/连接/密码和交互认证/通道回复/输入/resize、SFTP 初始化及原有文件属性/文本冲突工具、私钥校验和公钥派生、AI provider/model/catalog/prompt/redaction/parser/stream/history。Web 专属的会话 ownership、cookie、认证提示、HTTP 文件流和生命周期留在 Web crate。Desktop 和 Web 的会话调度循环不同,因为 Desktop 仍提供原生附加能力;没有复制原生 SSH/SFTP 全栈。
## 新增与修改文件
| 文件/目录 | 实际作用 |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `src/lib/backend/{api,tauri,http,runtime,events}.ts` | 同类型 invoke/listen/emit,运行时选择、集中能力、同源 URL、WS 和 SSE。 |
| `src/lib/backend/platform/{app,window,webviewWindow,webview,path,dialog,opener}.ts` | Desktop 转发原 API;浏览器替代或拒绝原生操作。 |
| `src/lib/backend/{BrowserGate.tsx,files.ts,web.test.tsx}` | 登录入口、浏览器 File 上传/下载和边界测试。 |
| `src/main.tsx`、`src/lib/{invoke,windowManager,appSessionFactory,appWorkspace,...}` | 接入边界,复用现有页面、状态和子窗口流程。 |
| 现有 providers/hooks、terminal/SFTP/AI/设置/连接/插件组件 | 替换原生 imports,依据集中 capability 隐藏/禁用能力。四种 locale 增加 Web 登录文本。 |
| `src-tauri/crates/nyaterm-core/src/{config,storage,utils,core/ai,core/history,ssh,services.rs}` | 从原有文件移动共享逻辑,新增通用协议与服务边界。 |
| `src-tauri/src/{config,storage,utils,core/ai,core/history,core/sftp/util.rs,...}` | 原位置使用 re-export/wrapper,保留 Desktop 调用兼容性。文件删除对应迁入 Core,非删除桌面功能。 |
| `src-tauri/src/{cmd/connection.rs,cmd/ai.rs,core/ssh,core/sftp/sftp_backend/session.rs}` | 调用共享服务与协议;原生管理器继续保留。 |
| `src-tauri/crates/nyaterm-web/src/{main,lib,auth,state,session,commands,sftp,ai,plugins,error}.rs` | 独立服务、显式命令 allowlist、会话和资源清理。 |
| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap}.rs` | 真正 russh/SFTP fixture 与进程启动测试。 |
| 各 crate Cargo.toml/Cargo.lock、`package.json`、`vite.config.ts` | 依赖、构建入口和 base path。 |
| `Dockerfile`、`.dockerignore`、`compose.web.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 |
| `docs/web-deployment.md`、本文 | 操作步骤、能力、安全边界和验证报告。 |
原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP/VNC Channel、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。
## 调用链
**Desktop terminal**:现有会话工厂 → typed invoke → Tauri adapter → `cmd/session.rs` → 原 SessionManager/SSH session → 共享 protocol/algorithm/input/resize → 原生 I/O loop → Tauri output event → 原 XTerminal。
**Web terminal**:同一会话工厂 → HTTP adapter → `POST /api/sessions` → WebSession + Core protocol/russh → `WS /api/sessions/{id}/terminal` → 二进制输出 / JSON 或二进制输入 / JSON resize/control → 同一 XTerminal。SSH host-key/password/passphrase/keyboard-interactive 提示通过 owner 的 SSE 到现有对话框,HTTP 响应按 requestId 返回。
**SFTP**:同一 FileExplorer → HTTP command → owner session 检查 → 同一 russh handle 的 SFTP subsystem → 共享初始化/文件工具。上传使用浏览器 File 作为原始流式 request body,远端同目录 `.part` 成功后 rename;下载使用 HTTP body stream 和 Content-Disposition/Content-Length。进度通过 SSE 的现有 transfer-event 格式送入 TransferContext。Desktop 继续使用本地 picker/path/拖拽、原有原生传输管理器。
**AI**:同一 AI panel → HTTP command → Web Ask/ownership/强制 redaction → Core model/provider/stream → owner SSE → panel。模型发现、测试、历史、清除/重绑共享 Core。Agent/MCP/原生附件在 Web 拒绝。
**设置与凭据**:HTTP allowlist / Tauri commands → shared config/services → redb/AES-GCM;Web 显式 secret getters 仅供已认证管理员主动读取,列表不返回明文。Web 会合并 masked AI/settings 字段,并拒绝通过 settings save 关闭或替换现有主密码。
## 生命周期与资源上限
1. 先建立 SSE,再创建 SSH;创建立即返回不透明 ID,后台连接有 120 秒总超时,提示有 90 秒超时。
2. 每个登录最多 16 个会话、4 个 AI 请求;全局最多 128 个会话和 128 个登录;每个 session 最多 4 个 SFTP 操作。
3. 每个会话最多一个 WS attachment;SSH output queue 为 32 × 64 KiB(2 MiB),WS write buffer 最多 256 KiB。输出按序 backpressure,最终 close 在 final bytes 后;socket send 有超时。输入 queue 为 128 条、WS 单帧/消息最大 64 KiB,浏览器发送缓冲超过 1 MiB 拒绝继续输入。
4. WS 每 15 秒 heartbeat,45 秒没有 peer 活动断开。断线保留 30 秒 attachment lease 和有界输出;浏览器自动尝试同 session 重附着,明确 SSH reconnect 则创建新连接。lease 到期取消连接。
5. 显式 close、创建取消、logout、8 小时登录到期和 shutdown 通过 CancellationToken 终止等待、SSH、SFTP 和 AI。SSH disconnect 有超时,registry 清除后发 session change。
6. 上传中断/失败或响应被丢弃时,RAII cleanup 清除 `.part` 并关闭 SFTP;上传/下载和文件操作均受 owner 与 cancel 约束。Web 无传输暂停/重试服务。
7. SIGTERM/Ctrl-C 取消共享 shutdown,HTTP graceful drain 最多 5 秒,再等待会话清理最多 5 秒。重启不会恢复活跃 SSH。
## 能力矩阵
| 能力 | Web MVP |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 保存/临时 SSH、终端输入输出/resize/关闭 | 支持标准 profile,UTF-8;应用终端类型,解析全局编码。 |
| 密码/私钥、主机指纹与手工交互认证 | 支持;不绕过 known_hosts;可提示私钥 passphrase。 |
| 保存连接/组、基础凭据、设置 | 共享持久化。保存数据为实例共享;活动会话按登录隔离。 |
| SFTP 浏览、单文件上传/下载、文本编辑 | 流式传输;应用 enabled/pipeline depth;UTF-8 文件名;不支持 compatibility mode/递归或原生工作流。 |
| AI Assistant | provider API/Ask streaming、脱敏、历史;无 Agent、MCP 或本地附件。 |
| 插件 | Web target/capability/permission 检查框架;只允许 UI 类权限,未提供安装或执行器。 |
| 本地文件、剪贴板、外部链接、子窗口 | 浏览器 File/download、Clipboard、受限链接、同源 iframe 页面替代;浏览器权限仍适用。 |
| Local Shell/PTY、Serial、Telnet、RDP/VNC | 不支持;创建入口与服务均 gate。 |
| tray、原生窗口、全局快捷键、桌面通知、OS credential manager、自动更新 | 不支持;Web 采用服务端加密存储和浏览器页面。 |
| SCP/Zmodem、watcher、复杂本地文件集成、录制、远程监控、传输控制、同步备份 | 不支持,集中 capability/panel/settings gate。 |
| SSH proxy/jump、agent/X11/证书、启动命令、network_device、非 UTF-8 | 拒绝连接;不能静默改成直接连接或忽略这些配置。 |
| OSC/CWD 自动跟踪、动态标题 | Web 暂不提供;Desktop 原逻辑保留。 |
## 安全边界
这是单管理员实例,不是多租户 ACL 服务。共享保存配置和历史应只对可信管理员开放。高熵登录密码与独立 32 字节外部数据密钥必须配置;启动不能回退到 OS credential manager 或路径派生密钥。既有主密码层次保留,错误数据密钥启动失败。redb 的元数据/AI 历史不等于整个数据库加密。
cookie 为随机 256 bit、HttpOnly、SameSite=Strict,HTTPS 下 Secure,Path 绑定 base path,8 小时有效。登录校验 Origin 与自定义请求头;mutation 校验 CSRF;WS 校验 cookie 和 Origin;所有静态/API/事件/传输入口校验配置的 Host 与存在的 Origin。没有 wildcard CORS,也不把 forwarded headers 当作认证依据。非回环 public URL 必须 HTTPS。登录尝试限额为全局每分钟 20 次。
session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返回 404、prompt 返回 403。WS URL 不含 token。SSH unknown/changed key 均要求明确确认后才保存。secret 不进入日志/列表,响应使用 no-store,并设置 CSP/nosniff/referrer policy。Web command 显式 allowlist,不能反射调用任意 Tauri command、访问服务端任意本地路径或执行本地程序。浏览器插件框架不提供服务端文件/进程权限。
## 验证结果
已完成的验证:
- 共享 Core:248 个单元测试通过,覆盖原有配置、迁移、存储、加密、SFTP helper、AI provider/catalog。
- 前端全量:157 个文件、990 个测试通过;Web adapter 的 5 个测试覆盖同源/base URL、CSRF、UTF-8 WS/输入/resize、native 拒绝、有界发送、既有设置 iframe 和登录 gate。
- Web integration:真实 russh fixture、SFTP subsystem、mock OpenAI-compatible provider;认证/CSRF/Host/CSP/SPA、owner prompts、known-host 首次确认及变更拒绝、UTF-8 input/resize、断开重附着、3 MiB 输出及 final-tail、5 MiB 上传下载、上传中断清理、凭据加密、AI stream/redaction/history、错误加密密钥拒绝。
- Desktop `cargo check --lib` 通过。原生 Rust 测试已成功编译,但测试 exe 在运行前以 `0xc0000139` / `STATUS_ENTRYPOINT_NOT_FOUND` 退出,无法验证 Desktop 原生 SSH/SFTP 运行测试;共享部分已由 Core 和 Web 测试验证。
- Web 进程启动测试通过:外部 `_FILE` 秘密、真实 HTTP 登录/API、子路径首页,以及更换数据密钥后拒绝重启。Axum 子路由使用带 `/` 的挂载点,裸路径重定向到目录 URL。
- 追加的 Web 安全/兼容断言通过:跨 owner / 错 Origin 的 WS upgrade 拒绝、全局编码、保存连接的加密认证、SFTP disabled、AI target-context ownership/redaction、清除历史、HTTP logout,以及等待 host-key prompt 时取消创建后的 session/prompt 清理。
- TypeScript、`pnpm lint`、Rust formatting、Desktop `cargo check --lib --locked` 通过。lint 保留未修改 CommandSuggestions 中一个既有 dependency warning;Vite 有既有 chunk-size/Browserslist 提示。
- `pnpm build` 和 `/nyaterm/` base path 的 `pnpm build:web` 已通过;输出资源和图标使用构建 base path;最后一轮两种构建均通过。
本机没有 Docker CLI/engine 或可用浏览器。因此未执行 Docker image build、Linux 容器运行和浏览器视觉/手工交互验收;不能把 HTTP fixture/DOM 测试称为这些验证。部署步骤见 [web-deployment.md](web-deployment.md)。
## 后续扩展
优先在 Linux CI 构建和启动 Docker 镜像,加入 Playwright 登录→保存 SSH→指纹确认→xterm→SFTP 的完整浏览器验收,并在三平台 native runner 验证 Desktop。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP overwrite/递归/取消控制、OSC/CWD 与 SSH proxy/jump。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。
+120
View File
@@ -0,0 +1,120 @@
# Web / Docker 部署
Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 MVP 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。
## 本地运行
需要 Node 24、pnpm 11.10 和 Rust 1.97.1(本次实际验证的版本)。
```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 的路径一致并以 `/` 结尾。 |
服务启动会验证数据密钥;错误密钥会阻止启动。文件方式会去掉末尾换行。登录密码可独立替换,服务器重启使所有旧登录失效;加密密钥目前没有在线轮换 API。
## Docker
```sh
docker build -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 反向代理配置。
仓库还提供 `compose.web.yml`,它通过外部环境变量注入密码和密钥:
```sh
docker compose -f compose.web.yml up --build -d
```
提前在当前 shell 或受限 `.env` 中设置两个必填秘密。`.env*` 已排除在 Git 和 Docker build context 外。生产环境优先将 Compose 改为 `secrets` 挂载和 `_FILE` 变量,避免把秘密写入镜像。Dockerfile 使用 Node、Rust、Debian 三阶段构建,最终镜像只含 dist、服务程序和运行依赖。
## HTTPS 和子路径
例如部署到 `https://terminal.example.com/nyaterm/`:
```sh
docker build --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web .
```
运行时设置 `NYATERM_WEB_PUBLIC_URL=https://terminal.example.com/nyaterm/`。前端和后端必须使用相同的 origin 和路径;后端会校验原始 Host / Origin,不使用转发头放宽校验。代理应保留完整路径,不能剥掉 `/nyaterm`。
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、密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask 和设置持久化。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。
Web 当前不支持本地 Shell、Serial、Telnet、RDP/VNC、代理/跳板、SSH agent/X11/证书登录、启动命令、非标准 SSH profile、非 UTF-8 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、远程监控、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。
测试与架构见 [web-architecture.md](web-architecture.md)。当前环境没有 Docker 和浏览器,因此镜像实构建和浏览器视觉验收仍需在部署环境执行。
+5 -1
View File
@@ -41,7 +41,11 @@
"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"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
+33
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,38 @@ dependencies = [
"zmodem2",
]
[[package]]
name = "nyaterm-core"
version = "0.1.0"
dependencies = [
"aes-gcm 0.10.3",
"base64 0.22.1",
"dirs",
"font-kit",
"futures-util",
"genai",
"hex",
"hmac 0.12.1",
"nucleo-matcher",
"redb",
"regex",
"reqwest 0.13.4",
"russh",
"russh-sftp",
"serde",
"serde_json",
"sha1 0.10.7",
"sha2 0.10.9",
"ssh-key 0.6.7",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-util",
"tracing",
"uuid",
"zeroize",
]
[[package]]
name = "nyaterm-gpu"
version = "0.1.0"
+2
View File
@@ -82,9 +82,11 @@ 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" }
tempfile = "3"
nyaterm-gpu = { path = "crates/nyaterm-gpu" }
nyaterm-otp = { path = "crates/otp" }
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "nyaterm-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
[features]
test-support = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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"
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"] }
[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,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);
}
@@ -67,7 +67,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 +159,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) => {
@@ -0,0 +1,5 @@
pub mod history;
mod recording;
pub use recording::{ExistingFileBehavior, RecordingMode, RotationPolicy};
pub mod ai;
pub mod capabilities;
@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum RecordingMode {
#[default]
Transcript,
Raw,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExistingFileBehavior {
#[default]
Unique,
Append,
Overwrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum RotationPolicy {
Session,
Daily,
Size { max_bytes: u64 },
}
impl Default for RotationPolicy {
fn default() -> Self {
Self::Session
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Unified error type for Tauri commands.
//!
//! Provides `AppError` and `AppResult` for consistent error handling across the app.
use serde::Serialize;
/// Unified error type for all Tauri commands.
///
/// Implements `Serialize` so Tauri can pass the error message to the frontend.
/// Uses `thiserror` for ergonomic `From` conversions and `Display` formatting.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("SSH error: {0}")]
Ssh(#[from] russh::Error),
#[error("SSH key error: {0}")]
SshKey(#[from] russh::keys::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
SessionNotFound(String),
#[error("{0}")]
Auth(String),
#[error("{0}")]
Config(String),
#[cfg_attr(not(windows), allow(dead_code))]
#[error("{0}")]
Unsupported(String),
#[error("{0}")]
Storage(String),
#[error("{0}")]
Channel(String),
#[error("{0}")]
Cancelled(String),
#[error("SFTP error: {0}")]
Sftp(#[from] russh_sftp::client::error::Error),
#[error("Crypto error: {0}")]
Crypto(String),
#[error("{0}")]
CloudSync(#[from] CloudSyncError),
#[error("Translation error: {0}")]
Translation(String),
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum CloudSyncError {
#[error(
"Remote sync metadata is inconsistent: latest points to {revision} but the referenced snapshot is missing."
)]
SnapshotMissing { revision: String },
#[error(
"New sync snapshot was not accepted by remote storage (candidate revision {revision}; latest was not changed)."
)]
SnapshotNotAccepted { revision: String },
#[error("Remote sync storage rejected file '{filename}' (gist may be at file capacity).")]
RemoteFileRejected { filename: String },
#[error(
"Remote sync snapshot revision mismatch: latest points to {pointer_revision} but snapshot contains {snapshot_revision}."
)]
RevisionMismatch {
pointer_revision: String,
snapshot_revision: String,
},
#[error("Remote sync snapshot hash mismatch: expected {expected} but got {actual}.")]
HashMismatch { expected: String, actual: String },
#[error(
"Remote sync was updated by another device: expected {expected_revision:?} but found {actual_revision:?}."
)]
ConcurrentUpdate {
expected_revision: Option<String>,
actual_revision: Option<String>,
},
#[error("Remote sync snapshot {revision} is corrupted.")]
CorruptedSnapshot { revision: String },
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
/// Convenience alias for `Result<T, AppError>`.
pub type AppResult<T> = Result<T, AppError>;
+9
View File
@@ -0,0 +1,9 @@
#![recursion_limit = "256"]
//! Transport-neutral persistence and SSH services used by Desktop and Web.
pub mod config;
pub mod core;
pub mod error;
pub mod services;
pub mod ssh;
pub mod storage;
pub mod utils;
@@ -0,0 +1,354 @@
use crate::config::{self, SavedConnection};
use crate::error::{AppError, AppResult};
use crate::utils::crypto;
use std::collections::HashSet;
pub fn save_connection(app: &impl Sized, mut connection: SavedConnection) -> AppResult<String> {
let mut cfg = config::load_config(app)?;
if connection.id.is_empty() {
connection.id = uuid::Uuid::new_v4().to_string();
}
let target_id = connection.id.clone();
let existing = cfg.connections.iter().find(|c| c.id == target_id).cloned();
normalize_connection_for_save(&mut connection);
config::validate_ssh_agent_settings(&connection.config)?;
validate_proxy_jump_config(&connection, &cfg.connections)?;
validate_ssh_algorithm_config(&connection)?;
validate_sftp_settings_config(&connection)?;
validate_rdp_config(&connection)?;
validate_vnc_config(&connection)?;
if let Some(ref mut auth) = connection.auth {
// account_id: Some("") means explicitly cleared, None means preserve existing
match auth.account_id.as_deref() {
Some("") => auth.account_id = None,
None => {
auth.account_id = existing
.as_ref()
.and_then(|entry| entry.auth.as_ref())
.and_then(|entry| entry.account_id.clone());
}
_ => {}
}
// password_source: Some("") means legacy/default behavior, None means preserve existing
match auth.password_source.as_deref() {
Some("") => auth.password_source = None,
None => {
auth.password_source = existing
.as_ref()
.and_then(|entry| entry.auth.as_ref())
.and_then(|entry| entry.password_source.clone());
}
_ => {}
}
// password_id: Some("") means explicitly cleared, None means preserve existing
match auth.password_id.as_deref() {
Some("") => auth.password_id = None,
None => {
auth.password_id = existing
.as_ref()
.and_then(|e| e.auth.as_ref())
.and_then(|a| a.password_id.clone());
}
_ => {}
}
if matches!(
connection.config,
config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. }
) && auth.account_id.as_deref().is_some_and(|id| !id.is_empty())
{
auth.password_id = None;
}
// password: non-empty = encrypt new value, "" = explicitly clear, None = preserve
auth.password = match auth.password.as_deref() {
Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?),
Some("") => None,
None => existing
.as_ref()
.and_then(|e| e.auth.as_ref())
.and_then(|a| a.password.clone()),
_ => None,
};
auth.has_password = false;
}
if let Some(existing_connection) = existing.as_ref() {
connection.asset = existing_connection.asset.clone();
}
if let Some(ex) = cfg.connections.iter_mut().find(|c| c.id == target_id) {
*ex = connection;
} else {
cfg.connections.push(connection);
}
config::save_config(app, &cfg)?;
Ok(target_id)
}
pub fn normalize_connection_for_save(connection: &mut SavedConnection) {
config::migrate_legacy_ssh_agent_settings(connection);
config::migrate_legacy_asset_tags(connection);
}
pub fn validate_ssh_algorithm_config(connection: &SavedConnection) -> AppResult<()> {
if !matches!(connection.config, config::ConnectionType::Ssh { .. }) {
return Ok(());
}
let Some(preferences) = connection.ssh_algorithms.as_ref() else {
return Ok(());
};
crate::ssh::algorithms::validate_ssh_algorithm_preferences(preferences)
}
pub fn validate_sftp_settings_config(connection: &SavedConnection) -> AppResult<()> {
if !matches!(connection.config, config::ConnectionType::Ssh { .. }) {
return Ok(());
}
let timeout_ms = connection.sftp.shell_detection_timeout_ms;
if !(config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS..=config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS)
.contains(&timeout_ms)
{
return Err(AppError::Config(format!(
"SFTP shell detection timeout must be between {} and {} ms",
config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS,
config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS
)));
}
Ok(())
}
pub fn validate_rdp_config(connection: &SavedConnection) -> AppResult<()> {
let config::ConnectionType::Rdp {
host,
port,
username,
security,
display,
clipboard,
reconnect,
..
} = &connection.config
else {
return Ok(());
};
if host.trim().is_empty() {
return Err(AppError::Config("RDP host is required".to_string()));
}
if *port == 0 {
return Err(AppError::Config(
"RDP port must be between 1 and 65535".to_string(),
));
}
if username.trim().is_empty() {
return Err(AppError::Config("RDP username is required".to_string()));
}
if !matches!(
security.certificate_policy.as_str(),
"strict" | "prompt" | "accept-temporarily"
) {
return Err(AppError::Config(
"RDP certificate policy is invalid".to_string(),
));
}
if !matches!(display.mode.as_str(), "fit-window" | "fixed" | "native") {
return Err(AppError::Config("RDP display mode is invalid".to_string()));
}
if !(640..=7680).contains(&display.width) || !(480..=4320).contains(&display.height) {
return Err(AppError::Config(
"RDP display size is outside the supported range".to_string(),
));
}
if !matches!(display.color_depth, 16 | 24 | 32) {
return Err(AppError::Config("RDP color depth is invalid".to_string()));
}
if !matches!(
clipboard.mode.as_str(),
"disabled" | "text-only" | "text-and-files"
) {
return Err(AppError::Config(
"RDP clipboard mode is invalid".to_string(),
));
}
if reconnect.max_attempts > 20 {
return Err(AppError::Config(
"RDP reconnect attempts must be 20 or fewer".to_string(),
));
}
Ok(())
}
pub fn validate_vnc_config(connection: &SavedConnection) -> AppResult<()> {
let config::ConnectionType::Vnc {
host,
port,
security,
display,
reconnect,
..
} = &connection.config
else {
return Ok(());
};
if host.trim().is_empty() {
return Err(AppError::Config("VNC host is required".to_string()));
}
if *port == 0 {
return Err(AppError::Config(
"VNC port must be between 1 and 65535".to_string(),
));
}
if !matches!(security.mode.as_str(), "auto" | "vnc-auth" | "none") {
return Err(AppError::Config("VNC security mode is invalid".to_string()));
}
if !matches!(display.scale_mode.as_str(), "fit" | "actual" | "stretch") {
return Err(AppError::Config("VNC scale mode is invalid".to_string()));
}
if reconnect.max_attempts > 20 {
return Err(AppError::Config(
"VNC reconnect attempts must be 20 or fewer".to_string(),
));
}
Ok(())
}
pub fn validate_proxy_jump_config(
connection: &SavedConnection,
existing_connections: &[SavedConnection],
) -> AppResult<()> {
let proxy_jump_id = connection
.network
.as_ref()
.and_then(|network| network.proxy_jump_id.as_deref());
let Some(proxy_jump_id) = proxy_jump_id else {
return Ok(());
};
if !matches!(
connection.config,
config::ConnectionType::Ssh { .. }
| config::ConnectionType::Rdp { .. }
| config::ConnectionType::Vnc { .. }
) {
return Err(AppError::Config(
"ProxyJump is only supported for SSH, RDP, and VNC connections".to_string(),
));
}
let mut visited = HashSet::new();
visited.insert(connection.id.as_str());
let mut current_jump_id = proxy_jump_id;
loop {
if !visited.insert(current_jump_id) {
if connection.id == current_jump_id {
return Err(AppError::Config(
"A connection cannot use itself as a jump host".to_string(),
));
}
return Err(AppError::Config(format!(
"ProxyJump chain contains a cycle at '{}'",
current_jump_id
)));
}
let jump_connection =
find_connection_for_proxy_jump(connection, existing_connections, current_jump_id)
.ok_or_else(|| {
AppError::Config(format!("Jump host '{}' not found", current_jump_id))
})?;
if !matches!(jump_connection.config, config::ConnectionType::Ssh { .. }) {
return Err(AppError::Config(
"Only SSH connections can be used as jump hosts".to_string(),
));
}
let Some(next_jump_id) = jump_connection
.network
.as_ref()
.and_then(|network| network.proxy_jump_id.as_deref())
else {
break;
};
current_jump_id = next_jump_id;
}
Ok(())
}
fn find_connection_for_proxy_jump<'a>(
edited_connection: &'a SavedConnection,
existing_connections: &'a [SavedConnection],
id: &str,
) -> Option<&'a SavedConnection> {
if edited_connection.id == id {
return Some(edited_connection);
}
existing_connections
.iter()
.find(|candidate| candidate.id == id)
}
fn missing_private_key_passphrase_error(error: &russh::keys::Error) -> bool {
let message = error.to_string().to_lowercase();
message.contains("encrypted")
|| message.contains("passphrase")
|| message.contains("password")
|| message.contains("cipher")
}
pub fn validate_private_key_content(content: &str, passphrase: Option<&str>) -> AppResult<()> {
let usable_passphrase = passphrase.filter(|value| !value.is_empty());
match russh::keys::decode_secret_key(content, usable_passphrase) {
Ok(_) => Ok(()),
Err(error)
if usable_passphrase.is_none() && missing_private_key_passphrase_error(&error) =>
{
Ok(())
}
Err(error) => Err(AppError::Config(format!(
"invalid SSH private key: {error}"
))),
}
}
pub fn derive_public_key_for_copy(content: &str, passphrase: Option<&str>) -> AppResult<String> {
let usable_passphrase = passphrase.filter(|value| !value.is_empty());
if let Ok(private_key) = russh::keys::decode_secret_key(content, usable_passphrase) {
return private_key.public_key().to_openssh().map_err(|error| {
AppError::Config(format!("failed to encode SSH public key: {error}"))
});
}
// OpenSSH containers keep the public key outside the encrypted private payload.
let private_key = ssh_key::PrivateKey::from_openssh(content)
.map_err(|error| AppError::Config(format!("failed to derive SSH public key: {error}")))?;
private_key
.public_key()
.to_openssh()
.map_err(|error| AppError::Config(format!("failed to encode SSH public key: {error}")))
}
pub fn validate_certificate_content(content: &str) -> AppResult<()> {
russh::keys::Certificate::from_openssh(content)
.map(|_| ())
.map_err(|error| AppError::Config(format!("invalid OpenSSH certificate: {error}")))
}
@@ -0,0 +1,305 @@
use crate::config::{SshAlgorithmMode, SshAlgorithmPreferences};
use crate::error::{AppError, AppResult};
use russh::keys::{Algorithm, EcdsaCurve, HashAlg};
use russh::{Preferred, cipher, kex, mac};
use serde::Serialize;
use std::{borrow::Cow, convert::TryFrom, str::FromStr};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AlgorithmRisk {
Modern,
Legacy,
Insecure,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AlgorithmOption {
pub id: String,
pub label: String,
pub risk: AlgorithmRisk,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SshAlgorithmDefaults {
pub kex: Vec<String>,
pub ciphers: Vec<String>,
pub macs: Vec<String>,
pub host_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SupportedSshAlgorithms {
pub kex: Vec<AlgorithmOption>,
pub ciphers: Vec<AlgorithmOption>,
pub macs: Vec<AlgorithmOption>,
pub host_keys: Vec<AlgorithmOption>,
pub compatible: SshAlgorithmDefaults,
pub secure: SshAlgorithmDefaults,
}
pub fn compatible_algorithms() -> Preferred {
let mut preferred = Preferred::default();
preferred.kex = Cow::Owned(vec![
kex::MLKEM768X25519_SHA256,
kex::CURVE25519,
kex::CURVE25519_PRE_RFC_8731,
kex::ECDH_SHA2_NISTP256,
kex::ECDH_SHA2_NISTP384,
kex::ECDH_SHA2_NISTP521,
kex::DH_G18_SHA512,
kex::DH_G17_SHA512,
kex::DH_G16_SHA512,
kex::DH_G15_SHA512,
kex::DH_G14_SHA256,
kex::DH_GEX_SHA256,
kex::DH_G14_SHA1,
kex::DH_GEX_SHA1,
kex::DH_G1_SHA1,
kex::EXTENSION_SUPPORT_AS_CLIENT,
kex::EXTENSION_SUPPORT_AS_SERVER,
kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
]);
preferred.key = Cow::Owned(vec![
Algorithm::Ed25519,
Algorithm::Ecdsa {
curve: EcdsaCurve::NistP256,
},
Algorithm::Ecdsa {
curve: EcdsaCurve::NistP384,
},
Algorithm::Rsa {
hash: Some(HashAlg::Sha512),
},
Algorithm::Rsa {
hash: Some(HashAlg::Sha256),
},
// Some network devices advertise malformed P-521 ECDSA host keys; prefer
// plain ssh-rsa first so negotiation can reach authentication.
Algorithm::Rsa { hash: None },
Algorithm::Ecdsa {
curve: EcdsaCurve::NistP521,
},
Algorithm::Dsa,
]);
preferred.cipher = Cow::Owned(vec![
cipher::CHACHA20_POLY1305,
cipher::AES_256_GCM,
cipher::AES_128_GCM,
cipher::AES_256_CTR,
cipher::AES_192_CTR,
cipher::AES_128_CTR,
cipher::AES_256_CBC,
cipher::AES_192_CBC,
cipher::AES_128_CBC,
cipher::TRIPLE_DES_CBC,
]);
preferred.mac = Cow::Owned(vec![
mac::HMAC_SHA512_ETM,
mac::HMAC_SHA256_ETM,
mac::HMAC_SHA512,
mac::HMAC_SHA256,
mac::HMAC_SHA1_ETM,
mac::HMAC_SHA1,
]);
preferred
}
pub fn secure_algorithms() -> Preferred {
Preferred::default()
}
pub fn validate_ssh_algorithm_preferences(preferences: &SshAlgorithmPreferences) -> AppResult<()> {
resolve_preferred_algorithms(Some(preferences)).map(|_| ())
}
pub fn resolve_preferred_algorithms(
preferences: Option<&SshAlgorithmPreferences>,
) -> AppResult<Preferred> {
let Some(preferences) = preferences else {
return Ok(compatible_algorithms());
};
match preferences.mode {
SshAlgorithmMode::Compatible => Ok(compatible_algorithms()),
SshAlgorithmMode::Secure => Ok(secure_algorithms()),
SshAlgorithmMode::Custom => custom_algorithms(preferences),
}
}
fn custom_algorithms(preferences: &SshAlgorithmPreferences) -> AppResult<Preferred> {
let mut preferred = Preferred::default();
preferred.kex = Cow::Owned(parse_kex_names(&preferences.kex)?);
preferred.cipher = Cow::Owned(parse_cipher_names(&preferences.ciphers)?);
preferred.mac = Cow::Owned(parse_mac_names(&preferences.macs)?);
preferred.key = Cow::Owned(parse_host_key_algorithms(&preferences.host_keys)?);
Ok(preferred)
}
fn parse_required_list<T, F>(values: &[String], label: &str, mut parse: F) -> AppResult<Vec<T>>
where
F: FnMut(&str) -> Option<T>,
{
if values.is_empty() {
return Err(AppError::Config(format!(
"SSH algorithm list '{}' must not be empty",
label
)));
}
values
.iter()
.map(|value| {
parse(value).ok_or_else(|| {
AppError::Config(format!(
"Unsupported SSH algorithm '{}' in {}",
value, label
))
})
})
.collect()
}
fn parse_kex_names(values: &[String]) -> AppResult<Vec<kex::Name>> {
parse_required_list(values, "key exchanges", |value| {
kex::Name::try_from(value).ok()
})
}
fn parse_cipher_names(values: &[String]) -> AppResult<Vec<cipher::Name>> {
parse_required_list(values, "ciphers", |value| {
cipher::Name::try_from(value).ok()
})
}
fn parse_mac_names(values: &[String]) -> AppResult<Vec<mac::Name>> {
parse_required_list(values, "MACs", |value| mac::Name::try_from(value).ok())
}
fn parse_host_key_algorithms(values: &[String]) -> AppResult<Vec<Algorithm>> {
parse_required_list(values, "host keys", |value| Algorithm::from_str(value).ok())
}
fn defaults_from_preferred(preferred: Preferred) -> SshAlgorithmDefaults {
SshAlgorithmDefaults {
kex: preferred
.kex
.iter()
.map(|algorithm| algorithm.as_ref().to_string())
.collect(),
ciphers: preferred
.cipher
.iter()
.map(|algorithm| algorithm.as_ref().to_string())
.collect(),
macs: preferred
.mac
.iter()
.map(|algorithm| algorithm.as_ref().to_string())
.collect(),
host_keys: preferred.key.iter().map(ToString::to_string).collect(),
}
}
fn algorithm_option(id: impl Into<String>, risk: AlgorithmRisk) -> AlgorithmOption {
let id = id.into();
AlgorithmOption {
label: id.clone(),
id,
risk,
}
}
fn kex_risk(id: &str) -> AlgorithmRisk {
match id {
"diffie-hellman-group1-sha1"
| "diffie-hellman-group14-sha1"
| "diffie-hellman-group-exchange-sha1" => AlgorithmRisk::Insecure,
value if value.starts_with("diffie-hellman-") => AlgorithmRisk::Legacy,
_ => AlgorithmRisk::Modern,
}
}
fn cipher_risk(id: &str) -> AlgorithmRisk {
match id {
"3des-cbc" => AlgorithmRisk::Insecure,
value if value.ends_with("-cbc") => AlgorithmRisk::Legacy,
_ => AlgorithmRisk::Modern,
}
}
fn mac_risk(id: &str) -> AlgorithmRisk {
match id {
"hmac-sha1" => AlgorithmRisk::Insecure,
"hmac-sha1-etm@openssh.com" => AlgorithmRisk::Legacy,
_ => AlgorithmRisk::Modern,
}
}
fn host_key_risk(id: &str) -> AlgorithmRisk {
match id {
"ssh-dss" => AlgorithmRisk::Insecure,
"ssh-rsa" => AlgorithmRisk::Legacy,
_ => AlgorithmRisk::Modern,
}
}
pub fn get_supported_ssh_algorithms() -> SupportedSshAlgorithms {
let compatible = defaults_from_preferred(compatible_algorithms());
let secure = defaults_from_preferred(secure_algorithms());
let mut kex_ids = compatible.kex.clone();
for id in &secure.kex {
if !kex_ids.contains(id) {
kex_ids.push(id.clone());
}
}
let mut cipher_ids = compatible.ciphers.clone();
for id in &secure.ciphers {
if !cipher_ids.contains(id) {
cipher_ids.push(id.clone());
}
}
let mut mac_ids = compatible.macs.clone();
for id in &secure.macs {
if !mac_ids.contains(id) {
mac_ids.push(id.clone());
}
}
let mut host_key_ids = compatible.host_keys.clone();
for id in &secure.host_keys {
if !host_key_ids.contains(id) {
host_key_ids.push(id.clone());
}
}
SupportedSshAlgorithms {
kex: kex_ids
.into_iter()
.map(|id| algorithm_option(id.clone(), kex_risk(&id)))
.collect(),
ciphers: cipher_ids
.into_iter()
.map(|id| algorithm_option(id.clone(), cipher_risk(&id)))
.collect(),
macs: mac_ids
.into_iter()
.map(|id| algorithm_option(id.clone(), mac_risk(&id)))
.collect(),
host_keys: host_key_ids
.into_iter()
.map(|id| algorithm_option(id.clone(), host_key_risk(&id)))
.collect(),
compatible,
secure,
}
}
@@ -0,0 +1,744 @@
//! Shared helpers for remote file system backends: path quoting, permission
//! formatting, and common type definitions.
use crate::error::{AppError, AppResult};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const SFTP_FILE_TYPE_MASK: u32 = 0o170000;
pub const POSIX_MODE_MASK: u32 = 0o7777;
/// Parsed entry from a remote directory listing for the file explorer.
#[derive(Debug, Clone, Serialize)]
pub struct FileEntry {
pub name: String,
pub is_dir: bool,
pub is_symlink: bool,
pub size: u64,
pub permissions: String,
pub owner: String,
pub group: String,
pub mtime: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_path_token: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryChild {
pub name: String,
pub path: String,
pub is_symlink: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_path_token: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RemotePathRef {
display_path: String,
raw_path: Option<Vec<u8>>,
}
impl RemotePathRef {
pub fn new(display_path: &str, raw_path_token: Option<&str>) -> AppResult<Self> {
let raw_path = raw_path_token
.filter(|token| !token.trim().is_empty())
.map(decode_raw_path_token)
.transpose()?;
Ok(Self {
display_path: display_path.to_string(),
raw_path,
})
}
pub fn display_path(&self) -> &str {
&self.display_path
}
pub fn raw_path(&self) -> Option<&[u8]> {
self.raw_path.as_deref()
}
pub fn sibling(&self, file_name: &str) -> Self {
let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes())
.map_or_else(
|| file_name.to_string(),
|bytes| String::from_utf8_lossy(&bytes).into_owned(),
);
let raw_path = self
.raw_path()
.and_then(|path| sibling_path(path, file_name.as_bytes()));
Self {
display_path,
raw_path,
}
}
}
fn sibling_path(path: &[u8], file_name: &[u8]) -> Option<Vec<u8>> {
if file_name.is_empty() {
return None;
}
let parent = path
.iter()
.rposition(|byte| *byte == b'/')
.map(|index| &path[..index])
.unwrap_or_default();
let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1);
if parent.is_empty() {
if path.starts_with(b"/") {
sibling.push(b'/');
}
} else {
sibling.extend_from_slice(parent);
sibling.push(b'/');
}
sibling.extend_from_slice(file_name);
Some(sibling)
}
pub fn raw_path_token(raw_path: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(raw_path)
}
pub fn decode_raw_path_token(token: &str) -> AppResult<Vec<u8>> {
URL_SAFE_NO_PAD
.decode(token)
.map_err(|error| AppError::Config(format!("Invalid remote path token: {error}")))
}
#[derive(Debug, Clone, Serialize)]
pub struct FileProperties {
pub name: String,
pub is_dir: bool,
pub is_symlink: bool,
pub symlink_target: Option<String>,
pub size: u64,
pub permissions: String,
pub owner: String,
pub group: String,
pub uid: String,
pub gid: String,
pub mtime: u64,
pub atime: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RemoteFileAttributeUpdate {
pub mode: Option<String>,
pub owner: Option<String>,
pub group: Option<String>,
#[serde(default)]
pub recursive: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteTextFile {
pub path: String,
pub content: String,
pub size: u64,
pub mtime: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<String>,
pub content_hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TextFileUnsupportedReason {
Binary,
UnsupportedEncoding,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum TextFileOpenResult {
Text { file: RemoteTextFile },
Unsupported { reason: TextFileUnsupportedReason },
}
pub fn classify_text_file(file: RemoteBinaryFile) -> TextFileOpenResult {
if file.content_bytes.contains(&0) {
return TextFileOpenResult::Unsupported {
reason: TextFileUnsupportedReason::Binary,
};
}
match String::from_utf8(file.content_bytes) {
Ok(content) => TextFileOpenResult::Text {
file: RemoteTextFile {
path: file.path,
size: file.size,
mtime: file.mtime,
mtime_nanos: file.mtime_nanos,
content_hash: content_hash(content.as_bytes()),
content,
},
},
Err(_) => TextFileOpenResult::Unsupported {
reason: TextFileUnsupportedReason::UnsupportedEncoding,
},
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteBinaryFile {
pub path: String,
pub content_bytes: Vec<u8>,
pub size: u64,
pub mtime: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteRemoteTextResult {
pub status: String,
pub mtime: Option<u64>,
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
}
impl WriteRemoteTextResult {
pub fn saved(mtime: u64, size: u64, mtime_nanos: Option<String>, content_hash: String) -> Self {
Self {
status: "saved".to_string(),
mtime: Some(mtime),
size: Some(size),
mtime_nanos,
content_hash: Some(content_hash),
}
}
pub fn conflict(mtime: u64, size: u64, mtime_nanos: Option<String>) -> Self {
Self {
status: "conflict".to_string(),
mtime: Some(mtime),
size: Some(size),
mtime_nanos,
content_hash: None,
}
}
}
pub fn content_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub fn ensure_text_bytes(bytes: &[u8], max_bytes: u64) -> AppResult<()> {
if bytes.len() as u64 > max_bytes {
return Err(AppError::Config(format!(
"File is too large to open as text ({} bytes > {} bytes)",
bytes.len(),
max_bytes
)));
}
if bytes.contains(&0) {
return Err(AppError::Config(
"Binary files are not supported by the built-in editor".to_string(),
));
}
Ok(())
}
/// POSIX shell-safe quoting: wraps `input` in single quotes and escapes any
/// embedded single-quote characters. An empty string returns `''`.
pub fn sh_quote(input: &str) -> String {
if input.is_empty() {
return "''".to_string();
}
let escaped = input.replace('\'', "'\\''");
format!("'{}'", escaped)
}
pub fn remote_dir_listing_path(path: &str) -> String {
if path == "/" || path.ends_with('/') {
path.to_string()
} else {
format!("{path}/")
}
}
/// Convert a POSIX permission bitmask to the classic `ls -l` string like `-rwxr-xr-x`.
pub fn permissions_to_string(mode: u32, type_char: char) -> String {
let mut s = String::with_capacity(10);
s.push(type_char);
s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
s.push(match (mode & 0o100 != 0, mode & 0o4000 != 0) {
(true, true) => 's',
(false, true) => 'S',
(true, false) => 'x',
(false, false) => '-',
});
s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
s.push(match (mode & 0o010 != 0, mode & 0o2000 != 0) {
(true, true) => 's',
(false, true) => 'S',
(true, false) => 'x',
(false, false) => '-',
});
s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
s.push(match (mode & 0o001 != 0, mode & 0o1000 != 0) {
(true, true) => 't',
(false, true) => 'T',
(true, false) => 'x',
(false, false) => '-',
});
s
}
pub fn type_char_from_mode(mode: u32) -> char {
match mode & SFTP_FILE_TYPE_MASK {
0o040000 => 'd',
0o120000 => 'l',
_ => '-',
}
}
pub fn describe_permissions(mode: Option<u32>) -> String {
match mode {
Some(mode) => format!(
"{mode:#06o} ({})",
permissions_to_string(mode, type_char_from_mode(mode))
),
None => "none".to_string(),
}
}
pub fn permissions_string_to_octal_mode(permissions: &str) -> Option<String> {
let chars: Vec<char> = permissions.chars().collect();
if chars.len() < 10 {
return None;
}
let mut mode = 0u32;
if chars[1] == 'r' {
mode |= 0o400;
}
if chars[2] == 'w' {
mode |= 0o200;
}
match chars[3] {
'x' => mode |= 0o100,
's' => mode |= 0o4100,
'S' => mode |= 0o4000,
_ => {}
}
if chars[4] == 'r' {
mode |= 0o040;
}
if chars[5] == 'w' {
mode |= 0o020;
}
match chars[6] {
'x' => mode |= 0o010,
's' => mode |= 0o2010,
'S' => mode |= 0o2000,
_ => {}
}
if chars[7] == 'r' {
mode |= 0o004;
}
if chars[8] == 'w' {
mode |= 0o002;
}
match chars[9] {
'x' => mode |= 0o001,
't' => mode |= 0o1001,
'T' => mode |= 0o1000,
_ => {}
}
Some(format!("{mode:04o}"))
}
pub fn scp_finalize_replace_command(
tmp_path: &str,
target_path: &str,
original: Option<&FileProperties>,
) -> String {
let mut commands = Vec::new();
if let Some(props) = original {
if let Some(owner_group) = scp_owner_group_spec(props) {
commands.push(format!(
"chown {} -- {}",
sh_quote(&owner_group),
sh_quote(tmp_path)
));
}
if let Some(mode) = permissions_string_to_octal_mode(&props.permissions) {
commands.push(format!(
"chmod {} -- {}",
sh_quote(&mode),
sh_quote(tmp_path)
));
}
}
commands.push(format!(
"mv -f -- {} {}",
sh_quote(tmp_path),
sh_quote(target_path)
));
commands.join(" && ")
}
fn scp_owner_group_spec(props: &FileProperties) -> Option<String> {
let uid = props.uid.trim();
let gid = props.gid.trim();
if is_numeric_id(uid) && is_numeric_id(gid) {
return Some(format!("{uid}:{gid}"));
}
let owner = props.owner.trim();
let group = props.group.trim();
if !owner.is_empty() && !group.is_empty() {
return Some(format!("{owner}:{group}"));
}
None
}
fn is_numeric_id(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}
pub fn owner_or_id(owner: &Option<String>, uid: Option<u32>) -> String {
owner
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| uid.map(|value| value.to_string()))
.unwrap_or_default()
}
pub fn group_or_id(group: &Option<String>, gid: Option<u32>) -> String {
group
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| gid.map(|value| value.to_string()))
.unwrap_or_default()
}
pub fn parse_octal_mode(mode: &str) -> crate::error::AppResult<u32> {
u32::from_str_radix(mode, 8)
.map_err(|_| crate::error::AppError::Channel(format!("Invalid octal mode: {}", mode)))
}
pub fn sanitize_download_file_name(name: &str) -> String {
sanitize_download_file_name_for_platform(name, cfg!(windows))
}
pub fn append_safe_local_child_path(parent: &str, child_name: &str) -> String {
std::path::Path::new(parent)
.join(sanitize_download_file_name(child_name))
.to_string_lossy()
.to_string()
}
fn sanitize_download_file_name_for_platform(name: &str, windows: bool) -> String {
let base = name;
if base.is_empty() {
return "download".to_string();
}
let mut result = String::new();
let mut chars = base.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
let is_last = chars.peek().is_none();
if should_percent_encode_download_char(ch, is_last, windows) {
percent_encode_char(ch, &mut result);
} else {
result.push_str(&base[index..index + ch.len_utf8()]);
}
}
if result.is_empty() {
result.push_str("download");
}
if windows && is_windows_reserved_device_name(&result) {
let mut chars = result.chars();
if let Some(first) = chars.next() {
let mut escaped = String::new();
percent_encode_char(first, &mut escaped);
escaped.push_str(chars.as_str());
result = escaped;
}
}
result
}
fn should_percent_encode_download_char(ch: char, is_last: bool, windows: bool) -> bool {
ch == '%'
|| ch == '/'
|| ch == '\0'
|| ch.is_control()
|| (windows
&& (matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*')
|| (is_last && matches!(ch, ' ' | '.'))))
}
fn percent_encode_char(ch: char, output: &mut String) {
let mut bytes = [0u8; 4];
for byte in ch.encode_utf8(&mut bytes).as_bytes() {
output.push('%');
output.push_str(&format!("{byte:02X}"));
}
}
fn is_windows_reserved_device_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name);
let upper = stem.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (upper.len() == 4
&& (upper.starts_with("COM") || upper.starts_with("LPT"))
&& upper.as_bytes()[3].is_ascii_digit()
&& upper.as_bytes()[3] != b'0')
}
#[cfg(test)]
mod tests {
use super::{
FileProperties, RemoteBinaryFile, RemotePathRef, TextFileOpenResult,
TextFileUnsupportedReason, classify_text_file, content_hash, decode_raw_path_token,
permissions_string_to_octal_mode, raw_path_token, sanitize_download_file_name_for_platform,
scp_finalize_replace_command,
};
#[test]
fn text_open_classifies_binary_and_invalid_utf8() {
let file = |content_bytes: Vec<u8>| RemoteBinaryFile {
path: "/tmp/file".to_string(),
size: content_bytes.len() as u64,
mtime: 1,
mtime_nanos: None,
content_bytes,
};
assert!(matches!(
classify_text_file(file(b"hello\0world".to_vec())),
TextFileOpenResult::Unsupported {
reason: TextFileUnsupportedReason::Binary
}
));
assert!(matches!(
classify_text_file(file(vec![0xff, 0xfe])),
TextFileOpenResult::Unsupported {
reason: TextFileUnsupportedReason::UnsupportedEncoding
}
));
let opened = classify_text_file(file(b"hello".to_vec()));
assert!(matches!(
opened,
TextFileOpenResult::Text { ref file }
if file.content == "hello" && file.content_hash == content_hash(b"hello")
));
}
#[test]
fn raw_path_token_round_trips_non_utf8_bytes() {
let raw_path = b"/home/user/\xce\xc4\xbc\xfe.txt";
let token = raw_path_token(raw_path);
assert_eq!(decode_raw_path_token(&token).unwrap(), raw_path);
}
#[test]
fn remote_path_ref_prefers_token_bytes() {
let raw_path = b"/remote/\x80name";
let token = raw_path_token(raw_path);
let path_ref = RemotePathRef::new("/remote/display-name", Some(&token)).unwrap();
assert_eq!(path_ref.display_path(), "/remote/display-name");
assert_eq!(path_ref.raw_path().unwrap(), raw_path);
}
#[test]
fn remote_path_sibling_preserves_raw_parent_bytes() {
let raw_path = b"/remote/\x80dir/\x81link";
let token = raw_path_token(raw_path);
let path_ref =
RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap();
let sibling = path_ref.sibling(".nyaterm-link-test");
assert_eq!(
sibling.raw_path().unwrap(),
b"/remote/\x80dir/.nyaterm-link-test"
);
assert_eq!(
sibling.display_path(),
"/remote/display-dir/.nyaterm-link-test"
);
}
#[test]
fn percent_encodes_windows_invalid_characters() {
assert_eq!(
sanitize_download_file_name_for_platform("a<b>:c\"d|e?f*.txt", true),
"a%3Cb%3E%3Ac%22d%7Ce%3Ff%2A.txt"
);
assert_eq!(
sanitize_download_file_name_for_platform("foo\\bar.txt", true),
"foo%5Cbar.txt"
);
}
#[test]
fn encodes_windows_reserved_device_names() {
assert_eq!(
sanitize_download_file_name_for_platform("CON.txt", true),
"%43ON.txt"
);
assert_eq!(
sanitize_download_file_name_for_platform("nul", true),
"%6Eul"
);
assert_eq!(
sanitize_download_file_name_for_platform("LPT1.log", true),
"%4CPT1.log"
);
}
#[test]
fn encodes_windows_trailing_dot_and_space() {
assert_eq!(
sanitize_download_file_name_for_platform("file. ", true),
"file.%20"
);
assert_eq!(
sanitize_download_file_name_for_platform("file.", true),
"file%2E"
);
}
#[test]
fn preserves_readable_safe_characters() {
assert_eq!(
sanitize_download_file_name_for_platform("中文 name-_.txt", true),
"中文 name-_.txt"
);
}
#[test]
fn encodes_percent_to_keep_generated_sequences_unambiguous() {
assert_eq!(
sanitize_download_file_name_for_platform("100%.txt", true),
"100%25.txt"
);
}
#[test]
fn unix_rules_are_minimal() {
assert_eq!(
sanitize_download_file_name_for_platform("a:b?c*.txt", false),
"a:b?c*.txt"
);
assert_eq!(
sanitize_download_file_name_for_platform("100%.txt", false),
"100%25.txt"
);
}
#[test]
fn parses_ls_permissions_to_octal_mode() {
assert_eq!(
permissions_string_to_octal_mode("-rw-r--r--").as_deref(),
Some("0644")
);
assert_eq!(
permissions_string_to_octal_mode("-rwxr-xr-x").as_deref(),
Some("0755")
);
assert_eq!(
permissions_string_to_octal_mode("-rwsr-sr-t").as_deref(),
Some("7755")
);
assert_eq!(
permissions_string_to_octal_mode("-rwSr-Sr-T").as_deref(),
Some("7644")
);
assert_eq!(permissions_string_to_octal_mode("bad"), None);
}
#[test]
fn scp_finalize_replace_prefers_numeric_uid_gid() {
let props = file_properties("-rwsr-sr-t", "kang", "kang", "1000", "1001");
let command = scp_finalize_replace_command("/tmp/new", "/etc/app.conf", Some(&props));
assert!(command.contains("chown '1000:1001' -- '/tmp/new'"));
assert!(command.contains("chmod '7755' -- '/tmp/new'"));
assert!(command.ends_with("mv -f -- '/tmp/new' '/etc/app.conf'"));
}
#[test]
fn scp_finalize_replace_falls_back_to_owner_group_names() {
let props = file_properties("-rw-r--r--", "kang", "staff", "kang", "staff");
let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props));
assert!(command.contains("chown 'kang:staff' -- '/tmp/new'"));
assert!(command.contains("chmod '0644' -- '/tmp/new'"));
}
#[test]
fn scp_finalize_replace_orders_chown_before_chmod_before_mv() {
let props = file_properties("-rwSr-Sr-T", "kang", "staff", "1000", "1001");
let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props));
let chown = command.find("chown").unwrap();
let chmod = command.find("chmod").unwrap();
let mv = command.find("mv -f").unwrap();
assert!(chown < chmod);
assert!(chmod < mv);
}
#[test]
fn scp_finalize_replace_skips_unparseable_mode() {
let props = file_properties("bad", "kang", "staff", "1000", "1001");
let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props));
assert!(command.contains("chown '1000:1001' -- '/tmp/new'"));
assert!(!command.contains("chmod"));
assert!(command.ends_with("mv -f -- '/tmp/new' '/home/kang/file'"));
}
fn file_properties(
permissions: &str,
owner: &str,
group: &str,
uid: &str,
gid: &str,
) -> FileProperties {
FileProperties {
name: "file".to_string(),
is_dir: false,
is_symlink: false,
symlink_target: None,
size: 0,
permissions: permissions.to_string(),
owner: owner.to_string(),
group: group.to_string(),
uid: uid.to_string(),
gid: gid.to_string(),
mtime: 0,
atime: 0,
}
}
}
@@ -0,0 +1,6 @@
//! Shared protocol operations; runtime ownership belongs to the transport adapter.
pub mod algorithms;
pub mod protocol;
pub mod terminal;
pub mod files;
@@ -0,0 +1,122 @@
use crate::error::{AppError, AppResult};
use russh::{ChannelMsg, client};
use std::{sync::Arc, time::Duration};
use tokio::time::timeout;
pub async fn connect<H: client::Handler + Send + 'static>(
config: Arc<client::Config>,
host: &str,
port: u16,
handler: H,
) -> Result<client::Handle<H>, H::Error> {
client::connect(config, (host, port), handler).await
}
pub async fn password<H: client::Handler>(
handle: &mut client::Handle<H>,
username: &str,
secret: &str,
) -> Result<client::AuthResult, russh::Error> {
handle.authenticate_password(username, secret).await
}
/// Preserve the existing Desktop request/reply timeout and rejection semantics.
pub async fn wait_channel_request_reply(
channel: &mut russh::Channel<client::Msg>,
request_name: &str,
) -> AppResult<()> {
timeout(Duration::from_secs(10), async {
loop {
match channel.wait().await {
Some(ChannelMsg::Success) => return Ok(()),
Some(ChannelMsg::Failure) => {
return Err(AppError::Channel(format!(
"{request_name} request rejected by server"
)));
}
Some(ChannelMsg::Close | ChannelMsg::Eof) | None => {
return Err(AppError::Channel(format!(
"SSH channel closed before {request_name} request completed"
)));
}
Some(_) => {}
}
}
})
.await
.map_err(|_| AppError::Channel(format!("{request_name} request timed out")))?
}
pub async fn initialize_sftp<S>(
stream: S,
config: russh_sftp::client::Config,
) -> Result<russh_sftp::client::SftpSession, russh_sftp::client::error::Error>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
russh_sftp::client::SftpSession::new_with_config(stream, config).await
}
pub async fn open_shell<H: client::Handler>(
handle: &mut client::Handle<H>,
cols: u32,
rows: u32,
) -> AppResult<russh::Channel<client::Msg>> {
open_shell_with_terminal(handle, cols, rows, "xterm-256color").await
}
pub async fn open_shell_with_terminal<H: client::Handler>(
handle: &mut client::Handle<H>,
cols: u32,
rows: u32,
terminal: &str,
) -> AppResult<russh::Channel<client::Msg>> {
let mut channel = handle.channel_open_session().await?;
let result = async {
channel
.request_pty(true, terminal, cols, rows, 0, 0, &[])
.await?;
wait_channel_request_reply(&mut channel, "PTY").await?;
channel.request_shell(true).await?;
wait_channel_request_reply(&mut channel, "Shell").await
}
.await;
if let Err(error) = result {
let _ = channel.close().await;
return Err(error);
}
Ok(channel)
}
pub async fn open_sftp<H: client::Handler>(
handle: &mut client::Handle<H>,
) -> AppResult<russh_sftp::client::SftpSession> {
open_sftp_with_config(handle, Default::default()).await
}
pub async fn open_sftp_with_config<H: client::Handler>(
handle: &mut client::Handle<H>,
config: russh_sftp::client::Config,
) -> AppResult<russh_sftp::client::SftpSession> {
let mut channel = handle.channel_open_session().await?;
channel.request_subsystem(true, "sftp").await?;
wait_channel_request_reply(&mut channel, "SFTP").await?;
Ok(initialize_sftp(channel.into_stream(), config).await?)
}
pub async fn keyboard_start<H: client::Handler>(
handle: &mut client::Handle<H>,
username: &str,
) -> Result<client::KeyboardInteractiveAuthResponse, russh::Error> {
handle
.authenticate_keyboard_interactive_start(username, None)
.await
}
pub async fn keyboard_respond<H: client::Handler>(
handle: &mut client::Handle<H>,
responses: Vec<String>,
) -> Result<client::KeyboardInteractiveAuthResponse, russh::Error> {
handle
.authenticate_keyboard_interactive_respond(responses)
.await
}
@@ -0,0 +1,72 @@
use russh::{Channel, ChannelMsg, client};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub enum Command {
Input(Vec<u8>),
Resize(u32, u32),
Close,
}
pub enum Output {
Data(Vec<u8>),
Closed,
Error,
}
pub const CHUNK_BYTES: usize = 64 * 1024;
pub const OUTPUT_CAPACITY: usize = 32;
pub async fn write(channel: &Channel<client::Msg>, bytes: &[u8]) -> Result<(), russh::Error> {
channel.data(bytes).await
}
pub async fn resize(
channel: &Channel<client::Msg>,
cols: u32,
rows: u32,
) -> Result<(), russh::Error> {
channel.window_change(cols, rows, 0, 0).await
}
/// Bounded output (at most 2 MiB); saturation backpressures the SSH channel.
/// Cancellation interrupts output waits and channel writes, including detached peers.
pub async fn run(
mut channel: Channel<client::Msg>,
mut input: mpsc::Receiver<Command>,
output: mpsc::Sender<Output>,
cancel: CancellationToken,
) {
let transfer = async {
loop {
tokio::select! {
command = input.recv() => match command {
Some(Command::Input(data)) => write(&channel, &data).await?,
Some(Command::Resize(cols, rows)) => resize(&channel, cols, rows).await?,
Some(Command::Close) | None => break,
},
message = channel.wait() => match message {
Some(ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. }) => {
for chunk in data.chunks(CHUNK_BYTES) {
if output.send(Output::Data(chunk.to_vec())).await.is_err() { return Ok::<(), russh::Error>(()); }
}
}
Some(ChannelMsg::Close | ChannelMsg::Eof) | None => break,
_ => {},
}
}
}
Ok::<(), russh::Error>(())
};
let failed =
tokio::select! { _ = cancel.cancelled() => false, result = transfer => result.is_err() };
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), channel.close()).await;
// Preserve ordering after buffered data. Sender drop also signals EOF if a
// detached consumer cannot make room before the bounded shutdown deadline.
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
output.send(if failed {
Output::Error
} else {
Output::Closed
}),
)
.await;
}
@@ -14,7 +14,7 @@ use super::util::*;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct KnownHostEntry {
pub struct KnownHostEntry {
pub id: String,
pub marker: Option<String>,
pub host_identifier: String,
@@ -0,0 +1,441 @@
//! redb-backed repository for `NyaTerm`'s user data.
#![allow(dead_code)]
mod credentials;
mod history;
mod known_hosts;
mod master_key;
mod migration;
mod notes;
mod rdp_known_hosts;
mod sessions;
mod settings_impl;
mod tables;
mod util;
mod vnc_known_hosts;
#[cfg(test)]
mod tests;
pub use known_hosts::KnownHostEntry;
pub use rdp_known_hosts::RdpCertificateMetadata;
#[allow(unused_imports)]
pub use tables::{
COMMAND_HISTORY_TABLE, CONNECTIONS_TABLE, CREDENTIALS_TABLE, GROUPS_TABLE,
IDX_CONNECTIONS_BY_GROUP_TABLE, IDX_CONNECTIONS_BY_LAST_USED_TABLE,
IDX_CONNECTIONS_BY_PROTOCOL_TABLE, KNOWN_HOSTS_TABLE, META_TABLE, NOTE_FOLDERS_TABLE,
NOTES_TABLE, OTP_ACCOUNTS_TABLE, PROXIES_TABLE, RDP_KNOWN_HOSTS_TABLE, SETTINGS_TABLE,
TUNNELS_TABLE, VNC_KNOWN_HOSTS_TABLE,
};
use crate::error::{AppError, AppResult};
use redb::Database;
use serde::{Serialize, de::DeserializeOwned};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use migration::backup_database_before_migration_if_needed;
use tables::DATABASE_FILE;
use util::storage_error;
static STORAGE: OnceLock<Arc<Storage>> = OnceLock::new();
#[derive(Debug)]
pub struct Storage {
pub(super) db: Database,
pub(super) db_path: PathBuf,
pub(super) pending_migration_backup: Option<BackupInfo>,
}
#[derive(Debug, Clone)]
pub(super) struct BackupInfo {
pub(super) path: PathBuf,
pub(super) created_at_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsDocKey {
AppSettings,
QuickCommands,
CloudSyncSettings,
CloudSyncState,
AiHistory,
AiAudit,
SftpFileBackendCache,
WindowState,
ProxyGroups,
TunnelGroups,
}
impl SettingsDocKey {
fn storage_key(self) -> &'static str {
match self {
Self::AppSettings => tables::SETTINGS_DEFAULT,
Self::QuickCommands => "settings/doc/quick-command",
Self::CloudSyncSettings => "settings/doc/cloud-sync",
Self::CloudSyncState => "settings/doc/cloud-sync-state",
Self::AiHistory => "settings/doc/ai-history",
Self::AiAudit => "settings/doc/ai-audit",
Self::SftpFileBackendCache => "settings/doc/file-backend-cache",
Self::WindowState => "settings/doc/window-state",
Self::ProxyGroups => "settings/doc/proxy-groups",
Self::TunnelGroups => "settings/doc/tunnel-groups",
}
}
fn legacy_key(self) -> Option<&'static str> {
match self {
Self::AppSettings => Some(tables::LEGACY_JSON_SETTINGS),
Self::QuickCommands => Some(tables::LEGACY_JSON_QUICK_COMMAND),
Self::CloudSyncSettings => Some(tables::LEGACY_JSON_CLOUD_SYNC),
Self::CloudSyncState => Some(tables::LEGACY_JSON_CLOUD_SYNC_STATE),
Self::AiHistory => Some(tables::LEGACY_JSON_AI_HISTORY),
Self::AiAudit => Some(tables::LEGACY_JSON_AI_AUDIT),
Self::SftpFileBackendCache => Some("file-backend-cache"),
Self::WindowState | Self::ProxyGroups | Self::TunnelGroups => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KnownHostCheck {
Match,
HostSeen,
UnknownHost,
}
pub fn init(config_dir: &Path) -> AppResult<()> {
fs::create_dir_all(config_dir)?;
let storage = Arc::new(Storage::open(config_dir)?);
if STORAGE.set(storage).is_err() {
tracing::debug!("redb storage was already initialized");
}
Ok(())
}
#[cfg(test)]
fn database_path(config_dir: &Path) -> PathBuf {
config_dir.join(DATABASE_FILE)
}
pub fn load_settings_doc<T: DeserializeOwned + Default>(key: SettingsDocKey) -> AppResult<T> {
Ok(storage()?.get_settings_doc(key)?.unwrap_or_default())
}
pub fn save_settings_doc<T: Serialize>(key: SettingsDocKey, value: &T) -> AppResult<()> {
storage()?.save_settings_doc(key, value)
}
pub fn update_settings_doc<T, R, F>(key: SettingsDocKey, updater: F) -> AppResult<R>
where
T: DeserializeOwned + Default + Serialize,
F: FnOnce(&mut T) -> AppResult<R>,
{
storage()?.update_settings_doc(key, updater)
}
pub fn load_sessions() -> AppResult<crate::config::SessionsConfig> {
storage()?.load_sessions()
}
pub fn replace_sessions(config: &crate::config::SessionsConfig) -> AppResult<()> {
storage()?.replace_sessions(config)
}
pub fn get_connection(connection_id: &str) -> AppResult<Option<crate::config::SavedConnection>> {
storage()?.get_connection_with_secret(connection_id)
}
pub fn mark_connection_used(connection_id: &str) -> AppResult<()> {
storage()?.mark_connection_used(connection_id)
}
pub fn list_passwords() -> AppResult<Vec<crate::config::SavedPassword>> {
storage()?.list_passwords()
}
pub fn replace_passwords(config: &crate::config::PasswordsConfig) -> AppResult<()> {
storage()?.replace_passwords(config)
}
pub fn list_ssh_keys() -> AppResult<Vec<crate::config::SshKey>> {
storage()?.list_ssh_keys()
}
pub fn replace_ssh_keys(config: &crate::config::KeysConfig) -> AppResult<()> {
storage()?.replace_ssh_keys(config)
}
pub fn list_credentials() -> AppResult<Vec<crate::config::SavedCredential>> {
storage()?.list_credentials()
}
pub fn replace_credentials(config: &crate::config::CredentialsConfig) -> AppResult<()> {
storage()?.replace_credentials(config)
}
pub fn list_otp_accounts() -> AppResult<Vec<crate::config::OtpEntry>> {
storage()?.list_otp_accounts()
}
pub fn replace_otp_accounts(config: &crate::config::OtpConfig) -> AppResult<()> {
storage()?.replace_otp_accounts(config)
}
pub fn list_proxies() -> AppResult<Vec<crate::config::ProxyConfig>> {
storage()?.list_proxies()
}
pub fn replace_proxies(proxies: &[crate::config::ProxyConfig]) -> AppResult<()> {
storage()?.replace_proxies(proxies)
}
pub fn list_tunnels() -> AppResult<Vec<crate::config::TunnelConfig>> {
storage()?.list_tunnels()
}
pub fn replace_tunnels(tunnels: &[crate::config::TunnelConfig]) -> AppResult<()> {
storage()?.replace_tunnels(tunnels)
}
pub fn list_command_history_entries(
limit: usize,
) -> AppResult<Vec<crate::core::history::HistoryEntry>> {
storage()?.list_recent_command_history(limit)
}
pub fn replace_command_history_entries(
entries: &[crate::core::history::HistoryEntry],
) -> AppResult<()> {
storage()?.replace_command_history(entries)
}
pub fn list_note_folders() -> AppResult<Vec<crate::config::NoteFolder>> {
storage()?.list_note_folders()
}
pub fn list_notes() -> AppResult<Vec<crate::config::NoteDocument>> {
storage()?.list_notes()
}
pub fn list_note_summaries() -> AppResult<Vec<crate::config::NoteSummary>> {
storage()?.list_note_summaries()
}
pub fn get_note(note_id: &str) -> AppResult<Option<crate::config::NoteDocument>> {
storage()?.get_note(note_id)
}
pub fn create_note_folder(
parent_id: Option<String>,
name: Option<String>,
) -> AppResult<crate::config::NoteFolder> {
storage()?.create_note_folder(parent_id, name)
}
pub fn create_note(
parent_id: Option<String>,
title: Option<String>,
markdown: Option<String>,
) -> AppResult<crate::config::NoteDocument> {
storage()?.create_note(parent_id, title, markdown)
}
pub fn update_note(
note_id: &str,
title: String,
markdown: String,
expected_revision: u64,
force: bool,
) -> AppResult<crate::config::NoteUpdateResult> {
storage()?.update_note(note_id, title, markdown, expected_revision, force)
}
pub fn rename_note_node(
node_kind: &str,
node_id: &str,
name: String,
) -> AppResult<crate::config::NoteNodeChange> {
storage()?.rename_note_node(node_kind, node_id, name)
}
pub fn move_note_node(
node_kind: &str,
node_id: &str,
parent_id: Option<String>,
sort_order: i64,
) -> AppResult<crate::config::NoteNodeChange> {
storage()?.move_note_node(node_kind, node_id, parent_id, sort_order)
}
pub fn delete_note_node(
node_kind: &str,
node_id: &str,
) -> AppResult<crate::config::DeleteNoteNodeResult> {
storage()?.delete_note_node(node_kind, node_id)
}
pub fn load_notes_snapshot() -> AppResult<crate::config::NotesSnapshot> {
storage()?.load_notes_snapshot()
}
pub fn replace_notes_snapshot(snapshot: &crate::config::NotesSnapshot) -> AppResult<()> {
storage()?.replace_notes_snapshot(snapshot)
}
pub fn check_known_host(
host_identifier: &str,
key_type: &str,
key_base64: &str,
) -> AppResult<KnownHostCheck> {
storage()?.check_known_host(host_identifier, key_type, key_base64)
}
pub fn upsert_known_host(line: &str) -> AppResult<()> {
storage()?.upsert_known_host(line)
}
pub fn replace_known_host_for_host(host_identifier: &str, line: &str) -> AppResult<()> {
storage()?.replace_known_host_for_host(host_identifier, line)
}
pub fn render_known_hosts_export() -> AppResult<String> {
storage()?.render_known_hosts_export()
}
pub fn replace_known_hosts_export(content: &str) -> AppResult<()> {
storage()?.replace_known_hosts_export(content)
}
pub fn list_known_hosts() -> AppResult<Vec<KnownHostEntry>> {
storage()?.list_known_hosts()
}
pub fn delete_known_host(id: &str) -> AppResult<()> {
storage()?.delete_known_host(id)
}
pub fn clear_known_hosts() -> AppResult<()> {
storage()?.clear_known_hosts()
}
pub fn check_rdp_known_host(
host: &str,
port: u16,
sha256_fingerprint: &str,
) -> AppResult<KnownHostCheck> {
storage()?.check_rdp_known_host(host, port, sha256_fingerprint)
}
pub fn upsert_rdp_known_host(
host: &str,
port: u16,
sha256_fingerprint: &str,
certificate: RdpCertificateMetadata,
) -> AppResult<()> {
storage()?.upsert_rdp_known_host(host, port, sha256_fingerprint, certificate)
}
pub fn check_vnc_known_host(
host: &str,
port: u16,
sha256_fingerprint: &str,
) -> AppResult<KnownHostCheck> {
storage()?.check_vnc_known_host(host, port, sha256_fingerprint)
}
pub fn upsert_vnc_known_host(host: &str, port: u16, sha256_fingerprint: &str) -> AppResult<()> {
storage()?.upsert_vnc_known_host(host, port, sha256_fingerprint)
}
pub fn load_master_key_token() -> AppResult<Option<String>> {
storage()?.load_master_key_token()
}
pub fn save_master_key_token(token: &str) -> AppResult<()> {
storage()?.save_master_key_token(token)
}
fn storage() -> AppResult<Arc<Storage>> {
if let Some(storage) = STORAGE.get() {
return Ok(storage.clone());
}
let config_dir = default_config_dir()?;
init(&config_dir)?;
STORAGE
.get()
.cloned()
.ok_or_else(|| AppError::Storage("redb storage did not initialize".to_string()))
}
fn default_config_dir() -> AppResult<PathBuf> {
#[cfg(test)]
{
return Ok(
std::env::temp_dir().join(format!("nyaterm-test-storage-{}", std::process::id()))
);
}
#[cfg(not(test))]
{
let home = dirs::home_dir()
.ok_or_else(|| AppError::Config("cannot determine home directory".to_string()))?;
Ok(home.join(".nyaterm"))
}
}
impl Storage {
pub fn open(config_dir: &Path) -> AppResult<Self> {
fs::create_dir_all(config_dir)?;
Self::open_path(&config_dir.join(DATABASE_FILE))
}
fn open_path(db_path: &Path) -> AppResult<Self> {
let pending_migration_backup = if db_path.exists() {
backup_database_before_migration_if_needed(db_path)?
} else {
None
};
let db = if db_path.exists() {
Database::open(db_path).map_err(storage_error)?
} else {
Database::create(db_path).map_err(storage_error)?
};
let storage = Self {
db,
db_path: db_path.to_path_buf(),
pending_migration_backup,
};
storage.migrate_if_needed()?;
storage.record_successful_v3_startup_and_cleanup_backups()?;
Ok(storage)
}
pub fn get_schema_version(&self) -> AppResult<u32> {
Ok(self.get_schema_version_optional()?.unwrap_or(1))
}
pub fn set_schema_version(&self, version: u32) -> AppResult<()> {
let txn = self.db.begin_write().map_err(storage_error)?;
util::write_meta_u32(&txn, tables::META_SCHEMA_VERSION, version)?;
txn.commit().map_err(storage_error)?;
Ok(())
}
pub fn migrate_if_needed(&self) -> AppResult<()> {
match self.get_schema_version_optional()? {
Some(version) if version >= tables::SCHEMA_VERSION => {
self.ensure_current_v3_tables()?;
return Ok(());
}
Some(version) => {
tracing::info!(schema_version = version, "Migrating redb storage schema");
}
None => {}
}
if self.has_legacy_data()? || self.get_schema_version_optional()?.is_some() {
self.migrate_to_v3()?;
} else {
self.initialize_v3_schema()?;
}
Ok(())
}
}
@@ -0,0 +1,388 @@
//! AES-256-GCM encryption for SSH credentials stored in redb JSON documents.
//!
//! ## Key hierarchy
//!
//! ```text
//! wrapping_key = SHA-256("nyaterm-key-wrap-v1:" || master_password_or_home_path)
//! master.key = redb text doc containing base64( wrap_nonce[12] || AES-256-GCM(wrapping_key, master_key[32]) )
//! sessions doc = { "password": base64( nonce[12] || AES-256-GCM(master_key, plaintext) ), … }
//! ```
//!
//! When a master password is configured the wrapping key is derived from it;
//! otherwise installed mode uses the user's home directory path as the key
//! material, while portable mode uses `data/config/portable.key`.
//!
//! The master password itself is stored in the settings document encrypted with the
//! home-path-derived key (via [`encrypt_settings_secret`]) to avoid a circular
//! dependency during bootstrap.
use crate::error::{AppError, AppResult};
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::RwLock;
const WRAPPING_KEY_PREFIX: &[u8] = b"nyaterm-key-wrap-v1:";
const LEGACY_WRAPPING_KEY_PREFIX: &[u8] = b"dragonfly-key-wrap-v1:";
static MASTER_PASSWORD: RwLock<Option<String>> = RwLock::new(None);
static PORTABLE_KEY_PATH: RwLock<Option<PathBuf>> = RwLock::new(None);
static SERVER_KEY_MATERIAL: RwLock<Option<zeroize::Zeroizing<Vec<u8>>>> = RwLock::new(None);
/// Web bootstrap supplies an external random key before accessing storage.
/// It is never persisted, and avoids Desktop's home-path / portable-key fallback.
pub fn set_server_key_material(key: [u8; 32]) {
*SERVER_KEY_MATERIAL.write().unwrap() = Some(zeroize::Zeroizing::new(key.to_vec()));
}
/// Set the master password used for wrapping key derivation.
/// Pass `None` to revert to home-path-based derivation.
pub fn set_master_password(password: Option<String>) {
let mut pw = MASTER_PASSWORD.write().unwrap();
*pw = password.filter(|s| !s.is_empty());
}
/// Set the portable-mode key path used when no master password is configured.
pub fn set_portable_key_path(path: Option<PathBuf>) {
let mut portable_key_path = PORTABLE_KEY_PATH.write().unwrap();
*portable_key_path = path;
}
/// Returns the currently configured master password, if any.
pub fn get_master_password() -> Option<String> {
MASTER_PASSWORD.read().unwrap().clone()
}
/// Derives a wrapping key from the given material.
///
/// When `password` is `Some`, uses the password as key material.
/// Otherwise falls back to the runtime key material.
fn derive_wrapping_key_with_prefix(
prefix: &[u8],
password: Option<&str>,
) -> AppResult<Key<Aes256Gcm>> {
let mut h = Sha256::new();
h.update(prefix);
match password {
Some(pw) => h.update(pw.as_bytes()),
None => h.update(fallback_key_material()?.as_slice()),
}
let digest = h.finalize();
Ok(*Key::<Aes256Gcm>::from_slice(&digest))
}
fn fallback_key_material() -> AppResult<Vec<u8>> {
if let Some(material) = SERVER_KEY_MATERIAL.read().unwrap().as_ref() {
return Ok(material.to_vec());
}
if let Some(path) = PORTABLE_KEY_PATH.read().unwrap().clone() {
return read_or_create_portable_key_material(path);
}
let home = dirs::home_dir()
.ok_or_else(|| AppError::Crypto("cannot determine home directory".into()))?;
Ok(home.to_string_lossy().as_bytes().to_vec())
}
fn read_or_create_portable_key_material(path: PathBuf) -> AppResult<Vec<u8>> {
if path.exists() {
let material = std::fs::read_to_string(&path)?;
let material = material.trim();
if material.is_empty() {
return Err(AppError::Crypto(format!(
"portable key file is empty: {}",
path.display()
)));
}
return Ok(material.as_bytes().to_vec());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
use aes_gcm::aead::rand_core::RngCore as _;
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let material = B64.encode(bytes);
std::fs::write(&path, format!("{material}\n"))?;
Ok(material.into_bytes())
}
fn derive_wrapping_key(password: Option<&str>) -> AppResult<Key<Aes256Gcm>> {
derive_wrapping_key_with_prefix(WRAPPING_KEY_PREFIX, password)
}
fn derive_legacy_wrapping_key(password: Option<&str>) -> AppResult<Key<Aes256Gcm>> {
derive_wrapping_key_with_prefix(LEGACY_WRAPPING_KEY_PREFIX, password)
}
/// Derives the wrapping key from the current master password (or home path).
fn get_wrapping_key() -> AppResult<Key<Aes256Gcm>> {
let pw = get_master_password();
derive_wrapping_key(pw.as_deref())
}
/// Wraps `master_key` with `wrapping_key` and writes it to redb `master.key`.
fn write_wrapped_master_key(
master_key: &Key<Aes256Gcm>,
wrapping_key: &Key<Aes256Gcm>,
) -> AppResult<()> {
let cipher = Aes256Gcm::new(wrapping_key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let wrapped = cipher
.encrypt(&nonce, master_key.as_slice())
.map_err(|e| AppError::Crypto(format!("wrap master.key: {e}")))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&wrapped);
crate::storage::save_master_key_token(&B64.encode(&combined))?;
Ok(())
}
/// Unwraps the master key from the raw bytes of `master.key`.
fn unwrap_master_key_bytes(raw: &[u8], wrapping_key: &Key<Aes256Gcm>) -> AppResult<Key<Aes256Gcm>> {
if raw.len() < 13 {
return Err(AppError::Crypto("master.key file is malformed".into()));
}
let cipher = Aes256Gcm::new(wrapping_key);
let (nonce_bytes, ciphertext) = raw.split_at(12);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let master_key_bytes = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| AppError::Crypto(format!("unwrap master.key: {e}")))?;
if master_key_bytes.len() != 32 {
return Err(AppError::Crypto("master key length mismatch".into()));
}
Ok(*Key::<Aes256Gcm>::from_slice(&master_key_bytes))
}
fn unwrap_master_key_with_compatible_wrapping(
raw: &[u8],
password: Option<&str>,
) -> AppResult<(Key<Aes256Gcm>, bool)> {
let wrapping_key = derive_wrapping_key(password)?;
match unwrap_master_key_bytes(raw, &wrapping_key) {
Ok(master_key) => Ok((master_key, false)),
Err(new_error) => {
let legacy_wrapping_key = derive_legacy_wrapping_key(password)?;
match unwrap_master_key_bytes(raw, &legacy_wrapping_key) {
Ok(master_key) => Ok((master_key, true)),
Err(legacy_error) => Err(AppError::Crypto(format!(
"unwrap master.key: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})"
))),
}
}
}
}
/// Loads the master key from redb `master.key`, creating it on first use.
fn get_master_key() -> AppResult<Key<Aes256Gcm>> {
if let Some(encoded) = crate::storage::load_master_key_token()? {
let raw = B64
.decode(encoded.trim())
.map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?;
let password = get_master_password();
let (master_key, used_legacy_prefix) =
unwrap_master_key_with_compatible_wrapping(&raw, password.as_deref())?;
if used_legacy_prefix {
let wrapping_key = derive_wrapping_key(password.as_deref())?;
write_wrapped_master_key(&master_key, &wrapping_key)?;
}
Ok(master_key)
} else {
let master_key = Aes256Gcm::generate_key(OsRng);
let wrapping_key = get_wrapping_key()?;
write_wrapped_master_key(&master_key, &wrapping_key)?;
Ok(master_key)
}
}
pub fn verify_master_key_token() -> AppResult<()> {
let _ = get_master_key()?;
Ok(())
}
/// Re-wraps the existing master key when the master password changes.
///
/// `old_password` is the previous master password (`None` = home-path-based).
/// `new_password` is the new master password (`None` = revert to home-path-based).
pub fn rewrap_master_key(old_password: Option<&str>, new_password: Option<&str>) -> AppResult<()> {
let master_key = if let Some(encoded) = crate::storage::load_master_key_token()? {
let raw = B64
.decode(encoded.trim())
.map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?;
unwrap_master_key_with_compatible_wrapping(&raw, old_password)?.0
} else {
Aes256Gcm::generate_key(OsRng)
};
let new_wrapping = derive_wrapping_key(new_password)?;
write_wrapped_master_key(&master_key, &new_wrapping)?;
Ok(())
}
// ---------------------------------------------------------------------------
// Settings-secret helpers (for the master password field in the settings document)
// ---------------------------------------------------------------------------
/// Encrypts a value using the home-path-derived key directly (not via master.key).
///
/// Used exclusively for storing the master password in settings.json to avoid
/// a circular dependency: we need the master password to unwrap master.key,
/// but master.key is needed to call [`encrypt`].
pub fn encrypt_settings_secret(plaintext: &str) -> AppResult<String> {
let key = derive_wrapping_key(None)?;
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(B64.encode(&combined))
}
/// Decrypts a value produced by [`encrypt_settings_secret`].
pub fn decrypt_settings_secret(token: &str) -> AppResult<String> {
let raw = B64
.decode(token)
.map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?;
if raw.len() < 13 {
return Err(AppError::Crypto("ciphertext too short".into()));
}
let key = derive_wrapping_key(None)?;
match decrypt_settings_secret_bytes(&raw, &key) {
Ok(value) => Ok(value),
Err(new_error) => {
let legacy_key = derive_legacy_wrapping_key(None)?;
decrypt_settings_secret_bytes(&raw, &legacy_key).map_err(|legacy_error| {
AppError::Crypto(format!(
"settings secret decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})"
))
})
}
}
}
fn decrypt_settings_secret_bytes(raw: &[u8], key: &Key<Aes256Gcm>) -> AppResult<String> {
let cipher = Aes256Gcm::new(key);
let (nonce_bytes, ciphertext) = raw.split_at(12);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?;
String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}")))
}
// ---------------------------------------------------------------------------
// Public encrypt / decrypt (use the master key from master.key)
// ---------------------------------------------------------------------------
/// Encrypts `plaintext` with AES-256-GCM.
///
/// Returns `base64( nonce[12] || ciphertext+tag )`.
pub fn encrypt(plaintext: &str) -> AppResult<String> {
let key = get_master_key()?;
let cipher = Aes256Gcm::new(&key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?;
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(B64.encode(&combined))
}
/// Decrypts a `base64( nonce || ciphertext )` token produced by [`encrypt`].
pub fn decrypt(token: &str) -> AppResult<String> {
let key = get_master_key()?;
let cipher = Aes256Gcm::new(&key);
let raw = B64
.decode(token)
.map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?;
if raw.len() < 13 {
return Err(AppError::Crypto("ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = raw.split_at(12);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?;
String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}")))
}
/// Decrypts an optional token, returning `None` when the input is `None` or empty.
pub fn decrypt_optional(token: &Option<String>) -> AppResult<Option<String>> {
match token {
Some(t) if !t.is_empty() => Ok(Some(decrypt(t)?)),
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wrap_key_for_test(master_key: &Key<Aes256Gcm>, wrapping_key: &Key<Aes256Gcm>) -> Vec<u8> {
let cipher = Aes256Gcm::new(wrapping_key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let wrapped = cipher
.encrypt(&nonce, master_key.as_slice())
.expect("wrap test key");
let mut combined = nonce.to_vec();
combined.extend_from_slice(&wrapped);
combined
}
fn encrypt_settings_secret_with_key_for_test(plaintext: &str, key: &Key<Aes256Gcm>) -> String {
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.expect("encrypt settings secret");
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
B64.encode(combined)
}
#[test]
fn legacy_dragonfly_master_key_prefix_can_be_unwrapped() {
let master_key = Aes256Gcm::generate_key(OsRng);
let legacy_wrapping_key =
derive_legacy_wrapping_key(Some("secret")).expect("legacy wrapping key");
let raw = wrap_key_for_test(&master_key, &legacy_wrapping_key);
let (unwrapped, used_legacy_prefix) =
unwrap_master_key_with_compatible_wrapping(&raw, Some("secret"))
.expect("unwrap with compatibility");
assert!(used_legacy_prefix);
assert_eq!(unwrapped.as_slice(), master_key.as_slice());
}
#[test]
fn legacy_dragonfly_settings_secret_prefix_can_be_decrypted() {
let legacy_key = derive_legacy_wrapping_key(None).expect("legacy settings key");
let token = encrypt_settings_secret_with_key_for_test("stored-password", &legacy_key);
assert_eq!(
decrypt_settings_secret(&token).expect("decrypt legacy settings secret"),
"stored-password"
);
}
}
@@ -0,0 +1,268 @@
use font_kit::font::Font;
use font_kit::source::SystemSource;
use serde::Serialize;
pub const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrainsMono Nerd Font Mono\", \"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace";
const LEGACY_DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace";
#[cfg(target_os = "macos")]
pub const DEFAULT_UI_FONT_FAMILY: &str = "system-ui, -apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Helvetica Neue\", Arial, sans-serif";
#[cfg(target_os = "windows")]
pub const DEFAULT_UI_FONT_FAMILY: &str =
"system-ui, \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", Arial, sans-serif";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub const DEFAULT_UI_FONT_FAMILY: &str =
"system-ui, \"Noto Sans SC\", \"Noto Sans CJK SC\", \"Helvetica Neue\", Arial, sans-serif";
const BUILT_IN_MONOSPACE_FONTS: &[&str] = &["JetBrainsMono Nerd Font Mono", "JetBrains Mono"];
const GENERIC_MONOSPACE_FAMILY: &str = "monospace";
#[derive(Debug, Clone, Serialize)]
pub struct FontInfo {
pub family: String,
pub monospace: bool,
}
pub fn list_system_font_infos() -> Vec<FontInfo> {
let source = SystemSource::new();
let Ok(mut families) = source.all_families() else {
return Vec::new();
};
families.sort();
families.dedup();
families
.into_iter()
.map(|family| FontInfo {
monospace: is_system_font_family_monospace_with_source(&source, &family),
family,
})
.collect()
}
pub fn list_system_font_families() -> Vec<String> {
let Ok(mut families) = SystemSource::new().all_families() else {
return Vec::new();
};
families.sort();
families.dedup();
families
}
pub fn normalize_terminal_font_family(font_family: &str) -> String {
if is_default_terminal_font_family(font_family) {
return DEFAULT_TERMINAL_FONT_FAMILY.to_string();
}
if is_legacy_default_terminal_font_family(font_family) {
return DEFAULT_TERMINAL_FONT_FAMILY.to_string();
}
let source = SystemSource::new();
let mut kept = Vec::new();
for raw_family in split_font_family_stack(font_family) {
let family = unquote_font_family(&raw_family);
if is_generic_monospace_family(&family) {
push_unique_font_family(&mut kept, GENERIC_MONOSPACE_FAMILY.to_string());
continue;
}
if is_built_in_monospace_family(&family)
|| is_system_font_family_monospace_with_source(&source, &family)
{
push_unique_font_family(&mut kept, raw_family.trim().to_string());
}
}
if kept.is_empty() {
DEFAULT_TERMINAL_FONT_FAMILY.to_string()
} else {
kept.join(", ")
}
}
fn is_built_in_monospace_family(family: &str) -> bool {
BUILT_IN_MONOSPACE_FONTS
.iter()
.any(|known| known.eq_ignore_ascii_case(family))
}
fn is_default_terminal_font_family(font_family: &str) -> bool {
font_family
.trim()
.eq_ignore_ascii_case(DEFAULT_TERMINAL_FONT_FAMILY)
}
fn is_legacy_default_terminal_font_family(font_family: &str) -> bool {
font_family
.trim()
.eq_ignore_ascii_case(LEGACY_DEFAULT_TERMINAL_FONT_FAMILY)
}
fn is_generic_monospace_family(family: &str) -> bool {
family.eq_ignore_ascii_case(GENERIC_MONOSPACE_FAMILY)
}
fn is_system_font_family_monospace_with_source(source: &SystemSource, family: &str) -> bool {
let Ok(family_handle) = source.select_family_by_name(family) else {
return false;
};
family_handle
.fonts()
.iter()
.filter_map(|handle| Font::from_handle(handle).ok())
.any(|font| is_font_monospace(&font))
}
fn is_font_monospace(font: &Font) -> bool {
font.is_monospace() || has_equal_sample_advances(&font)
}
fn has_equal_sample_advances(font: &Font) -> bool {
let mut advances = Vec::new();
for ch in ['i', 'W', '0', 'm', ' '] {
let Some(glyph_id) = font.glyph_for_char(ch) else {
return false;
};
let Ok(advance) = font.advance(glyph_id) else {
return false;
};
advances.push(advance.x());
}
let Some(first) = advances.first().copied() else {
return false;
};
advances
.iter()
.all(|advance| (advance - first).abs() <= f32::EPSILON)
}
fn split_font_family_stack(stack: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaping = false;
for ch in stack.chars() {
if escaping {
current.push(ch);
escaping = false;
continue;
}
if ch == '\\' {
current.push(ch);
escaping = true;
continue;
}
match quote {
Some(active_quote) if ch == active_quote => {
quote = None;
current.push(ch);
}
Some(_) => current.push(ch),
None if ch == '\'' || ch == '"' => {
quote = Some(ch);
current.push(ch);
}
None if ch == ',' => {
let trimmed = current.trim();
if !trimmed.is_empty() {
result.push(trimmed.to_string());
}
current.clear();
}
None => current.push(ch),
}
}
let trimmed = current.trim();
if !trimmed.is_empty() {
result.push(trimmed.to_string());
}
result
}
fn unquote_font_family(family: &str) -> String {
let trimmed = family.trim();
if trimmed.len() >= 2 {
let first = trimmed.chars().next();
let last = trimmed.chars().next_back();
if matches!(
(first, last),
(Some('\''), Some('\'')) | (Some('"'), Some('"'))
) {
return trimmed[1..trimmed.len() - 1].to_string();
}
}
trimmed.to_string()
}
fn push_unique_font_family(families: &mut Vec<String>, family: String) {
let normalized = unquote_font_family(&family);
if families
.iter()
.map(|existing| unquote_font_family(existing))
.any(|existing| existing.eq_ignore_ascii_case(&normalized))
{
return;
}
families.push(family);
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_TERMINAL_FONT_FAMILY, normalize_terminal_font_family, split_font_family_stack,
unquote_font_family,
};
#[test]
fn splits_font_stack_without_splitting_inside_quotes() {
assert_eq!(
split_font_family_stack("JetBrains Mono, \"Noto, Sans\", monospace"),
vec!["JetBrains Mono", "\"Noto, Sans\"", "monospace"]
);
}
#[test]
fn removes_matching_wrapping_quotes() {
assert_eq!(unquote_font_family("\"Cascadia Mono\""), "Cascadia Mono");
assert_eq!(unquote_font_family("'JetBrains Mono'"), "JetBrains Mono");
}
#[test]
fn normalizes_legacy_default_to_nerd_font_default() {
assert_eq!(
normalize_terminal_font_family(
"\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace"
),
DEFAULT_TERMINAL_FONT_FAMILY
);
}
#[test]
fn keeps_current_default_terminal_font_stack() {
assert_eq!(
normalize_terminal_font_family(DEFAULT_TERMINAL_FONT_FAMILY),
DEFAULT_TERMINAL_FONT_FAMILY
);
}
#[test]
fn keeps_custom_jetbrains_mono_stack_without_forcing_nerd_font() {
assert_eq!(
normalize_terminal_font_family("\"JetBrains Mono\", monospace"),
"\"JetBrains Mono\", monospace"
);
}
}
@@ -0,0 +1,224 @@
//! Fuzzy search using nucleo-matcher.
use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use serde::{Deserialize, Serialize};
/// Single fuzzy match with score and highlighted character indices.
#[derive(Debug, Clone, Serialize)]
pub struct FuzzyResult {
pub command: String,
pub score: u32,
pub indices: Vec<u32>,
/// Provider tag: "history", "quickCommand", etc.
pub source: String,
/// Text shown in the suggestion panel (may differ from `command`).
pub display: String,
}
/// Candidate passed from the frontend for stateless fuzzy scoring.
#[derive(Debug, Clone, Deserialize)]
pub struct FuzzySearchCandidate {
pub id: String,
pub value: String,
pub display: String,
}
/// Generic frontend candidate match with stable id and value.
#[derive(Debug, Clone, Serialize)]
pub struct FuzzyCandidateResult {
pub id: String,
pub value: String,
pub display: String,
pub score: u32,
pub indices: Vec<u32>,
}
/// Generic fuzzy search over `(display_text, value)` pairs.
///
/// Matches against `display_text` (shown in the suggestion panel).
/// `value` is stored as `command` in the result (used when filling/executing).
/// `source` tags every result so the frontend can distinguish providers.
pub fn fuzzy_search_items(
items: &[(&str, &str)],
pattern_str: &str,
source: &str,
limit: usize,
min_command_length: Option<usize>,
max_command_length: Option<usize>,
) -> Vec<FuzzyResult> {
let pattern_str = pattern_str.trim();
if pattern_str.is_empty() {
return Vec::new();
}
let pattern = Pattern::new(
pattern_str,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
);
if pattern.atoms.is_empty() {
return Vec::new();
}
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let mut buf = Vec::new();
let mut scored: Vec<(usize, u32)> = Vec::new();
for (idx, (display, value)) in items.iter().enumerate() {
let value_length = value.chars().count();
if min_command_length.is_some_and(|min| value_length < min) {
continue;
}
if max_command_length.is_some_and(|max| value_length > max) {
continue;
}
let haystack = Utf32Str::new(display, &mut buf);
if let Some(score) = pattern.score(haystack, &mut matcher) {
scored.push((idx, score));
}
}
scored.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
scored.truncate(limit);
let mut results = Vec::with_capacity(scored.len());
for (idx, score) in scored {
let (display, value) = &items[idx];
let haystack = Utf32Str::new(display, &mut buf);
let mut indices = Vec::new();
pattern.indices(haystack, &mut matcher, &mut indices);
indices.sort_unstable();
indices.dedup();
results.push(FuzzyResult {
command: (*value).to_string(),
score,
indices,
source: source.to_string(),
display: (*display).to_string(),
});
}
results
}
/// Generic fuzzy search over frontend-provided candidates.
///
/// This keeps business state on the frontend while sharing the Rust fuzzy scorer.
pub fn fuzzy_search_candidates(
items: &[FuzzySearchCandidate],
pattern_str: &str,
limit: usize,
) -> Vec<FuzzyCandidateResult> {
let pattern_str = pattern_str.trim();
if pattern_str.is_empty() {
return Vec::new();
}
let pattern = Pattern::new(
pattern_str,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
);
if pattern.atoms.is_empty() {
return Vec::new();
}
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let mut buf = Vec::new();
let mut scored: Vec<(usize, u32)> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let haystack = Utf32Str::new(&item.display, &mut buf);
if let Some(score) = pattern.score(haystack, &mut matcher) {
scored.push((idx, score));
}
}
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
scored.truncate(limit);
let mut results = Vec::with_capacity(scored.len());
for (idx, score) in scored {
let item = &items[idx];
let haystack = Utf32Str::new(&item.display, &mut buf);
let mut indices = Vec::new();
pattern.indices(haystack, &mut matcher, &mut indices);
indices.sort_unstable();
indices.dedup();
results.push(FuzzyCandidateResult {
id: item.id.clone(),
value: item.value.clone(),
display: item.display.clone(),
score,
indices,
});
}
results
}
#[cfg(test)]
mod tests {
use super::{FuzzySearchCandidate, fuzzy_search_candidates};
fn candidate(id: &str, display: &str) -> FuzzySearchCandidate {
FuzzySearchCandidate {
id: id.to_string(),
value: id.to_string(),
display: display.to_string(),
}
}
#[test]
fn fuzzy_candidates_return_empty_for_blank_pattern() {
let items = [candidate("session:1", "production ssh")];
let results = fuzzy_search_candidates(&items, " ", 10);
assert!(results.is_empty());
}
#[test]
fn fuzzy_candidates_respect_limit_and_return_stable_ids() {
let items = [
candidate("session:1", "production ssh root@10.0.0.1"),
candidate("connection:1", "production database postgres"),
candidate("session:2", "local shell"),
];
let results = fuzzy_search_candidates(&items, "prod", 1);
assert_eq!(results.len(), 1);
assert!(matches!(
results[0].id.as_str(),
"session:1" | "connection:1"
));
assert_eq!(results[0].value, results[0].id);
}
#[test]
fn fuzzy_candidates_sort_by_score_then_original_order() {
let items = [
candidate("first", "docker image"),
candidate("second", "docker image"),
candidate("third", "documentation index"),
];
let results = fuzzy_search_candidates(&items, "docker image", 10);
assert!(results.len() >= 2);
assert_eq!(results[0].id, "first");
assert_eq!(results[1].id, "second");
assert!(results[0].score >= results[1].score);
}
}
@@ -0,0 +1,4 @@
pub mod crypto;
pub mod fonts;
pub mod fuzzy;
pub mod url;
@@ -0,0 +1,81 @@
use reqwest::Url;
use crate::error::{AppError, AppResult};
pub fn normalize_api_base_url(value: &str) -> AppResult<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
let mut url = Url::parse(trimmed)
.map_err(|err| AppError::Config(format!("Invalid API base URL '{trimmed}': {err}")))?;
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url.to_string())
}
pub fn join_api_base_url(base_url: &str, suffix: &str) -> AppResult<String> {
let normalized = normalize_api_base_url(base_url)?;
let url = Url::parse(&normalized).map_err(|err| {
AppError::Config(format!(
"Invalid API base URL '{}': {err}",
normalized.as_str()
))
})?;
let original_query = url.query().map(str::to_string);
let mut full_url = url.join(suffix.trim_start_matches('/')).map_err(|err| {
AppError::Config(format!(
"Failed to join API path '{}' to '{}': {err}",
suffix, normalized
))
})?;
full_url.set_query(original_query.as_deref());
Ok(full_url.to_string())
}
pub fn normalize_storage_endpoint(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_base_url_is_directory_url() {
assert_eq!(
normalize_api_base_url("https://api.example.com/v1").unwrap(),
"https://api.example.com/v1/"
);
assert_eq!(
normalize_api_base_url("https://api.example.com/v1/").unwrap(),
"https://api.example.com/v1/"
);
}
#[test]
fn api_base_url_preserves_query() {
assert_eq!(
normalize_api_base_url(" https://api.example.com/v1?api-version=1 ").unwrap(),
"https://api.example.com/v1/?api-version=1"
);
}
#[test]
fn api_base_url_join_preserves_query() {
assert_eq!(
join_api_base_url("https://api.example.com/v1?api-version=1", "models").unwrap(),
"https://api.example.com/v1/models?api-version=1"
);
}
#[test]
fn storage_endpoint_trims_whitespace_and_trailing_slashes() {
assert_eq!(
normalize_storage_endpoint(" https://dav.example.com/remote.php/webdav// "),
"https://dav.example.com/remote.php/webdav"
);
}
}
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "nyaterm-web"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
[dependencies]
nyaterm-core = { path = "../nyaterm-core" }
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["ws", "multipart"] }
tower-http = { version = "0.6", features = ["fs", "set-header"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["rt", "io"] }
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
sha2 = "0.10"
rand = "0.8"
zeroize = "1"
url = "2"
async-stream = "0.3"
subtle = "2"
russh = { path = "../../vendor/russh/russh", default-features = false, features = ["des", "dsa", "flate2", "ring", "rsa"] }
russh-sftp = { path = "../../vendor/russh-sftp" }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "native-tls"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
tempfile = "3"
tokio-tungstenite = "0.29"
rand-new = { package = "rand", version = "0.10" }
+268
View File
@@ -0,0 +1,268 @@
use crate::{
error::{Result, WebError},
state::{Event, State},
};
use nyaterm_core::{
config::{self, AiAgentKind, AiMode},
core::ai::{catalog, history, model, parser, redaction, stream, types::*},
};
use serde_json::{Value, json};
use std::{sync::Arc, time::Duration};
pub async fn command(
state: &Arc<State>,
owner: &str,
command: &str,
args: &Value,
) -> Result<Value> {
match command {
"clear_ai_history" => {
history::clear_ai_history_storage(&())?;
Ok(Value::Null)
}
"rebind_ai_session" => {
let scope: AiSessionScope = serde_json::from_value(args["ownerScope"].clone())?;
validate_scope(state, owner, &scope).await?;
Ok(json!(history::rebind_ai_session(
&(),
args["sessionId"]
.as_str()
.ok_or(WebError::bad("AI session required"))?
.into(),
scope
)?))
}
"get_ai_sessions" => Ok(json!(history::get_ai_sessions(&())?)),
"get_ai_messages" => Ok(json!(history::get_ai_messages(
&(),
args["sessionId"]
.as_str()
.ok_or(WebError::bad("AI session required"))?
.into()
)?)),
"delete_ai_session" => {
history::delete_ai_session(
&(),
args["sessionId"]
.as_str()
.ok_or(WebError::bad("AI session required"))?
.into(),
)?;
Ok(Value::Null)
}
"list_ai_model_names" => Ok(json!(model::list_model_names(&()).await?)),
"test_ai_model_connection" => {
let settings = merged_settings(args)?;
model::test_model_connection(
&settings,
args["modelId"]
.as_str()
.ok_or(WebError::bad("AI model required"))?,
)
.await?;
Ok(Value::Null)
}
"test_ai_provider_connection" => {
let settings = merged_settings(args)?;
Ok(json!(
model::test_provider_connection(
&settings,
args["credentialId"]
.as_str()
.ok_or(WebError::bad("Provider required"))?
)
.await?
))
}
"refresh_ai_model_settings" => {
let _guard = state.mutation.lock().await;
let mut existing = config::load_app_settings(&())?;
let mut settings = merged_settings(args)?;
let discoveries = model::list_model_names_for_settings(&settings).await?;
settings.models = catalog::merge_model_discoveries(&settings, discoveries);
settings.default_model_id =
catalog::update_default_model_id(&settings, &settings.models);
config::normalize_ai_settings(&mut settings);
existing.ai = config::encrypt_ai_settings(settings.clone())?;
existing.cloud_sync = config::encrypt_cloud_sync_settings(existing.cloud_sync)?;
config::save_app_settings(&(), &existing)?;
state.broadcast("settings-changed", Value::Null).await;
Ok(json!(config::mask_ai_settings(settings)))
}
"cancel_ai_chat_stream" => {
let id = args["streamId"]
.as_str()
.ok_or(WebError::bad("AI stream required"))?;
if let Some(token) = state
.ai_streams
.lock()
.await
.remove(&(owner.into(), id.into()))
{
token.cancel();
}
Ok(Value::Null)
}
"start_ai_chat_stream" => start(state, owner, args).await,
_ => Err(WebError::unsupported()),
}
}
pub fn supports(command: &str) -> bool {
matches!(
command,
"clear_ai_history"
| "rebind_ai_session"
| "test_ai_provider_connection"
| "refresh_ai_model_settings"
| "get_ai_sessions"
| "get_ai_messages"
| "delete_ai_session"
| "list_ai_model_names"
| "test_ai_model_connection"
| "cancel_ai_chat_stream"
| "start_ai_chat_stream"
)
}
fn merged_settings(args: &Value) -> Result<config::AiSettings> {
let existing = config::load_app_settings(&())?.ai;
if args["aiSettings"].is_null() {
return Ok(existing);
}
Ok(config::merge_masked_ai_settings(
&existing,
serde_json::from_value(args["aiSettings"].clone())?,
))
}
async fn validate_scope(state: &Arc<State>, owner: &str, scope: &AiSessionScope) -> Result<()> {
if scope.r#type == AiSessionScopeType::Terminal {
state
.session(
owner,
scope
.target_id
.as_deref()
.ok_or(WebError::bad("Terminal scope requires session"))?,
)
.await?;
}
Ok(())
}
async fn start(state: &Arc<State>, owner: &str, args: &Value) -> Result<Value> {
let mut request: AiChatRequest = serde_json::from_value(args["request"].clone())?;
if request.mode != AiMode::Ask
|| request.agent_kind != AiAgentKind::Nyaterm
|| !request.attachments.is_empty()
{
return Err(WebError::unsupported());
}
for id in request
.terminal_session_id
.iter()
.chain(request.default_target_session_id.iter())
.chain(request.targets.iter().map(|t| &t.terminal_session_id))
.chain(
request
.target_contexts
.iter()
.filter_map(|t| t.target.as_ref().map(|t| &t.terminal_session_id)),
)
{
state.session(owner, id).await?;
}
validate_scope(state, owner, &request.owner_scope).await?;
let mut settings = config::load_app_settings(&())?.ai;
if !settings.enabled {
return Err(WebError::bad("AI assistant is disabled"));
}
settings.redaction_enabled = true;
model::resolve_request_model(&settings, &request)?;
let stream_id = request
.stream_id
.clone()
.filter(|s| s.len() <= 128 && !s.is_empty())
.unwrap_or_else(|| format!("ai-stream-{}", uuid()));
let session_id = request
.session_id
.clone()
.filter(|s| s.len() <= 128 && !s.is_empty())
.unwrap_or_else(|| format!("ai-session-{}", uuid()));
request.session_id = Some(session_id.clone());
history::validate_session_scope(&(), &session_id, &request)?;
redaction::redact_context(&mut request.context);
for target in &mut request.target_contexts {
redaction::redact_context(&mut target.context);
}
request.user_input = redaction::redact_sensitive_text(&request.user_input);
let login = state.login(owner).await?;
let cancel = login.cancel.child_token();
let key = (owner.to_owned(), stream_id.clone());
let mut streams = state.ai_streams.lock().await;
if streams.contains_key(&key) || streams.keys().filter(|(id, _)| id == owner).count() >= 4 {
return Err(WebError::bad("Too many AI streams"));
}
streams.insert(key.clone(), cancel.clone());
drop(streams);
let state = state.clone();
let events = login.events.clone();
let task_stream = stream_id.clone();
let task_session = session_id.clone();
tokio::spawn(async move {
let sink = stream::EventSink(move |id: &str, payload: AiStreamEventPayload| {
let _ = events.send(Event {
event: format!("ai-stream-{id}"),
payload: json!(payload),
});
});
let make_event = |kind: &str| AiStreamEventPayload {
event_type: kind.into(),
stream_id: task_stream.clone(),
session_id: Some(task_session.clone()),
text_delta: None,
reasoning_delta: None,
message: None,
command_cards: vec![],
usage: None,
error: None,
};
stream::emit_stream_event(&sink, &task_stream, make_event("start"));
if settings.record_history {
let _ = history::save_user_message(&(), &task_session, &request);
}
let (_cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel();
let result = tokio::select! {
_ = cancel.cancelled()=>Err(WebError::bad("AI stream cancelled")),
result=tokio::time::timeout(Duration::from_secs(600),stream::run_model_stream(&sink,&task_stream,&request,&settings,&mut cancel_rx))=>match result{Ok(result)=>result.map_err(Into::into),Err(_)=>Err(WebError::bad("AI stream timed out"))},
};
let event = match result {
Ok(output) => {
let (text, reasoning, mut cards) =
parser::parse_model_output(&output.text, output.reasoning_content);
parser::bind_command_card_targets(&mut cards, &request);
let message = AiMessage {
id: format!("msg-{}", uuid()),
session_id: task_session.clone(),
role: AiMessageRole::Assistant,
content: text,
created_at: now_rfc3339(),
reasoning_content: reasoning,
command_cards: cards.clone(),
};
if settings.record_history {
let _ = history::append_message(&(), message.clone());
}
let mut event = make_event("done");
event.message = Some(message);
event.command_cards = cards;
event
}
Err(_) => {
let mut event = make_event("error");
event.error = Some("AI request failed or was cancelled".into());
event
}
};
stream::emit_stream_event(&sink, &task_stream, event);
state.ai_streams.lock().await.remove(&key);
});
Ok(json!({"streamId":stream_id,"sessionId":session_id}))
}
+197
View File
@@ -0,0 +1,197 @@
use crate::{
error::{Result, WebError},
state::{Login, State},
};
use axum::{
Json,
body::Body,
extract::{Request, State as ExtractState},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::{
sync::Arc,
time::{Duration, Instant},
};
pub const COOKIE: &str = "nyaterm_session";
#[derive(Clone)]
pub struct Owner(pub String);
pub fn random_secret() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn digest(value: &str) -> [u8; 32] {
Sha256::digest(value.as_bytes()).into()
}
fn equal(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
bool::from(a.ct_eq(b))
}
pub fn cookie_owner(headers: &HeaderMap) -> Option<String> {
headers
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == COOKIE && value.len() == 43).then(|| value.to_owned())
})
}
pub fn validate_boundary(state: &State, headers: &HeaderMap, require_origin: bool) -> Result<()> {
let host = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if host != state.host {
return Err(WebError::forbidden());
}
match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
Some(origin) if origin != state.origin => Err(WebError::forbidden()),
None if require_origin => Err(WebError::forbidden()),
_ => Ok(()),
}
}
pub async fn guard(
ExtractState(state): ExtractState<Arc<State>>,
mut request: Request<Body>,
next: Next,
) -> Response {
let unsafe_method = !matches!(
*request.method(),
axum::http::Method::GET | axum::http::Method::HEAD
);
let websocket = request.headers().contains_key(header::UPGRADE);
let result = async {
validate_boundary(&state, request.headers(), unsafe_method || websocket)?;
let token = cookie_owner(request.headers()).ok_or(WebError(
StatusCode::UNAUTHORIZED,
"Sign in required".into(),
))?;
let login = state.login(&token).await?;
if unsafe_method {
let csrf = request
.headers()
.get("x-nyaterm-csrf")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !equal(csrf.as_bytes(), login.csrf.as_bytes()) {
return Err(WebError::forbidden());
}
}
request.extensions_mut().insert(Owner(token));
Ok(())
}
.await;
match result {
Ok(()) => next.run(request).await,
Err(error) => error.into_response(),
}
}
pub async fn site_guard(
ExtractState(state): ExtractState<Arc<State>>,
request: Request<Body>,
next: Next,
) -> Response {
match validate_boundary(&state, request.headers(), false) {
Ok(()) => next.run(request).await,
Err(error) => error.into_response(),
}
}
#[derive(Deserialize)]
pub struct LoginRequest {
password: String,
}
pub async fn sign_in(
ExtractState(state): ExtractState<Arc<State>>,
headers: HeaderMap,
Json(mut payload): Json<LoginRequest>,
) -> Result<Response> {
use zeroize::Zeroize;
validate_boundary(&state, &headers, true)?;
if headers
.get("x-nyaterm-request")
.and_then(|v| v.to_str().ok())
!= Some("1")
{
return Err(WebError::forbidden());
}
let mut attempts = state.login_attempts.lock().await;
attempts.retain(|instant| instant.elapsed() < Duration::from_secs(60));
if attempts.len() >= 20 {
return Err(WebError(
StatusCode::TOO_MANY_REQUESTS,
"Try again later".into(),
));
}
attempts.push(Instant::now());
drop(attempts);
let valid = equal(&digest(&payload.password), &state.password_hash);
payload.password.zeroize();
if !valid {
return Err(WebError(
StatusCode::UNAUTHORIZED,
"Invalid credentials".into(),
));
}
// Re-authentication revokes the old owner's sessions, avoiding abandoned leases.
if let Some(old) = cookie_owner(&headers) {
state.close_owner(&old).await;
}
let owner = random_secret();
let csrf = random_secret();
let (events, _) = tokio::sync::broadcast::channel(256);
let mut logins = state.logins.lock().await;
if logins.len() >= 128 {
return Err(WebError(
StatusCode::TOO_MANY_REQUESTS,
"Too many active logins".into(),
));
}
logins.insert(
owner.clone(),
Arc::new(Login {
csrf: csrf.clone(),
expires: Instant::now() + Duration::from_secs(8 * 3600),
events,
cancel: state.shutdown.child_token(),
}),
);
Ok((
[(header::SET_COOKIE, cookie(&state, &owner, 8 * 3600))],
Json(json!({"csrf":csrf})),
)
.into_response())
}
fn cookie(state: &State, token: &str, max_age: u32) -> String {
format!(
"{COOKIE}={token}; Path={}/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}",
state.base_path,
if state.secure_cookie { "; Secure" } else { "" }
)
}
pub async fn current(
ExtractState(state): ExtractState<Arc<State>>,
axum::Extension(owner): axum::Extension<Owner>,
) -> Result<Json<serde_json::Value>> {
Ok(Json(json!({"csrf":state.login(&owner.0).await?.csrf})))
}
pub async fn sign_out(
ExtractState(state): ExtractState<Arc<State>>,
axum::Extension(owner): axum::Extension<Owner>,
) -> Response {
state.close_owner(&owner.0).await;
(
[(header::SET_COOKIE, cookie(&state, "", 0))],
Json(json!({})),
)
.into_response()
}
@@ -0,0 +1,151 @@
use crate::{
auth::Owner,
error::{Result, WebError},
state::State,
};
use axum::{
Extension, Json,
extract::{Path, State as ExtractState},
};
use nyaterm_core::{config, services, storage, utils::crypto};
use serde_json::{Value, json};
use std::sync::Arc;
fn argument<T: serde::de::DeserializeOwned>(args: &Value, name: &str) -> Result<T> {
Ok(serde_json::from_value(args[name].clone())?)
}
fn text<'a>(args: &'a Value, name: &str) -> Result<&'a str> {
args[name]
.as_str()
.ok_or(WebError::bad("Missing command argument"))
}
pub async fn route(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Path(command): Path<String>,
Json(args): Json<Value>,
) -> Result<Json<Value>> {
// Explicit allowlist; no dynamic reflection or Desktop invoke handler here.
let result = match command.as_str() {
"get_app_runtime_info" => json!({"mode":"web","portable":false,"packageManager":null,"executableDir":"","dataDir":"","configDir":"","logDir":"","webviewDataDir":"","portableMarkerPath":null}),
"get_app_settings" => {
let mut settings = config::load_app_settings(&())?;
if settings.security.master_password.is_some() { settings.security.master_password=Some("__SET__".into()); }
settings.ai=config::mask_ai_settings(settings.ai);
settings.cloud_sync=config::mask_cloud_sync_settings(settings.cloud_sync);
json!(settings)
},
"save_app_settings" => {
let _guard=state.mutation.lock().await;
let mut next:config::AppSettings=argument(&args,"settings")?;
let current=config::load_app_settings(&())?;
// Master-password rotation remains a Desktop-only flow. Web cannot
// disable or replace an existing master password through a settings save.
if next.security.master_password.as_deref().is_some_and(|v| v!="__SET__") || next.security.master_password.is_none() && current.security.master_password.is_some() { return Err(WebError::unsupported()); }
next.security.master_password=current.security.master_password;
next.appearance.normalize_window_transparency();
next.terminal.normalize_scrollback_lines();next.terminal.normalize_timestamp_format();next.recording.normalize();
next.ai=config::encrypt_ai_settings(config::merge_masked_ai_settings(&current.ai,next.ai))?;
next.cloud_sync=config::encrypt_cloud_sync_settings(config::merge_masked_cloud_sync_settings(&current.cloud_sync,next.cloud_sync))?;
config::save_app_settings(&(),&next)?;
state.broadcast("settings-changed",Value::Null).await;
Value::Null
},
"save_app_ui_settings" => {
let _guard=state.mutation.lock().await;
let ui:config::UiConfig=argument(&args,"ui")?;
storage::update_settings_doc::<config::AppSettings,_,_>(storage::SettingsDocKey::AppSettings,|settings| { settings.ui=ui;Ok(()) })?;
state.broadcast("settings-changed",Value::Null).await;Value::Null
},
"get_saved_connections" => {
let mut connections=config::load_config(&())?.connections;
let values=connections.iter_mut().map(|conn|{let exists=conn.auth.as_ref().is_some_and(|a|a.password.is_some());if let Some(auth)=&mut conn.auth {auth.password=None;}let mut value=json!(conn);if !value["auth"].is_null(){value["auth"]["has_password"]=json!(exists);}value}).collect::<Vec<_>>();
json!(values)
},
"save_connection" => {
let _guard=state.mutation.lock().await;
let connection:config::SavedConnection=argument(&args,"connection")?;
if !matches!(connection.config,config::ConnectionType::Ssh { .. }) { return Err(WebError::unsupported()); }
let id=services::save_connection(&(),connection)?;
state.broadcast("connections-changed",Value::Null).await;json!(id)
},
"delete_connection" => {
let _guard=state.mutation.lock().await;
let mut config=config::load_config(&())?;config.connections.retain(|c|Some(c.id.as_str())!=args["id"].as_str());config::save_config(&(),&config)?;
state.broadcast("connections-changed",Value::Null).await;Value::Null
},
"get_groups" => json!(config::load_config(&())?.groups),
"get_connection_custom_icons" => json!(config::load_config(&())?.custom_icons),
"get_supported_ssh_algorithms" => json!(nyaterm_core::ssh::algorithms::get_supported_ssh_algorithms()),
"save_group" => {
let _guard=state.mutation.lock().await;
let mut group:config::Group=argument(&args,"group")?;if group.id.is_empty(){group.id=uuid::Uuid::new_v4().to_string();}
let id=group.id.clone();let mut config=config::load_config(&())?;
config.groups.retain(|g|g.id!=id);config.groups.push(group);config::save_config(&(),&config)?;
state.broadcast("connections-changed",Value::Null).await;json!(id)
},
"get_saved_passwords" => json!(config::load_passwords(&())?.passwords.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"username":entry.username,"has_password":entry.password.is_some()})).collect::<Vec<_>>()),
"save_password" => {
let _guard=state.mutation.lock().await;
let mut entry:config::SavedPassword=argument(&args,"entry")?;if entry.id.is_empty(){entry.id=uuid::Uuid::new_v4().to_string();}
let id=entry.id.clone();let mut config=config::load_passwords(&())?;
entry.password=match entry.password.as_deref(){Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>config.passwords.iter().find(|p|p.id==id).and_then(|p|p.password.clone())};
config.passwords.retain(|p|p.id!=id);config.passwords.push(entry);config::save_passwords(&(),&config)?;json!(id)
},
"delete_password" => { let _guard=state.mutation.lock().await;let mut config=config::load_passwords(&())?;config.passwords.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_passwords(&(),&config)?;Value::Null },
"get_ssh_keys" => json!(config::load_keys(&())?.keys.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"has_key_data":entry.key.is_some(),"has_cert_data":entry.cert.is_some()})).collect::<Vec<_>>()),
"save_ssh_key" => {
let _guard=state.mutation.lock().await;
let mut entry:config::SshKey=argument(&args,"key")?;
if entry.key_file_path.is_some() || entry.cert_file_path.is_some() { return Err(WebError::unsupported()); }
if entry.id.is_empty(){entry.id=uuid::Uuid::new_v4().to_string();}let id=entry.id.clone();
let mut config=config::load_keys(&())?;let existing=config.keys.iter().find(|p|p.id==id);
// The client can supply plaintext only via transient key_data. It may
// never inject an encrypted on-disk token or a server filesystem path.
entry.key=match entry.key_data.take(){Some(data)=>{services::validate_private_key_content(&data,entry.passphrase.as_deref())?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.key.clone())};
entry.cert=match entry.cert_data.take(){Some(data)=>{services::validate_certificate_content(&data)?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.cert.clone())};
if entry.key.is_none() { return Err(WebError::bad("Private key required")); }
entry.passphrase=match entry.passphrase.as_deref(){Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>existing.and_then(|k|k.passphrase.clone())};
config.keys.retain(|p|p.id!=id);config.keys.push(entry);config::save_keys(&(),&config)?;json!(id)
},
"delete_ssh_key" => { let _guard=state.mutation.lock().await;let mut config=config::load_keys(&())?;config.keys.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_keys(&(),&config)?;Value::Null },
"get_saved_credentials" => { let entries=config::load_credentials(&())?.credentials;json!(entries.into_iter().map(|mut e| { let exists=e.password.is_some();e.password=None;let mut value=json!(e);value["has_password"]=json!(exists);value }).collect::<Vec<_>>()) },
"save_credential" => { let _guard=state.mutation.lock().await;let entry:config::SavedCredential=argument(&args,"entry")?;{ let mut config=config::load_credentials(&())?;let id=config::upsert_credential(&mut config,entry)?;config::save_credentials(&(),&config)?;state.broadcast("credentials-changed",Value::Null).await;json!(id) } },
"get_connection_password_value" => {let conn=config::load_connection_by_id(&(),text(&args,"id")?)?;json!(conn.auth.as_ref().map(|a|crypto::decrypt_optional(&a.password)).transpose()?.flatten())},
"get_saved_credential_password" => json!(config::load_credential_by_id(&(),text(&args,"id")?)?.password),
"get_password_value" | "get_saved_password_value" => json!(config::load_password_by_id(&(),text(&args,"id")?)?.password),
"get_ssh_key_passphrase" => json!(config::load_key_by_id(&(),text(&args,"id")?)?.passphrase),
"get_ssh_key_private_key" => json!(config::decrypt_key_pem(&config::load_key_by_id(&(),text(&args,"id")?)?)?),
"get_ssh_key_public_key" => { let key=config::load_key_by_id(&(),text(&args,"id")?)?;let plain=config::decrypt_key_pem(&key)?.ok_or(WebError::bad("Private key required"))?;json!(services::derive_public_key_for_copy(&plain,key.passphrase.as_deref())?) },
"delete_credential" => { let _guard=state.mutation.lock().await;let mut config=config::load_credentials(&())?;let id=text(&args,"id")?;config.credentials.retain(|p|p.id!=id);config::save_credentials(&(),&config)?;state.broadcast("credentials-changed",Value::Null).await;Value::Null },
"get_known_hosts" => json!(storage::list_known_hosts()?),
"delete_known_host" => { storage::delete_known_host(text(&args,"id")?)?;Value::Null },
"respond_host_key_verify" | "submit_ssh_auth_response" | "cancel_ssh_auth_request" | "submit_otp_response" => {
let id=text(&args,"requestId")?;
let mut prompts=state.prompts.lock().await;
if !prompts.get(id).is_some_and(|p|p.owner==owner.0){return Err(WebError::forbidden());}
let prompt=prompts.remove(id).unwrap();
let reply=match command.as_str(){"respond_host_key_verify"=>args["accepted"].clone(),"cancel_ssh_auth_request"=>Value::Null,"submit_otp_response"=>args["responses"].clone(),_=>args["response"].clone()};
let _=prompt.reply.send(reply);Value::Null
},
"close_session" => { state.session(&owner.0,text(&args,"sessionId")?).await?.cancel.cancel();Value::Null },
"cancel_session_creation" => { let id=text(&args,"createRequestId")?;for session in state.sessions.lock().await.values().filter(|s|s.owner==owner.0&&s.request_id.as_deref()==Some(id)){session.cancel.cancel();}Value::Null },
"list_sessions" | "get_sessions" => json!(state.sessions.lock().await.values().filter(|s|s.owner==owner.0).map(|s|s.info()).collect::<Vec<_>>()),
"get_session_info" => state.session(&owner.0,text(&args,"sessionId")?).await?.info(),
"try_get_terminal_cwd" | "get_session_cwd" => { state.session(&owner.0,text(&args,"sessionId")?).await?;Value::Null },
"get_app_lock_state" => json!(false),
"get_system_fonts" => json!(["JetBrains Mono","monospace"]),
"get_system_font_infos" => json!([{"family":"JetBrains Mono","monospace":true}]),
"get_plugin_catalog" | "list_plugins" | "get_plugins" => json!([]),
"get_plugin_capabilities" => crate::plugins::capabilities(),
"check_web_plugin_compatibility" => crate::plugins::check(&args["manifest"]),
"get_plugin_marketplace" => json!({"target":"web","catalog":{"catalogVersion":1,"repository":{"id":"web","name":"Web"},"generatedAt":"","plugins":[]}}),
"get_command_history" | "fuzzy_search_history" | "get_quick_commands" => json!([]),
"register_command_submission" | "register_command_confirmation_candidate" => { state.session(&owner.0,text(&args,"sessionId")?).await?;Value::Null },
"finish_recording_scope" | "notify_mcp_session_restore_complete" => Value::Null,
command if crate::sftp::supports(command) => crate::sftp::command(&state,&owner.0,command,&args).await?,
command if crate::ai::supports(command) => crate::ai::command(&state,&owner.0,command,&args).await?,
_ => return Err(WebError::unsupported()),
};
Ok(Json(result))
}
+49
View File
@@ -0,0 +1,49 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
#[derive(Debug)]
pub struct WebError(pub StatusCode, pub String);
pub type Result<T> = std::result::Result<T, WebError>;
impl WebError {
pub fn bad(message: impl Into<String>) -> Self {
Self(StatusCode::BAD_REQUEST, message.into())
}
pub fn forbidden() -> Self {
Self(StatusCode::FORBIDDEN, "Access denied".into())
}
pub fn unsupported() -> Self {
Self(
StatusCode::NOT_IMPLEMENTED,
"This capability is unavailable in Web mode".into(),
)
}
}
impl From<nyaterm_core::error::AppError> for WebError {
fn from(_: nyaterm_core::error::AppError) -> Self {
Self::bad("Backend operation failed")
}
}
impl From<serde_json::Error> for WebError {
fn from(_: serde_json::Error) -> Self {
Self::bad("Invalid request parameters")
}
}
impl From<russh::Error> for WebError {
fn from(_: russh::Error) -> Self {
Self::bad("SSH operation failed")
}
}
impl From<russh_sftp::client::error::Error> for WebError {
fn from(_: russh_sftp::client::error::Error) -> Self {
Self::bad("SFTP operation failed")
}
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
(self.0, Json(json!({"error": self.1}))).into_response()
}
}
+126
View File
@@ -0,0 +1,126 @@
pub mod ai;
pub mod auth;
pub mod commands;
pub mod error;
pub mod plugins;
pub mod session;
pub mod sftp;
pub mod state;
use crate::{auth::Owner, state::State};
use axum::{
Extension, Router,
extract::{DefaultBodyLimit, State as ExtractState},
http::{HeaderValue, StatusCode},
middleware,
response::{
Sse,
sse::{Event, KeepAlive},
},
routing::{get, post},
};
use std::{convert::Infallible, sync::Arc, time::Duration};
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
};
async fn events(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
) -> error::Result<Sse<impl futures_util::Stream<Item = std::result::Result<Event, Infallible>>>> {
let login = state.login(&owner.0).await?;
let mut receiver = login.events.subscribe();
let stream = async_stream::stream! {
yield Ok(Event::default().data(r#"{"event":"ready","payload":null}"#));
loop{
tokio::select!{
_ = login.cancel.cancelled()=>{yield Ok(Event::default().event("expired").data("{}"));break;},
next=receiver.recv()=>match next{
Ok(event)=>{if let Ok(data)=serde_json::to_string(&event){yield Ok(Event::default().data(data));}},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{yield Ok(Event::default().event("expired").data("{}"));break;},
Err(_)=>break,
}
}
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
}
pub fn router(state: Arc<State>, dist: std::path::PathBuf) -> Router {
let protected = Router::new()
.route("/auth/session", get(auth::current))
.route("/auth/logout", post(auth::sign_out))
.route("/events", get(events))
.route("/sessions", post(session::create_route))
.route("/sessions/{id}/terminal", get(session::ws_route))
.route("/sessions/{id}/download", get(sftp::download))
.route(
"/sessions/{id}/upload",
post(sftp::upload).layer(DefaultBodyLimit::disable()),
)
.route("/commands/{command}", post(commands::route))
.route_layer(middleware::from_fn_with_state(state.clone(), auth::guard));
let api = protected
.route("/auth/login", post(auth::sign_in))
.fallback(|| async {
(
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error":"API route not found"})),
)
})
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
let site = Router::new()
.nest("/api", api)
.route_service("/", ServeFile::new(dist.join("index.html")))
.fallback_service(ServeDir::new(&dist).fallback(ServeFile::new(dist.join("index.html"))));
let app = if state.base_path.is_empty() {
site
} else {
// Axum nests an inner `/` at the exact prefix. Include the trailing
// slash so the browser's directory URL reaches index.html.
let directory = format!("{}/", state.base_path);
Router::new().nest(&directory, site).route(
&state.base_path,
get(move || {
let directory = directory.clone();
async move { axum::response::Redirect::permanent(&directory) }
}),
)
};
app.layer(middleware::from_fn_with_state(state.clone(), auth::site_guard))
.layer(SetResponseHeaderLayer::overriding(axum::http::header::CONTENT_SECURITY_POLICY,HeaderValue::from_static("default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'")))
.layer(SetResponseHeaderLayer::overriding(axum::http::header::CACHE_CONTROL,HeaderValue::from_static("no-store")))
.layer(SetResponseHeaderLayer::overriding(axum::http::header::X_CONTENT_TYPE_OPTIONS,HeaderValue::from_static("nosniff")))
.layer(SetResponseHeaderLayer::overriding(axum::http::header::REFERRER_POLICY,HeaderValue::from_static("same-origin")))
.with_state(state)
}
pub async fn reap(state: Arc<State>) {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {_ = state.shutdown.cancelled()=>break,_ = interval.tick()=>{}}
let expired = state
.logins
.lock()
.await
.iter()
.filter(|(_, l)| l.expires <= std::time::Instant::now())
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
for owner in expired {
state.close_owner(&owner).await;
}
state
.prompts
.lock()
.await
.retain(|_, prompt| !prompt.reply.is_closed());
for session in state.sessions.lock().await.values() {
if !session.attached.load(std::sync::atomic::Ordering::Acquire)
&& session.detached_at.lock().unwrap().elapsed() > Duration::from_secs(30)
{
session.cancel.cancel();
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use nyaterm_web::{auth, state::State};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
fn secret(name: &str) -> Result<zeroize::Zeroizing<String>, Box<dyn std::error::Error>> {
let value = if let Ok(path) = std::env::var(format!("{name}_FILE")) {
std::fs::read_to_string(path)?
.trim_end_matches(['\r', '\n'])
.to_owned()
} else {
std::env::var(name).map_err(|_| format!("{name} or {name}_FILE is required"))?
};
Ok(zeroize::Zeroizing::new(value))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let password = secret("NYATERM_WEB_PASSWORD")?;
if password.len() < 32 {
return Err("NYATERM_WEB_PASSWORD must contain at least 32 random characters".into());
}
let encoded = secret("NYATERM_WEB_ENCRYPTION_KEY")?;
let bytes = zeroize::Zeroizing::new(
STANDARD
.decode(encoded.as_bytes())
.map_err(|_| "Invalid encryption key encoding")?,
);
let key: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| "Encryption key must decode to 32 bytes")?;
nyaterm_core::utils::crypto::set_server_key_material(key);
let public =
std::env::var("NYATERM_WEB_PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8080/".into());
let url = url::Url::parse(&public)?;
let origin = url.origin().ascii_serialization();
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("Invalid public URL".into());
}
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
if url.scheme() != "https" && !loopback {
return Err("Non-loopback public URLs require HTTPS".into());
}
let base_path = url.path().trim_end_matches('/').to_owned();
if base_path.contains('%') || base_path.contains("//") {
return Err("Invalid base path".into());
}
let host = url[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
let data = PathBuf::from(
std::env::var("NYATERM_WEB_DATA_DIR").unwrap_or_else(|_| "./nyaterm-web-data".into()),
);
std::fs::create_dir_all(&data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o700))?;
}
nyaterm_core::storage::init(&data)?;
// Existing encrypted master-password data is not silently discarded. Web
// unwraps it with the external server key, preserving the existing key hierarchy.
if let Some(secret) = nyaterm_core::storage::load_settings_doc::<
nyaterm_core::config::AppSettings,
>(nyaterm_core::storage::SettingsDocKey::AppSettings)?
.security
.master_password
{
let plain = nyaterm_core::utils::crypto::decrypt_settings_secret(&secret)?;
nyaterm_core::utils::crypto::set_master_password(Some(plain));
nyaterm_core::utils::crypto::verify_master_key_token()?;
}
nyaterm_core::utils::crypto::verify_master_key_token()?;
let dist = PathBuf::from(std::env::var("NYATERM_WEB_DIST").unwrap_or_else(|_| "dist".into()));
if !dist.join("index.html").is_file() {
return Err("React dist/index.html is missing; run pnpm build:web".into());
}
let state = Arc::new(State {
origin,
host,
base_path,
secure_cookie: url.scheme() == "https",
password_hash: auth::digest(&password),
logins: Mutex::new(HashMap::new()),
sessions: Mutex::new(HashMap::new()),
prompts: Mutex::new(HashMap::new()),
ai_streams: Mutex::new(HashMap::new()),
login_attempts: Mutex::new(Vec::new()),
mutation: Mutex::new(()),
shutdown: tokio_util::sync::CancellationToken::new(),
});
tokio::spawn(nyaterm_web::reap(state.clone()));
let bind = std::env::var("NYATERM_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:8080".into());
let listener = tokio::net::TcpListener::bind(&bind).await?;
eprintln!("NyaTerm Web listening on {bind}");
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler");
tokio::select! {_ = tokio::signal::ctrl_c()=>{},_ = terminate.recv()=>{}}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
shutdown.cancel();
});
use std::future::IntoFuture;
let stop = state.shutdown.clone();
let server = axum::serve(listener, nyaterm_web::router(state.clone(), dist))
.with_graceful_shutdown(async move { stop.cancelled().await })
.into_future();
tokio::pin!(server);
tokio::select! {
result=&mut server=>result?,
_ = state.shutdown.cancelled()=> {let _=tokio::time::timeout(std::time::Duration::from_secs(5),&mut server).await;}
}
for session in state.sessions.lock().await.values() {
session.cancel.cancel();
}
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while !state.sessions.lock().await.is_empty() {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
})
.await;
Ok(())
}
@@ -0,0 +1,20 @@
//! Web compatibility boundary. No untrusted plugin code executes on the server.
use serde_json::{Value, json};
pub fn capabilities() -> Value {
json!({"runtime":"web","installation":false,"native":false,"server_process":false,"server_filesystem":false,"allowedPermissions":["ui"],"scope":"authenticated-browser"})
}
pub fn check(manifest: &Value) -> Value {
let denied = manifest["permissions"]
.as_array()
.map(|permissions| {
permissions
.iter()
.filter(|p| p.as_str() != Some("ui"))
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let browser_only =
manifest["backend"].is_null() && denied.is_empty() && manifest["permissions"].is_array();
json!({"runtime":"web","browserCompatible":browser_only,"deniedPermissions":denied,"installationAvailable":false})
}
+612
View File
@@ -0,0 +1,612 @@
use crate::{
auth::Owner,
error::{Result, WebError},
state::State,
};
use axum::{
Extension, Json,
extract::{
Path, State as ExtractState, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::Response,
};
use nyaterm_core::{
config,
ssh::{
algorithms, protocol,
terminal::{self, Command, Output},
},
storage,
utils::crypto,
};
use russh::{client, keys::PublicKeyBase64};
use serde_json::{Value, json};
use std::{
sync::{
Arc, Mutex as StdMutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::{Mutex, OwnedMutexGuard, mpsc};
use tokio_util::sync::CancellationToken;
pub struct Handler {
state: Arc<State>,
owner: String,
host: String,
port: u16,
cancel: CancellationToken,
}
impl client::Handler for Handler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
key: &russh::keys::PublicKey,
) -> std::result::Result<bool, Self::Error> {
let host_id = if self.port == 22 {
self.host.clone()
} else {
format!("[{}]:{}", self.host, self.port)
};
let key_type = key.algorithm().to_string();
let encoded = key.public_key_base64();
let known = storage::check_known_host(&host_id, &key_type, &encoded)
.map_err(|_| russh::Error::UnknownKey)?;
if matches!(known, storage::KnownHostCheck::Match) {
return Ok(true);
}
let reply = self
.state
.prompt(
&self.owner,
"host-key-verify",
json!({
"host":self.host,"port":self.port,"keyType":key_type,
"fingerprint":key.fingerprint(russh::keys::HashAlg::Sha256).to_string(),
"isKeyChanged":matches!(known,storage::KnownHostCheck::HostSeen),
}),
&self.cancel,
)
.await
.map_err(|_| russh::Error::UnknownKey)?;
if reply.as_bool() != Some(true) {
return Ok(false);
}
storage::replace_known_host_for_host(&host_id, &format!("{host_id} {key_type} {encoded}"))
.map_err(|_| russh::Error::UnknownKey)?;
Ok(true)
}
}
pub struct WebSession {
pub id: String,
pub owner: String,
pub name: String,
pub host: String,
pub port: u16,
pub username: String,
pub connection_id: Option<String>,
pub request_id: Option<String>,
pub cancel: CancellationToken,
pub handle: Mutex<Option<client::Handle<Handler>>>,
pub input: mpsc::Sender<Command>,
pub output: Arc<Mutex<mpsc::Receiver<Output>>>,
pub attached: AtomicBool,
pub detached_at: StdMutex<Instant>,
pub ready: AtomicBool,
pub transfers: Arc<tokio::sync::Semaphore>,
pub sftp: config::SftpSettings,
}
impl WebSession {
pub fn info(&self) -> Value {
json!({"id":self.id,"name":self.name,"session_type":"SSH","host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","terminal_available":true,"shell_available":true,"sftp_available":self.sftp.enabled && self.ready.load(Ordering::Acquire),"runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false})
}
}
struct Target {
host: String,
port: u16,
username: String,
name: String,
mode: String,
secret: Option<zeroize::Zeroizing<String>>,
key: Option<String>,
passphrase: Option<zeroize::Zeroizing<String>>,
preferences: Option<config::SshAlgorithmPreferences>,
connection_id: Option<String>,
terminal_type: config::SshTerminalType,
sftp: config::SftpSettings,
}
fn require_utf8(encoding: &str) -> Result<()> {
let effective = if encoding.is_empty() || encoding.eq_ignore_ascii_case("global") {
config::load_app_settings(&())?.interaction.default_encoding
} else {
encoding.to_owned()
};
if !matches!(effective.to_lowercase().as_str(), "utf-8" | "utf8") {
return Err(WebError::bad("Web SSH requires UTF-8 terminal encoding"));
}
Ok(())
}
fn validate_sftp(settings: &config::SftpSettings) -> Result<()> {
if settings.enabled {
if settings.compatibility_mode {
return Err(WebError::unsupported());
}
if !settings.filename_encoding.is_empty() {
require_utf8(&settings.filename_encoding)?;
}
}
Ok(())
}
fn target(args: &Value) -> Result<Target> {
if let Some(id) = args["connectionId"].as_str() {
let conn = config::load_connection_by_id(&(), id)?;
let config::ConnectionType::Ssh {
host,
port,
username,
x11_forwarding,
agent_forwarding_config,
encoding,
..
} = &conn.config
else {
return Err(WebError::unsupported());
};
if *x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled) {
return Err(WebError::unsupported());
}
require_utf8(encoding)?;
validate_sftp(&conn.sftp)?;
if conn.post_login.as_ref().is_some_and(|p| p.enabled)
|| conn.ssh_profile != config::SshProfile::Standard
{
return Err(WebError::unsupported());
}
let terminal_type =
config::resolve_ssh_terminal_type(&conn.ssh_profile, conn.terminal_type.as_ref());
// Unsupported network paths must not silently become direct connections.
if conn
.network
.as_ref()
.is_some_and(|n| n.proxy_id.is_some() || n.proxy_jump_id.is_some())
{
return Err(WebError::unsupported());
}
let account = conn
.auth
.as_ref()
.map(|a| {
config::load_saved_account(&(), a.account_id.as_deref(), a.password_id.as_deref())
})
.transpose()?
.flatten();
let username = config::resolve_account_username(account.as_ref(), username);
let mut secret = None;
let mut key = None;
let mut passphrase = None;
let mode = conn
.auth
.as_ref()
.map(|a| a.mode.clone())
.unwrap_or_else(|| "password".into());
if let Some(auth) = &conn.auth {
secret = match &auth.password {
Some(ciphertext) => Some(crypto::decrypt(ciphertext)?),
None if auth.password_source.as_deref() != Some("connection") => {
config::decrypt_account_password(account.as_ref())?
}
_ => None,
};
if let Some(key_id) = &auth.key_id {
let saved = config::load_key_by_id(&(), key_id)?;
if saved.cert.is_some() {
return Err(WebError::unsupported());
}
key = saved.key.as_deref().map(crypto::decrypt).transpose()?;
passphrase = saved.passphrase;
}
}
Ok(Target {
host: host.clone(),
port: *port,
username,
name: conn.name,
mode,
secret: secret.map(zeroize::Zeroizing::new),
key,
passphrase: passphrase.map(zeroize::Zeroizing::new),
preferences: conn.ssh_algorithms,
connection_id: Some(id.into()),
terminal_type,
sftp: conn.sftp,
})
} else {
let value = &args["config"];
if !value["proxy"].is_null()
|| !value["proxy_jump"].is_null()
|| value["x11_forwarding"].as_bool() == Some(true)
{
return Err(WebError::unsupported());
}
if value["agent_forwarding_config"]["enabled"].as_bool() == Some(true)
|| !value["auth"]["cert_data"].is_null()
{
return Err(WebError::unsupported());
}
require_utf8(value["encoding"].as_str().unwrap_or(""))?;
let sftp: config::SftpSettings = value
.get("sftp")
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()?
.unwrap_or_default();
validate_sftp(&sftp)?;
if value["ssh_profile"]
.as_str()
.is_some_and(|p| p != "standard")
{
return Err(WebError::unsupported());
}
let terminal_type = value
.get("terminal_type")
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()?
.unwrap_or(config::SshTerminalType::Xterm256Color);
let port = match value["port"].as_u64() {
Some(p) => u16::try_from(p)
.ok()
.filter(|p| *p > 0)
.ok_or(WebError::bad("Invalid SSH port"))?,
None if value["port"].is_null() => 22,
_ => return Err(WebError::bad("Invalid SSH port")),
};
Ok(Target {
host: value["host"]
.as_str()
.ok_or(WebError::bad("SSH host required"))?
.into(),
port,
username: value["username"].as_str().unwrap_or("root").into(),
name: value["name"].as_str().unwrap_or("SSH").into(),
mode: value["auth"]["type"].as_str().unwrap_or("password").into(),
secret: value["auth"]["password"]
.as_str()
.map(|v| zeroize::Zeroizing::new(v.into())),
key: value["auth"]["key_data"].as_str().map(String::from),
passphrase: value["auth"]["passphrase"]
.as_str()
.map(|v| zeroize::Zeroizing::new(v.into())),
preferences: value
.get("ssh_algorithms")
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()?,
connection_id: None,
terminal_type,
sftp,
})
}
}
pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<String> {
if args["startupCommand"].as_object().is_some()
|| args["runtimeMode"]
.as_str()
.is_some_and(|m| m != "standard")
|| args["config"]["post_login"]["enabled"].as_bool() == Some(true)
{
return Err(WebError::unsupported());
}
let target = target(&args)?;
if !matches!(target.mode.as_str(), "none" | "password" | "key") {
return Err(WebError::unsupported());
}
if target.host.is_empty()
|| target.host.len() > 253
|| target
.host
.chars()
.any(|c| c.is_whitespace() || c.is_control())
|| target.port == 0
|| target.username.is_empty()
|| target.username.len() > 256
|| target.username.contains('\0')
{
return Err(WebError::bad("Invalid SSH target"));
}
let login = state.login(owner).await?;
let (input, commands) = mpsc::channel(128);
let (sender, output) = mpsc::channel(terminal::OUTPUT_CAPACITY);
let id = uuid::Uuid::new_v4().to_string();
let session = Arc::new(WebSession {
id: id.clone(),
owner: owner.into(),
name: target.name.clone(),
host: target.host.clone(),
port: target.port,
username: target.username.clone(),
connection_id: target.connection_id.clone(),
request_id: args["createRequestId"].as_str().map(String::from),
cancel: login.cancel.child_token(),
handle: Mutex::new(None),
input,
output: Arc::new(Mutex::new(output)),
attached: AtomicBool::new(false),
detached_at: StdMutex::new(Instant::now()),
ready: AtomicBool::new(false),
transfers: Arc::new(tokio::sync::Semaphore::new(4)),
sftp: target.sftp.clone(),
});
let mut registry = state.sessions.lock().await;
if registry.len() >= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 {
return Err(WebError::bad("Too many sessions"));
}
registry.insert(id.clone(), session.clone());
drop(registry);
tokio::spawn(async move {
let result = tokio::select! {
_ = session.cancel.cancelled() => Err(WebError::bad("Session creation cancelled")),
result = tokio::time::timeout(Duration::from_secs(120), connect(&state,&session,target)) => result.unwrap_or(Err(WebError::bad("SSH connection timed out"))),
};
match result {
Ok(channel) => {
session.ready.store(true, Ordering::Release);
state
.event(&session.owner, "sessions-changed", Value::Null)
.await;
terminal::run(channel, commands, sender, session.cancel.clone()).await;
}
Err(_) => {
state
.event(
&session.owner,
&format!("connection-error-{}", session.id),
json!("SSH connection failed or authentication was cancelled"),
)
.await;
let _ = sender.try_send(Output::Error);
}
}
session.cancel.cancel();
if let Some(handle) = session.handle.lock().await.take() {
let _ = tokio::time::timeout(
Duration::from_secs(2),
handle.disconnect(russh::Disconnect::ByApplication, "", ""),
)
.await;
}
state.sessions.lock().await.remove(&session.id);
// Attached terminals receive ordered closure on the WS after final bytes.
if !session.attached.load(Ordering::Acquire) {
state
.event(
&session.owner,
&format!("session-closed-{}", session.id),
Value::Null,
)
.await;
}
state
.event(&session.owner, "sessions-changed", Value::Null)
.await;
});
Ok(id)
}
async fn connect(
state: &Arc<State>,
session: &Arc<WebSession>,
mut target: Target,
) -> Result<russh::Channel<client::Msg>> {
let client_config = client::Config {
preferred: algorithms::resolve_preferred_algorithms(target.preferences.as_ref())?,
keepalive_interval: Some(Duration::from_secs(30)),
keepalive_max: 3,
..Default::default()
};
let handler = Handler {
state: state.clone(),
owner: session.owner.clone(),
host: target.host.clone(),
port: target.port,
cancel: session.cancel.clone(),
};
let handle =
protocol::connect(Arc::new(client_config), &target.host, target.port, handler).await?;
let mut locked = session.handle.lock().await;
*locked = Some(handle);
let handle = locked.as_mut().unwrap();
let mut authenticated = false;
if target.mode == "none" {
authenticated = handle.authenticate_none(&target.username).await?.success();
}
if target.mode == "key" {
let key_data = zeroize::Zeroizing::new(
target
.key
.take()
.ok_or(WebError::bad("Private key required"))?,
);
let mut decoded = russh::keys::decode_secret_key(
&key_data,
target.passphrase.as_deref().map(|p| p.as_str()),
);
if decoded.is_err() && target.passphrase.is_none() {
let response=state.prompt(&session.owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),&session.cancel).await?;
target.passphrase = response["secret"]
.as_str()
.map(|s| zeroize::Zeroizing::new(s.into()));
decoded = russh::keys::decode_secret_key(
&key_data,
target.passphrase.as_deref().map(|p| p.as_str()),
);
}
let key = decoded.map_err(|_| WebError::bad("Private key could not be unlocked"))?;
let hash = handle.best_supported_rsa_hash().await?.flatten();
authenticated = handle
.authenticate_publickey(
&target.username,
russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash),
)
.await?
.success();
}
if !authenticated && target.mode != "key" {
for attempt in 1..=3 {
if target.secret.is_none() {
let response = state.prompt(&session.owner,"ssh-auth-request",json!({
"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,
"reason":if attempt == 1 {"missing_password"} else {"password_rejected"},"promptKind":"password","availableMethods":["password"],"currentAuthMode":"password","attempt":attempt,"canSave":false,"accountId":null,
}),&session.cancel).await?;
target.secret = response["secret"]
.as_str()
.map(|s| zeroize::Zeroizing::new(s.into()));
}
let Some(secret) = &target.secret else {
return Err(WebError::bad("SSH authentication cancelled"));
};
let result = protocol::password(handle, &target.username, secret).await?;
if result.success() {
authenticated = true;
break;
}
if let russh::client::AuthResult::Failure {
remaining_methods, ..
} = result
{
if remaining_methods.contains(&russh::MethodKind::KeyboardInteractive) {
authenticated = keyboard_auth(state, session, handle, &target).await?;
break;
}
}
target.secret = None;
}
}
if !authenticated && target.mode == "key" {
authenticated = keyboard_auth(state, session, handle, &target).await?;
}
if !authenticated {
return Err(WebError::bad("SSH authentication failed"));
}
let channel =
protocol::open_shell_with_terminal(handle, 80, 24, target.terminal_type.as_str()).await?;
if let Some(id) = &target.connection_id {
storage::mark_connection_used(id)?;
}
Ok(channel)
}
async fn keyboard_auth(
state: &Arc<State>,
session: &Arc<WebSession>,
handle: &mut client::Handle<Handler>,
target: &Target,
) -> Result<bool> {
let mut step = protocol::keyboard_start(handle, &target.username).await?;
for round in 1..=8 {
match step {
client::KeyboardInteractiveAuthResponse::Success => return Ok(true),
client::KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
client::KeyboardInteractiveAuthResponse::InfoRequest {
name,
instructions,
prompts,
} => {
if prompts.len() > 16 {
return Err(WebError::bad("Too many authentication prompts"));
}
let response=state.prompt(&session.owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::<Vec<_>>(),"otpEntryId":null}),&session.cancel).await?;
let responses: Vec<String> = serde_json::from_value(response)?;
if responses.len() != prompts.len() {
return Err(WebError::bad("Invalid authentication response"));
}
step = protocol::keyboard_respond(handle, responses).await?;
}
}
}
Err(WebError::bad("Too many authentication rounds"))
}
pub async fn create_route(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Json(args): Json<Value>,
) -> Result<Json<Value>> {
Ok(Json(
json!({"session_id":create(state,&owner.0,args).await?}),
))
}
pub async fn ws_route(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Path(id): Path<String>,
upgrade: WebSocketUpgrade,
) -> Result<Response> {
let session = state.session(&owner.0, &id).await?;
let receiver = session.output.clone().try_lock_owned().map_err(|_| {
WebError(
axum::http::StatusCode::CONFLICT,
"Terminal already attached".into(),
)
})?;
Ok(upgrade
.max_message_size(64 * 1024)
.max_frame_size(64 * 1024)
.max_write_buffer_size(256 * 1024)
.on_upgrade(move |socket| socket_loop(session, receiver, socket)))
}
struct Attachment(Arc<WebSession>);
impl Drop for Attachment {
fn drop(&mut self) {
*self.0.detached_at.lock().unwrap() = Instant::now();
self.0.attached.store(false, Ordering::Release);
}
}
async fn socket_loop(
session: Arc<WebSession>,
mut output: OwnedMutexGuard<mpsc::Receiver<Output>>,
mut socket: WebSocket,
) {
session.attached.store(true, Ordering::Release);
let _attachment = Attachment(session.clone());
let mut heartbeat = tokio::time::interval(Duration::from_secs(15));
let mut last_peer = Instant::now();
loop {
tokio::select! {
biased;
next = output.recv() => {
let final_frame = !matches!(&next, Some(Output::Data(_)));
let message = match next {
Some(Output::Data(data)) => Message::Binary(data.into()),
Some(Output::Error) => Message::Text(json!({"type":"error","error":"SSH connection failed"}).to_string().into()),
_ => Message::Text(json!({"type":"closed"}).to_string().into()),
};
if !matches!(tokio::time::timeout(Duration::from_secs(10),socket.send(message)).await,Ok(Ok(()))) { session.cancel.cancel(); break; }
if final_frame { break; }
},
_ = session.cancel.cancelled() => { let _ = tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Text(json!({"type":"closed"}).to_string().into()))).await; break; },
message = socket.recv() => match message {
Some(Ok(Message::Text(text))) => {
last_peer = Instant::now();
let Ok(value) = serde_json::from_str::<Value>(&text) else { break; };
let command = match value["type"].as_str() {
Some("input") => value["data"].as_str().map(|s| Command::Input(s.as_bytes().to_vec())),
Some("resize") => value["cols"].as_u64().zip(value["rows"].as_u64()).filter(|(c,r)| (1..=1000).contains(c)&&(1..=1000).contains(r)).map(|(c,r)| Command::Resize(c as u32,r as u32)),
Some("close") => { session.cancel.cancel(); break; },
_ => None,
};
let Some(command) = command else { break; };
if session.input.try_send(command).is_err() { break; }
},
Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::Input(bytes.to_vec())).is_err() { break; } },
Some(Ok(Message::Pong(_))) => last_peer=Instant::now(),
Some(Ok(Message::Ping(data))) => { if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Pong(data))).await,Ok(Ok(()))) { break; } },
_ => break,
},
_ = heartbeat.tick() => {
if last_peer.elapsed() > Duration::from_secs(45) { break; }
if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Ping(Vec::new().into()))).await,Ok(Ok(()))) { break; }
}
}
}
}
+468
View File
@@ -0,0 +1,468 @@
use crate::{
auth::Owner,
error::{Result, WebError},
session::WebSession,
state::State,
};
use axum::{
Extension, Json,
body::Body,
extract::{Path, Query, State as ExtractState},
http::header,
response::Response,
};
use nyaterm_core::ssh::{files, protocol};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::io::ReaderStream;
pub fn supports(command: &str) -> bool {
matches!(
command,
"get_home_dir"
| "list_remote_dir"
| "list_remote_child_directories"
| "delete_remote_file"
| "rename_remote_file"
| "create_remote_dir"
| "create_remote_file"
| "read_remote_file_text"
| "open_remote_file_text"
| "write_remote_file_text"
| "get_file_properties"
| "get_remote_file_stat"
)
}
pub async fn open(session: &WebSession) -> Result<russh_sftp::client::SftpSession> {
if !session.sftp.enabled {
return Err(WebError::unsupported());
}
if !session.ready.load(std::sync::atomic::Ordering::Acquire) {
return Err(WebError::bad("SSH is still connecting"));
}
let operation = async {
let mut handle = session.handle.lock().await;
let handle = handle
.as_mut()
.ok_or(WebError::bad("SSH is disconnected"))?;
let config = russh_sftp::client::Config {
max_concurrent_writes: session.sftp.pipeline_depth.unwrap_or(8) as usize,
..Default::default()
};
Ok(protocol::open_sftp_with_config(handle, config).await?)
};
tokio::select! {
_ = session.cancel.cancelled() => Err(WebError::bad("Session closed")),
result = tokio::time::timeout(Duration::from_secs(30), operation) => result.map_err(|_| WebError::bad("SFTP timed out"))?,
}
}
fn validate_path(path: &str) -> Result<&str> {
if path.is_empty() || path.contains('\0') || path.len() > 4096 {
Err(WebError::bad("Invalid remote path"))
} else {
Ok(path)
}
}
fn path<'a>(args: &'a Value, name: &str) -> Result<&'a str> {
validate_path(
args[name]
.as_str()
.ok_or(WebError::bad("Remote path required"))?,
)
}
fn entry(name: String, attrs: &russh_sftp::protocol::FileAttributes) -> files::FileEntry {
files::FileEntry {
name,
is_dir: attrs.is_dir(),
is_symlink: attrs.is_symlink(),
size: attrs.size.unwrap_or(0),
permissions: files::describe_permissions(attrs.permissions),
owner: files::owner_or_id(&attrs.user, attrs.uid),
group: files::group_or_id(&attrs.group, attrs.gid),
mtime: attrs.mtime.unwrap_or(0) as u64,
raw_path_token: None,
}
}
pub async fn command(
state: &Arc<State>,
owner: &str,
command: &str,
args: &Value,
) -> Result<Value> {
// Raw filename encodings and recursive/native workflows have no Web MVP contract.
if !args["rawPathToken"].is_null() {
return Err(WebError::unsupported());
}
let session = state.session(owner, path(args, "sessionId")?).await?;
let _permit = session
.transfers
.clone()
.try_acquire_owned()
.map_err(|_| WebError::bad("Too many SFTP operations"))?;
let sftp = open(&session).await?;
let operation = async {
Ok(match command {
"get_home_dir" => json!(sftp.canonicalize(".").await?),
"list_remote_dir" | "list_remote_child_directories" => {
let parent = path(args, "path")?;
let entries = sftp.read_dir(parent).await?.collect::<Vec<_>>();
if entries
.iter()
.any(|e| std::str::from_utf8(e.file_name_bytes()).is_err())
{
return Err(WebError::bad("Web SFTP requires UTF-8 filenames"));
}
let entries = entries.into_iter();
let entries = entries.filter(|e| e.file_name() != "." && e.file_name() != "..");
if command == "list_remote_child_directories" {
json!(
entries
.filter(|e| e.metadata().is_dir()
&& (args["showHiddenFiles"].as_bool() == Some(true)
|| !e.file_name().starts_with('.')))
.map(|e| files::DirectoryChild {
name: e.file_name(),
path: format!("{}/{}", parent.trim_end_matches('/'), e.file_name()),
is_symlink: e.metadata().is_symlink(),
raw_path_token: None
})
.collect::<Vec<_>>()
)
} else {
json!(
entries
.map(|e| entry(e.file_name(), &e.metadata()))
.collect::<Vec<_>>()
)
}
}
"delete_remote_file" => {
let p = path(args, "path")?;
if sftp.symlink_metadata(p).await?.is_dir() {
sftp.remove_dir(p).await?;
} else {
sftp.remove_file(p).await?;
}
Value::Null
}
"rename_remote_file" => {
sftp.rename(path(args, "oldPath")?, path(args, "newPath")?)
.await?;
Value::Null
}
"create_remote_dir" => {
sftp.create_dir(path(args, "path")?).await?;
Value::Null
}
"create_remote_file" => {
sftp.create(path(args, "path")?)
.await?
.shutdown()
.await
.map_err(|_| WebError::bad("Remote write failed"))?;
Value::Null
}
"read_remote_file_text" | "open_remote_file_text" => {
let p = path(args, "path")?;
let attrs = sftp.metadata(p).await?;
let mut file = sftp.open(p).await?;
let limit = args["maxBytes"]
.as_u64()
.unwrap_or(1024 * 1024)
.min(4 * 1024 * 1024);
let mut bytes = Vec::new();
(&mut file)
.take(limit + 1)
.read_to_end(&mut bytes)
.await
.map_err(|_| WebError::bad("Remote read failed"))?;
if bytes.len() as u64 > limit {
return Err(WebError::bad("Remote file is too large"));
}
let result = files::classify_text_file(files::RemoteBinaryFile {
path: p.into(),
size: bytes.len() as u64,
mtime: attrs.mtime.unwrap_or(0) as u64,
mtime_nanos: None,
content_bytes: bytes,
});
if command == "open_remote_file_text" {
json!(result)
} else {
match result {
files::TextFileOpenResult::Text { file } => json!(file),
_ => return Err(WebError::bad("Remote file is not UTF-8 text")),
}
}
}
"write_remote_file_text" => {
let p = path(args, "path")?;
let content = args["content"]
.as_str()
.ok_or(WebError::bad("Content required"))?;
let attrs = sftp.metadata(p).await?;
if args["force"].as_bool() != Some(true) {
let changed = args["expectedMtime"]
.as_u64()
.is_some_and(|t| t != attrs.mtime.unwrap_or(0) as u64)
|| args["expectedSize"]
.as_u64()
.is_some_and(|s| s != attrs.size.unwrap_or(0));
let mut hash_changed = false;
if let Some(expected) = args["expectedHash"].as_str() {
let mut old = Vec::new();
sftp.open(p)
.await?
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut old)
.await
.map_err(|_| WebError::bad("Remote read failed"))?;
hash_changed = files::content_hash(&old) != expected;
}
if changed || hash_changed {
return Ok(json!(files::WriteRemoteTextResult::conflict(
attrs.mtime.unwrap_or(0) as u64,
attrs.size.unwrap_or(0),
None
)));
}
}
let mut file = sftp.create(p).await?;
file.write_all(content.as_bytes())
.await
.map_err(|_| WebError::bad("Remote write failed"))?;
file.shutdown()
.await
.map_err(|_| WebError::bad("Remote write failed"))?;
let attrs = sftp.metadata(p).await?;
json!(files::WriteRemoteTextResult::saved(
attrs.mtime.unwrap_or(0) as u64,
content.len() as u64,
None,
files::content_hash(content.as_bytes())
))
}
"get_file_properties" | "get_remote_file_stat" => {
let p = path(args, "path")?;
let attrs = sftp.symlink_metadata(p).await?;
let base = entry(p.rsplit('/').next().unwrap_or(p).into(), &attrs);
json!(files::FileProperties {
name: base.name,
is_dir: base.is_dir,
is_symlink: base.is_symlink,
symlink_target: if attrs.is_symlink() {
sftp.read_link(p).await.ok()
} else {
None
},
size: base.size,
permissions: base.permissions,
owner: base.owner,
group: base.group,
uid: attrs.uid.map(|v| v.to_string()).unwrap_or_default(),
gid: attrs.gid.map(|v| v.to_string()).unwrap_or_default(),
mtime: base.mtime,
atime: attrs.atime.unwrap_or(0) as u64
})
}
_ => return Err(WebError::unsupported()),
})
};
let result = tokio::select! {_ = session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(30),operation)=>result.map_err(|_|WebError::bad("SFTP timed out"))?};
let _ = tokio::time::timeout(Duration::from_secs(2), sftp.close()).await;
result
}
#[derive(Deserialize)]
pub struct TransferPath {
path: String,
}
struct Transfer {
state: Arc<State>,
owner: String,
session_id: String,
id: String,
path: String,
direction: &'static str,
total: u64,
bytes: u64,
sftp: Arc<russh_sftp::client::SftpSession>,
temporary: Option<String>,
done: bool,
last_progress: std::time::Instant,
}
impl Transfer {
async fn progress(&mut self, status: &str) {
if status == "progress" && self.last_progress.elapsed() < Duration::from_millis(200) {
return;
}
self.last_progress = std::time::Instant::now();
self.state.event(&self.owner,"transfer-event",json!({"id":self.id,"session_id":self.session_id,"file_name":self.path.rsplit('/').next().unwrap_or("file"),"remote_path":self.path,"local_path":"","direction":self.direction,"kind":"file","bytes_transferred":self.bytes,"total_size":self.total,"size":self.total,"status":status})).await;
}
async fn finish(&mut self, status: &str) {
self.progress(status).await;
self.done = true;
}
}
impl Drop for Transfer {
fn drop(&mut self) {
let sftp = self.sftp.clone();
let temporary = self.temporary.take();
let state = self.state.clone();
let owner = self.owner.clone();
let id = self.id.clone();
let sid = self.session_id.clone();
let done = self.done;
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(5), async {
if let Some(path) = temporary {
let _ = sftp.remove_file(path).await;
}
let _ = sftp.close().await;
})
.await;
if !done {
state
.event(
&owner,
"transfer-event",
json!({"id":id,"session_id":sid,"status":"cancelled"}),
)
.await;
}
});
}
}
pub async fn download(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Path(id): Path<String>,
Query(query): Query<TransferPath>,
) -> Result<Response> {
validate_path(&query.path)?;
let session = state.session(&owner.0, &id).await?;
let permit = session
.transfers
.clone()
.try_acquire_owned()
.map_err(|_| WebError::bad("Too many transfers"))?;
let sftp = Arc::new(open(&session).await?);
let total = sftp.metadata(&query.path).await?.size.unwrap_or(0);
let file = sftp.open(&query.path).await?;
let mut transfer = Transfer {
state,
owner: owner.0,
session_id: id,
id: uuid::Uuid::new_v4().to_string(),
path: query.path.clone(),
direction: "download",
total,
bytes: 0,
sftp,
temporary: None,
done: false,
last_progress: std::time::Instant::now(),
};
let cancel = session.cancel.clone();
use futures_util::StreamExt;
let stream = async_stream::try_stream! {
let _permit=permit;let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await;
loop {
let next=tokio::select!{_ = cancel.cancelled()=>Some(Err(std::io::Error::other("Session closed"))),result=tokio::time::timeout(Duration::from_secs(30),chunks.next())=>result.unwrap_or(Some(Err(std::io::Error::other("SFTP download timed out"))))};
match next { Some(Ok(bytes))=>{transfer.bytes+=bytes.len() as u64;transfer.progress("progress").await;yield bytes;},Some(Err(error))=>{transfer.finish("error").await;Err(error)?;},None=>break }
}
if transfer.bytes!=transfer.total {transfer.finish("error").await;Err(std::io::Error::other("Remote file changed during download"))?;}
transfer.finish("completed").await;
};
let safe =
files::sanitize_download_file_name(query.path.rsplit('/').next().unwrap_or("download"))
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || ".-_%".contains(c) {
c
} else {
'_'
}
})
.collect::<String>();
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, total)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{safe}\""),
)
.body(Body::from_stream(
stream.map(|r: std::io::Result<axum::body::Bytes>| r),
))
.unwrap())
}
pub async fn upload(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Path(id): Path<String>,
Query(query): Query<TransferPath>,
request: axum::extract::Request,
) -> Result<Json<Value>> {
use futures_util::StreamExt;
validate_path(&query.path)?;
let session = state.session(&owner.0, &id).await?;
let _permit = session
.transfers
.clone()
.try_acquire_owned()
.map_err(|_| WebError::bad("Too many transfers"))?;
let sftp = Arc::new(open(&session).await?);
let transfer_id = uuid::Uuid::new_v4().to_string();
let temporary = format!("{}.nyaterm-{}.part", query.path, transfer_id);
let mut transfer = Transfer {
state,
owner: owner.0,
session_id: id,
id: transfer_id,
path: query.path.clone(),
direction: "upload",
total: request
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0),
bytes: 0,
sftp: sftp.clone(),
temporary: Some(temporary.clone()),
done: false,
last_progress: std::time::Instant::now(),
};
let mut file = sftp.create(&temporary).await?;
let mut stream = request.into_body().into_data_stream();
let copy = async {
transfer.progress("started").await;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| WebError::bad("Upload interrupted"))?;
transfer.bytes += chunk.len() as u64;
if transfer.bytes > 1024 * 1024 * 1024 {
return Err(WebError::bad("Upload exceeds 1 GiB"));
}
file.write_all(&chunk)
.await
.map_err(|_| WebError::bad("SFTP write failed"))?;
transfer.progress("progress").await;
}
file.shutdown()
.await
.map_err(|_| WebError::bad("SFTP write failed"))?;
sftp.rename(&temporary, &query.path).await?;
Ok::<_, WebError>(())
};
let result = tokio::select! {_ = session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(3600),copy)=>result.unwrap_or(Err(WebError::bad("Upload timed out")))};
if let Err(error) = result {
transfer.finish("error").await;
return Err(error);
}
transfer.temporary = None;
transfer.total = transfer.bytes;
transfer.finish("completed").await;
Ok(Json(json!({"bytes":transfer.bytes})))
}
+129
View File
@@ -0,0 +1,129 @@
use crate::{
error::{Result, WebError},
session::WebSession,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::{Mutex, broadcast, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Serialize, Deserialize)]
pub struct Event {
pub event: String,
pub payload: Value,
}
pub struct Login {
pub csrf: String,
pub expires: Instant,
pub events: broadcast::Sender<Event>,
pub cancel: CancellationToken,
}
pub struct Prompt {
pub owner: String,
pub reply: oneshot::Sender<Value>,
}
pub struct State {
pub origin: String,
pub host: String,
pub base_path: String,
pub secure_cookie: bool,
pub password_hash: [u8; 32],
pub logins: Mutex<HashMap<String, Arc<Login>>>,
pub sessions: Mutex<HashMap<String, Arc<WebSession>>>,
pub prompts: Mutex<HashMap<String, Prompt>>,
pub ai_streams: Mutex<HashMap<(String, String), CancellationToken>>,
pub login_attempts: Mutex<Vec<Instant>>,
pub mutation: Mutex<()>,
pub shutdown: CancellationToken,
}
impl State {
pub async fn login(&self, owner: &str) -> Result<Arc<Login>> {
self.logins
.lock()
.await
.get(owner)
.filter(|login| login.expires > Instant::now() && !login.cancel.is_cancelled())
.cloned()
.ok_or(WebError(
axum::http::StatusCode::UNAUTHORIZED,
"Sign in required".into(),
))
}
pub async fn event(&self, owner: &str, event: &str, payload: Value) {
if let Ok(login) = self.login(owner).await {
let _ = login.events.send(Event {
event: event.into(),
payload,
});
}
}
pub async fn broadcast(&self, event: &str, payload: Value) {
for login in self.logins.lock().await.values() {
let _ = login.events.send(Event {
event: event.into(),
payload: payload.clone(),
});
}
}
pub async fn prompt(
&self,
owner: &str,
event: &str,
mut payload: Value,
cancel: &CancellationToken,
) -> Result<Value> {
let id = uuid::Uuid::new_v4().to_string();
let (reply, rx) = oneshot::channel();
payload["requestId"] = Value::String(id.clone());
payload["targetWindowLabel"] = Value::Null;
self.prompts.lock().await.insert(
id.clone(),
Prompt {
owner: owner.into(),
reply,
},
);
self.event(owner, event, payload).await;
let result = tokio::select! {
_ = cancel.cancelled() => Err(WebError::bad("Session creation cancelled")),
response = tokio::time::timeout(Duration::from_secs(90), rx) => response.ok().and_then(|r| r.ok()).ok_or(WebError::bad("Authentication prompt expired")),
};
self.prompts.lock().await.remove(&id);
result
}
pub async fn session(&self, owner: &str, id: &str) -> Result<Arc<WebSession>> {
self.sessions
.lock()
.await
.get(id)
.filter(|s| s.owner == owner && !s.cancel.is_cancelled())
.cloned()
.ok_or(WebError(
axum::http::StatusCode::NOT_FOUND,
"Session not found".into(),
))
}
pub async fn close_owner(&self, owner: &str) {
if let Some(login) = self.logins.lock().await.remove(owner) {
login.cancel.cancel();
}
for session in self
.sessions
.lock()
.await
.values()
.filter(|s| s.owner == owner)
{
session.cancel.cancel();
}
self.prompts
.lock()
.await
.retain(|_, prompt| prompt.owner != owner);
}
}
@@ -0,0 +1,116 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::json;
use std::{
path::Path,
process::{Child, Command, Stdio},
time::Duration,
};
struct Process(Child);
impl Drop for Process {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn command(data: &Path, dist: &Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_nyaterm-web"));
cmd.env_remove("NYATERM_WEB_PASSWORD")
.env_remove("NYATERM_WEB_PASSWORD_FILE")
.env_remove("NYATERM_WEB_ENCRYPTION_KEY")
.env_remove("NYATERM_WEB_ENCRYPTION_KEY_FILE")
.env("NYATERM_WEB_DATA_DIR", data)
.env("NYATERM_WEB_DIST", dist)
.stdout(Stdio::null())
.stderr(Stdio::piped());
cmd
}
#[tokio::test]
async fn bootstrap_uses_external_secret_files_and_rejects_wrong_key() {
let temporary = tempfile::tempdir().unwrap();
let data = temporary.path().join("data");
let dist = temporary.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("index.html"), "<html>NyaTerm Web</html>").unwrap();
let password = "test-login-secret-at-least-32-characters";
let key = STANDARD.encode([19u8; 32]);
let password_file = temporary.path().join("password");
let key_file = temporary.path().join("encryption");
std::fs::write(&password_file, password).unwrap();
std::fs::write(&key_file, &key).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let host = listener.local_addr().unwrap();
drop(listener);
let origin = format!("http://{host}");
let mut process = Process(
command(&data, &dist)
.env("NYATERM_WEB_PASSWORD_FILE", &password_file)
.env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file)
.env("NYATERM_WEB_PUBLIC_URL", format!("{origin}/terminal/"))
.env("NYATERM_WEB_BIND", host.to_string())
.spawn()
.unwrap(),
);
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let mut ready = false;
for _ in 0..100 {
if let Ok(response) = client.get(format!("{origin}/terminal/")).send().await {
let status = response.status();
let body = response.text().await.unwrap();
assert_eq!(status, 200, "Unexpected static response: {body}");
assert!(body.contains("NyaTerm"));
ready = true;
break;
}
if let Some(status) = process.0.try_wait().unwrap() {
panic!("Server exited during startup: {status}");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(ready, "Server did not start");
let response = client
.post(format!("{origin}/terminal/api/auth/login"))
.header("origin", &origin)
.header("x-nyaterm-request", "1")
.json(&json!({"password":password}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let cookie = response.headers()["set-cookie"]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned();
let csrf = response.json::<serde_json::Value>().await.unwrap()["csrf"]
.as_str()
.unwrap()
.to_owned();
assert_eq!(
client
.post(format!("{origin}/terminal/api/commands/list_sessions"))
.header("origin", &origin)
.header("cookie", cookie)
.header("x-nyaterm-csrf", csrf)
.json(&json!({}))
.send()
.await
.unwrap()
.status(),
200
);
process.0.kill().unwrap();
process.0.wait().unwrap();
std::fs::write(&key_file, STANDARD.encode([20u8; 32])).unwrap();
let output = command(&data, &dist)
.env("NYATERM_WEB_PASSWORD_FILE", &password_file)
.env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file)
.env("NYATERM_WEB_PUBLIC_URL", &origin)
.output()
.unwrap();
assert!(!output.status.success());
let logs = String::from_utf8_lossy(&output.stderr);
assert!(!logs.contains(password));
assert!(!logs.contains(&key));
}
File diff suppressed because it is too large. Load diff
+1 -65
View File
@@ -109,71 +109,7 @@ pub fn reveal_ai_provider_api_key(credential_id: String) -> AppResult<String> {
crypto::decrypt(encrypted_key)
}
fn merge_model_discoveries(
settings: &config::AiSettings,
discoveries: Vec<ai::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
}
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())
}
use nyaterm_core::core::ai::catalog::{merge_model_discoveries, update_default_model_id};
#[tauri::command]
pub fn cancel_ai_chat_stream(stream_id: String) -> AppResult<()> {
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more